From ccac2a9880554b1241f7187afd7bff82af688c7f Mon Sep 17 00:00:00 2001 From: ankur-paan Date: Wed, 21 Oct 2026 21:34:18 +0100 Subject: [PATCH 1/4] feat(ui): add security access control, zero-knowledge guidance, FAQ, and interactive user guide --- CHANGELOG.md | 15 + Cargo.lock | 73 +- Cargo.toml | 3 +- README.md | 4 +- crates/kuiperd/src/lib.rs | 62 + crates/rekuiper-conf/src/lib.rs | 89 + crates/rekuiper-connectors/src/http.rs | 30 +- crates/rekuiper-connectors/src/kafka.rs | 51 +- crates/rekuiper-connectors/src/lib.rs | 3 + crates/rekuiper-connectors/src/modbus.rs | 42 +- crates/rekuiper-connectors/src/mqtt.rs | 41 +- crates/rekuiper-connectors/src/opcua.rs | 39 +- crates/rekuiper-core/src/model.rs | 191 +- .../rekuiper-core/tests/test_core_actors.rs | 15 +- crates/rekuiper-mcp/README.md | 43 +- crates/rekuiper-mcp/src/prompts.rs | 87 + crates/rekuiper-mcp/src/resources.rs | 679 +- crates/rekuiper-mcp/src/tools.rs | 330 + crates/rekuiper-server/Cargo.toml | 3 + crates/rekuiper-server/src/agent.rs | 568 + crates/rekuiper-server/src/comms.rs | 312 + crates/rekuiper-server/src/declarative.rs | 338 +- crates/rekuiper-server/src/engine/dag.rs | 547 + crates/rekuiper-server/src/engine/mod.rs | 148 +- .../rekuiper-server/src/engine/operators.rs | 265 + crates/rekuiper-server/src/engine/runtime.rs | 13 +- crates/rekuiper-server/src/engine/sinks.rs | 6 +- crates/rekuiper-server/src/fleet.rs | 1552 ++ crates/rekuiper-server/src/handlers/comms.rs | 150 + .../src/handlers/connections.rs | 45 +- crates/rekuiper-server/src/handlers/data.rs | 11 +- .../rekuiper-server/src/handlers/metadata.rs | 467 + crates/rekuiper-server/src/handlers/mod.rs | 2 + crates/rekuiper-server/src/handlers/rules.rs | 165 +- crates/rekuiper-server/src/lib.rs | 74 +- crates/rekuiper-server/src/routes.rs | 210 +- crates/rekuiper-server/src/secrets.rs | 197 + crates/rekuiper-server/src/sink_cache.rs | 5 +- crates/rekuiper-server/src/state.rs | 100 +- crates/rekuiper-server/src/ui.html | 12195 ++++++++++++++-- .../rekuiper-server/tests/perf_throughput.rs | 2 + .../rekuiper-server/tests/test_otlp_and_ui.rs | 166 + .../tests/test_phase3_agent_and_rollback.rs | 464 + .../tests/test_phase4_fleet_control_plane.rs | 437 + .../tests/test_stream_engine_phase1.rs | 201 + crates/rekuiper-sql/src/eval.rs | 79 + crates/rekuiper-sql/src/parser.rs | 11 +- .../rekuiper-sql/tests/test_sql_functions.rs | 22 +- docs/.vitepress/config.mts | 6 +- docs/directory.json | 32 + docs/en_US/compare/rekuiper-vs-ekuiper.md | 12 +- docs/en_US/faq.md | 147 + .../property_forms_and_connection_hub.md | 173 + .../guide/plugins/plugins_and_services_hub.md | 268 + .../rules/designer_and_schema_builder.md | 175 + docs/en_US/guide/rules/visual_studio.md | 158 + docs/en_US/guide/schemas/schema_registry.md | 204 + docs/en_US/guide/ui/web_console_user_guide.md | 324 + docs/en_US/index.md | 4 +- docs/en_US/mcp/overview.md | 37 +- docs/en_US/operation/fleet_management.md | 354 + docs/en_US/operation/migration.md | 6 +- .../public/screenshots/console_overview.png | Bin 0 -> 231193 bytes .../screenshots/console_security_modal.png | Bin 0 -> 228312 bytes .../screenshots/rule_designer_recipes.png | Bin 0 -> 282029 bytes .../screenshots/rule_designer_window.png | Bin 0 -> 282171 bytes .../screenshots/stream_designer_templates.png | Bin 0 -> 267504 bytes .../screenshots/stream_schema_inference.png | Bin 0 -> 261720 bytes .../solutions/mcp-industrial-ai-agents.md | 8 +- docs/resources/ui/console_overview.png | Bin 0 -> 231193 bytes docs/resources/ui/console_security_modal.png | Bin 0 -> 228312 bytes docs/resources/ui/rule_designer_recipes.png | Bin 0 -> 282029 bytes docs/resources/ui/rule_designer_window.png | Bin 0 -> 282171 bytes .../ui/stream_designer_templates.png | Bin 0 -> 267504 bytes docs/resources/ui/stream_schema_inference.png | Bin 0 -> 261720 bytes scripts/build_kuiperd_wsl.sh | 25 + scripts/manage_fleet.py | 375 + scripts/rebuild_and_restart.sh | 31 + scripts/run_full_ci.sh | 55 + scripts/sync_and_test.sh | 22 + 80 files changed, 21657 insertions(+), 1311 deletions(-) create mode 100644 crates/rekuiper-server/src/agent.rs create mode 100644 crates/rekuiper-server/src/comms.rs create mode 100644 crates/rekuiper-server/src/engine/dag.rs create mode 100644 crates/rekuiper-server/src/engine/operators.rs create mode 100644 crates/rekuiper-server/src/fleet.rs create mode 100644 crates/rekuiper-server/src/handlers/comms.rs create mode 100644 crates/rekuiper-server/src/secrets.rs create mode 100644 crates/rekuiper-server/tests/test_phase3_agent_and_rollback.rs create mode 100644 crates/rekuiper-server/tests/test_phase4_fleet_control_plane.rs create mode 100644 crates/rekuiper-server/tests/test_stream_engine_phase1.rs create mode 100644 docs/en_US/faq.md create mode 100644 docs/en_US/guide/connections/property_forms_and_connection_hub.md create mode 100644 docs/en_US/guide/plugins/plugins_and_services_hub.md create mode 100644 docs/en_US/guide/rules/designer_and_schema_builder.md create mode 100644 docs/en_US/guide/rules/visual_studio.md create mode 100644 docs/en_US/guide/schemas/schema_registry.md create mode 100644 docs/en_US/guide/ui/web_console_user_guide.md create mode 100644 docs/en_US/operation/fleet_management.md create mode 100644 docs/en_US/public/screenshots/console_overview.png create mode 100644 docs/en_US/public/screenshots/console_security_modal.png create mode 100644 docs/en_US/public/screenshots/rule_designer_recipes.png create mode 100644 docs/en_US/public/screenshots/rule_designer_window.png create mode 100644 docs/en_US/public/screenshots/stream_designer_templates.png create mode 100644 docs/en_US/public/screenshots/stream_schema_inference.png create mode 100644 docs/resources/ui/console_overview.png create mode 100644 docs/resources/ui/console_security_modal.png create mode 100644 docs/resources/ui/rule_designer_recipes.png create mode 100644 docs/resources/ui/rule_designer_window.png create mode 100644 docs/resources/ui/stream_designer_templates.png create mode 100644 docs/resources/ui/stream_schema_inference.png create mode 100644 scripts/build_kuiperd_wsl.sh create mode 100644 scripts/manage_fleet.py create mode 100644 scripts/rebuild_and_restart.sh create mode 100644 scripts/run_full_ci.sh create mode 100644 scripts/sync_and_test.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 4074900c..30129d06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > See `BENCHMARK-AUDIT.md` and `test/BENCHMARKS.md` for the audited methodology. > No latency-histogram p99 is claimed. +## [Unreleased] - Rekuiper Manager & Visual Stream Studio (Phases 1–4) + +### Added +- **Zero-Copy Record Metadata (`RecordMeta`)**: Added an immutable, `Arc`-shared `RecordMeta` structure (`topic`, `source`, `custom`) to `StreamRecord`. Metadata passes through SQL projection pipelines and multi-port graph stages with zero map allocations and constant time ($O(1)$) overhead. Added zero-copy `meta()` SQL function with backward-compatible `__meta__` and JSON envelope fallback. +- **Multi-Port DAG Execution Engine**: Added native Directed Acyclic Graph (DAG) rule routing with port-indexed selective edge dispatch (`EdgeTargets::Multi`). Nodes selectively route records across output ports `0..N` based on ordered predicates without broadcast flattening. +- **Native Stream Control Operators**: Added `SwitchNode` (multi-port predicate routing), `RateLimitNode` (token-bucket drop and buffer rate limiter), and `WatchdogNode` (inactivity timeout dead-man alert generator). +- **CommsHub and Wire-Tap Observability**: Added an asynchronous event bus (`CommsHub`) in `AppState`. Added rate-limited wire-tap probes (capped at 20 events per second with drop counters) toggleable via `POST /rules/:id/tap/:node_id/enable|disable`. Added real-time `/comms` WebSocket broadcast stream. +- **Visual Stream Studio**: Embedded a visual flow canvas directly in `/ui`. Users can design stream pipelines with drag-and-drop source, operator, and sink nodes, inspect live node badges (events per second, queue depth, error count), and sample live tuple data in an interactive wire-tap drawer. +- **Outbound Edge Agent and Yamux Tunneling**: Added `--control-plane-url` CLI option and `REKUIPER_CONTROL_PLANE_URL` environment configuration to `kuiperd`. The edge agent establishes an outbound mTLS WebSocket connection with exponential backoff. Yamux multiplexing handles control telemetry on stream 0 and tunnels remote HTTP and WebSocket requests into the local Axum router on streams 1..N. +- **Transactional Deployment Watchdog and Auto-Rollback**: Added automatic health monitoring for 30 seconds following declarative configuration deployment. The watchdog automatically reverts the edge gateway to the previous configuration snapshot and raises an alert if error counts or panics exceed thresholds. +- **Credential Masking and Secret Protection**: Added automatic credential redaction (`***REDACTED***`) for sensitive fields (passwords, bearer tokens, private keys, client secrets) across all configuration export and inspection APIs. Preserves original credentials bidirectionally during declarative reloads. +- **Fleet Control Plane ("Rekuiper Manager")**: Added device fleet catalog (`/api/v1/fleet/devices`) and group management (`/api/v1/fleet/groups`). Added staged canary rollout engine (`/api/v1/fleet/rollouts`) supporting progressive rollout stages (5% -> 25% -> 100%) and automatic canary rollback. Added remote Yamux proxy (`/fleet/proxy/:device_id/*path`), unified audit logging (`/api/v1/fleet/audit`), and fleet control dashboard in `/ui`. +- **Fleet Provisioning and Bearer Authentication**: Added token validation (`BasicConfig.fleet_token`, `REKUIPER_FLEET_TOKEN`) for `/fleet/ws` and `/api/v1/fleet/*` endpoints. Rejects unauthorized registrations, proxy requests, and rollout commands with HTTP 401 Unauthorized. +- **Model Context Protocol (`rekuiper-mcp`) Expansion**: Expanded the native MCP server to 57 tools with dedicated categories for Fleet Management (`list_fleet_devices`, `get_fleet_rollouts`, `trigger_fleet_rollout`, `list_fleet_snapshots`, `proxy_fleet_device_api`) and Visual Stream Studio & Wire-Tap (`get_studio_palette`, `validate_graph_rule`, `toggle_wire_tap`, `get_wire_tap_status`). Added 3 new MCP live resources (`rekuiper://fleet/devices`, `rekuiper://fleet/rollouts`, `rekuiper://studio/palette`) and 2 interactive orchestration prompts (`staged_fleet_rollout`, `design_visual_dag_pipeline`). + ## [1.0.0-rc4] - 2026-10-16 ### Added diff --git a/Cargo.lock b/Cargo.lock index 1b0479c5..8ce43f95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -574,6 +574,7 @@ dependencies = [ "async-trait", "axum-core", "axum-macros", + "base64", "bytes", "futures-util", "http", @@ -592,8 +593,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite 0.24.0", "tower 0.5.3", "tower-layer", "tower-service", @@ -2588,6 +2591,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -3414,7 +3423,7 @@ dependencies = [ "sqlx", "tokio", "tokio-rustls", - "tokio-tungstenite", + "tokio-tungstenite 0.23.1", "tracing", "uuid", "webpki-roots 0.26.11", @@ -3472,6 +3481,7 @@ dependencies = [ "futures", "indexmap", "parking_lot", + "regex", "rekuiper-conf", "rekuiper-connectors", "rekuiper-core", @@ -3485,11 +3495,12 @@ dependencies = [ "sqlx", "sysinfo", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.23.1", "tower 0.4.13", "tower-http 0.5.2", "tracing", "uuid", + "yamux", ] [[package]] @@ -4641,7 +4652,19 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite", + "tungstenite 0.23.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.24.0", ] [[package]] @@ -4894,6 +4917,24 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.8", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "twox-hash" version = "1.6.3" @@ -5164,6 +5205,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -5521,6 +5572,22 @@ dependencies = [ "time", ] +[[package]] +name = "yamux" +version = "0.13.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1991f6690292030e31b0144d73f5e8368936c58e45e7068254f7138b23b00672" +dependencies = [ + "futures", + "log", + "nohash-hasher", + "parking_lot", + "pin-project", + "rand 0.9.5", + "static_assertions", + "web-time", +] + [[package]] name = "yoke" version = "0.7.5" diff --git a/Cargo.toml b/Cargo.toml index 649e74f5..6094ff27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ categories = ["embedded", "network-programming", "science"] [workspace.dependencies] tokio = { version = "1.38", features = ["full"] } -axum = { version = "0.7", features = ["macros"] } +axum = { version = "0.7", features = ["macros", "ws"] } serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["preserve_order"] } indexmap = { version = "2.6", features = ["serde"] } @@ -61,6 +61,7 @@ sha1 = "0.10" uuid = { version = "1", features = ["v4"] } rand = "0.8" boa_engine = "0.20" +yamux = "0.13" # Internal crate dependencies rekuiper-core = { path = "crates/rekuiper-core" } diff --git a/README.md b/README.md index 078264a8..ca1ea388 100644 --- a/README.md +++ b/README.md @@ -165,8 +165,8 @@ Details: [Sources](docs/en_US/guide/sources/overview.md) · [Sinks](docs/en_US/g - **Declarative GitOps & Dead-Letter Queues (DLQ).** Run in read-only GitOps mode (`--declarative`) with automatic file watching, and isolate poison-pill records across 5 pipeline stages (`GET/DELETE /rules/:id/errors`). - **Extensions.** Write SQL functions as sandboxed WebAssembly modules. - **Secrets.** Read credentials from HashiCorp Vault or environment variables. -- **Operations.** Prometheus metrics, a self-contained Web UI (`GET /ui`), an interactive terminal dashboard (`rekuiper top`), a Helm chart, and a `rekuiper` / `kuiper` CLI. -- **AI tooling.** A Model Context Protocol (MCP) server (`rekuiper-mcp`) exposing **48 tools** (including `explain_pipeline_profile`), 15 live resources, and prompt workflows. See [MCP](docs/en_US/mcp/overview.md). +- **Operations & Fleet Management.** Prometheus metrics, a self-contained Web UI (`GET /ui`), an interactive terminal dashboard (`rekuiper top`), a Helm chart, a `rekuiper` / `kuiper` CLI, a centralized Fleet Control Plane with staged canary rollouts, and the Visual Stream DAG Studio with real-time wire-tap telemetry. +- **AI tooling.** A Model Context Protocol (MCP) server (`rekuiper-mcp`) exposing **57 tools** (including `explain_pipeline_profile`, fleet rollouts, and visual DAG inspection), 25 live resources, and 12 prompt workflows. See [MCP](docs/en_US/mcp/overview.md). --- diff --git a/crates/kuiperd/src/lib.rs b/crates/kuiperd/src/lib.rs index c29c0e11..00d9d2a6 100644 --- a/crates/kuiperd/src/lib.rs +++ b/crates/kuiperd/src/lib.rs @@ -29,6 +29,51 @@ pub struct Args { /// Watch declarative path and automatically hot-reload when manifest changes #[arg(long, env = "REKUIPER_DECLARATIVE_WATCH", default_value_t = false)] pub watch: bool, + + /// Control plane WebSocket URL for fleet management (e.g. ws://control-plane:9081/fleet/ws) + #[arg(long, env = "REKUIPER_CONTROL_PLANE_URL")] + pub control_plane_url: Option, + + /// Edge device identifier + #[arg(long, env = "REKUIPER_DEVICE_ID")] + pub device_id: Option, + + /// Edge device display name + #[arg(long, env = "REKUIPER_DEVICE_NAME")] + pub device_name: Option, + + /// Plant or facility tag + #[arg(long, env = "REKUIPER_PLANT")] + pub plant: Option, + + /// Geographic region tag + #[arg(long, env = "REKUIPER_REGION")] + pub region: Option, + + /// Hardware device type + #[arg(long, env = "REKUIPER_DEVICE_TYPE")] + pub device_type: Option, + + /// Optional shared bearer or provisioning token for fleet authentication + #[arg(long, env = "REKUIPER_FLEET_TOKEN")] + pub fleet_token: Option, + + /// Enable web console UI at /ui (default: false for security) + #[arg( + long, + visible_alias = "ui", + env = "REKUIPER_ENABLE_UI", + default_value_t = false + )] + pub enable_ui: bool, + + /// Allowed client IP addresses or CIDR ranges for web console access (comma-separated, e.g. "127.0.0.1,192.168.1.0/24") + #[arg(long, env = "REKUIPER_UI_ALLOW_IP")] + pub ui_allow_ip: Option, + + /// Optional password for Web UI access + #[arg(long, env = "REKUIPER_UI_PASSWORD")] + pub ui_password: Option, } pub async fn run_daemon() -> Result<()> { @@ -39,9 +84,26 @@ pub async fn run_daemon() -> Result<()> { let config = paths.load_config()?; let version = env!("CARGO_PKG_VERSION").to_string(); + let ui_allow_ip_vec = args.ui_allow_ip.map(|s| { + s.split(',') + .map(|part| part.trim().to_string()) + .filter(|part| !part.is_empty()) + .collect::>() + }); + let options = rekuiper_server::ServerOptions { declarative_path: args.declarative.map(std::path::PathBuf::from), declarative_watch: args.watch, + control_plane_url: args.control_plane_url, + device_id: args.device_id, + device_name: args.device_name, + plant: args.plant, + region: args.region, + device_type: args.device_type, + fleet_token: args.fleet_token, + enable_ui: args.enable_ui, + ui_allow_ip: ui_allow_ip_vec, + ui_password: args.ui_password, }; rekuiper_server::start_server_with_options(config, version, options).await?; diff --git a/crates/rekuiper-conf/src/lib.rs b/crates/rekuiper-conf/src/lib.rs index 7cde35da..c34ccf2d 100644 --- a/crates/rekuiper-conf/src/lib.rs +++ b/crates/rekuiper-conf/src/lib.rs @@ -44,6 +44,36 @@ pub struct BasicConfig { /// Whether rules and sinks can connect to private networks (default false for security). #[serde(default)] pub enable_private_net: bool, + /// Control plane WebSocket URL for fleet management (e.g. ws://control-plane:9081/fleet/ws). + #[serde(default)] + pub control_plane_url: Option, + /// Unique identifier for this edge device (agent mode). + #[serde(default)] + pub device_id: Option, + /// Display name for this edge device. + #[serde(default)] + pub device_name: Option, + /// Plant/facility tag (e.g. Plant-Austin). + #[serde(default)] + pub device_plant: Option, + /// Geographic region tag (e.g. US-South). + #[serde(default)] + pub device_region: Option, + /// Hardware device type tag (e.g. IPC-Industrial). + #[serde(default)] + pub device_type: Option, + /// Optional shared bearer or provisioning token for fleet authentication. + #[serde(default)] + pub fleet_token: Option, + /// Whether the web console UI (/ui) is enabled. Default false for security. + #[serde(default)] + pub enable_ui: bool, + /// Allowed client IP addresses or CIDR ranges for web console access (empty = all allowed). + #[serde(default)] + pub ui_ip_allowlist: Vec, + /// Optional custom password for Web UI access. + #[serde(default)] + pub ui_password: Option, } fn default_ip() -> String { @@ -94,6 +124,16 @@ impl Default for BasicConfig { http_server_ip: default_http_server_ip(), http_server_port: default_http_server_port(), enable_private_net: false, + control_plane_url: None, + device_id: None, + device_name: None, + device_plant: None, + device_region: None, + device_type: None, + fleet_token: None, + enable_ui: false, + ui_ip_allowlist: Vec::new(), + ui_password: None, } } } @@ -237,6 +277,55 @@ fn apply_basic_override(basic: &mut BasicConfig, key: &str, val: &str) { basic.enable_private_net = v; } } + "CONTROLPLANEURL" => { + basic.control_plane_url = if val.trim().is_empty() { + None + } else { + Some(val.trim().to_string()) + }; + } + "DEVICEID" => { + basic.device_id = if val.trim().is_empty() { + None + } else { + Some(val.trim().to_string()) + }; + } + "DEVICENAME" => { + basic.device_name = if val.trim().is_empty() { + None + } else { + Some(val.trim().to_string()) + }; + } + "DEVICEPLANT" | "PLANT" => { + basic.device_plant = if val.trim().is_empty() { + None + } else { + Some(val.trim().to_string()) + }; + } + "DEVICEREGION" | "REGION" => { + basic.device_region = if val.trim().is_empty() { + None + } else { + Some(val.trim().to_string()) + }; + } + "DEVICETYPE" => { + basic.device_type = if val.trim().is_empty() { + None + } else { + Some(val.trim().to_string()) + }; + } + "FLEETTOKEN" => { + basic.fleet_token = if val.trim().is_empty() { + None + } else { + Some(val.trim().to_string()) + }; + } _ => {} } } diff --git a/crates/rekuiper-connectors/src/http.rs b/crates/rekuiper-connectors/src/http.rs index e7f4ce5c..cf372740 100644 --- a/crates/rekuiper-connectors/src/http.rs +++ b/crates/rekuiper-connectors/src/http.rs @@ -2,11 +2,12 @@ use crate::{Sink, META_KEY}; use anyhow::{bail, Context, Result}; use async_trait::async_trait; use indexmap::IndexMap; -use rekuiper_core::model::StreamRecord; +use rekuiper_core::model::{RecordMeta, StreamRecord}; use rekuiper_core::StreamSender; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; +use std::sync::Arc; pub struct HttpSink { pub url: String, @@ -76,6 +77,7 @@ fn default_http_interval() -> u64 { pub struct HttpPullSource { pub config: HttpPullConfig, pub tx: StreamSender, + pub status_callback: Option, } impl HttpPullSource { @@ -93,6 +95,9 @@ impl HttpPullSource { _ = ticker.tick() => { match self.fetch_once(&client).await { Ok(records) => { + if let Some(ref cb) = self.status_callback { + cb(&self.config.url, "http", "connected", None); + } for record in records { // All subscribers dropped: shut down cleanly. if self.tx.send(record).await.is_err() { @@ -106,6 +111,9 @@ impl HttpPullSource { self.config.url, e ); + if let Some(ref cb) = self.status_callback { + cb(&self.config.url, "http", "error", Some(&e.to_string())); + } } } } @@ -149,17 +157,25 @@ impl HttpPullSource { .json() .await .with_context(|| format!("HTTP pull response from {} is not JSON", self.config.url))?; + let rec_meta = Arc::new(RecordMeta { + topic: None, + source: Some(Arc::from(self.config.url.as_str())), + custom: None, + }); match payload { - Value::Object(map) => Ok(vec![StreamRecord::new( - map.into_iter().collect::>(), - )]), + Value::Object(map) => { + let mut rec = StreamRecord::new(map.into_iter().collect::>()); + rec.meta = Some(rec_meta); + Ok(vec![rec]) + } Value::Array(items) => { let mut records = Vec::new(); for item in items { if let Value::Object(map) = item { - records.push(StreamRecord::new( - map.into_iter().collect::>(), - )); + let mut rec = + StreamRecord::new(map.into_iter().collect::>()); + rec.meta = Some(rec_meta.clone()); + records.push(rec); } else { tracing::warn!( "Skipping non-object element in HTTP pull response from {}", diff --git a/crates/rekuiper-connectors/src/kafka.rs b/crates/rekuiper-connectors/src/kafka.rs index 5e8d1c64..be2ceccc 100644 --- a/crates/rekuiper-connectors/src/kafka.rs +++ b/crates/rekuiper-connectors/src/kafka.rs @@ -2,7 +2,7 @@ use crate::META_KEY; use anyhow::{Context, Result}; use indexmap::IndexMap; use parking_lot::RwLock; -use rekuiper_core::model::StreamRecord; +use rekuiper_core::model::{RecordMeta, StreamRecord}; use rekuiper_core::StreamSender; use rskafka::client::partition::PartitionClient; use serde::{Deserialize, Serialize}; @@ -204,6 +204,7 @@ fn value_to_key_bytes(v: &Value) -> Vec { pub struct KafkaSource { pub config: KafkaConfig, pub tx: StreamSender, + pub status_callback: Option, } impl KafkaSource { @@ -237,9 +238,17 @@ impl KafkaSource { .partition_client(topic, self.config.partition, UnknownTopicHandling::Error) .await { - Ok(partition_client) => partition_client, + Ok(partition_client) => { + if let Some(ref cb) = self.status_callback { + cb(topic, "kafka", "connected", None); + } + partition_client + } Err(e) => { tracing::warn!("Kafka source partition client failed: {}", e); + if let Some(ref cb) = self.status_callback { + cb(topic, "kafka", "error", Some(&e.to_string())); + } return; } }; @@ -257,7 +266,7 @@ impl KafkaSource { for rec in records { offset = rec.offset + 1; let payload = rec.record.value.as_deref().unwrap_or_default(); - match decode_kafka_payload(payload) { + match decode_kafka_payload(payload, Some(topic)) { Ok(decoded) => { for record in decoded { // All subscribers dropped: shut down cleanly. @@ -274,6 +283,9 @@ impl KafkaSource { } Err(e) => { tracing::warn!("Kafka source fetch failed: {}", e); + if let Some(ref cb) = self.status_callback { + cb(topic, "kafka", "error", Some(&e.to_string())); + } tokio::time::sleep(std::time::Duration::from_secs(1)).await; } } @@ -297,20 +309,27 @@ impl KafkaSource { /// Decode one Kafka record payload: a JSON object yields a single record, a /// JSON array yields one record per object element. -fn decode_kafka_payload(payload: &[u8]) -> Result> { +fn decode_kafka_payload(payload: &[u8], topic: Option<&str>) -> Result> { let value: Value = serde_json::from_slice(payload).context("Kafka payload is not valid JSON")?; + let rec_meta = Arc::new(RecordMeta { + topic: topic.map(Arc::from), + source: Some(Arc::from("kafka")), + custom: None, + }); match value { - Value::Object(map) => Ok(vec![StreamRecord::new( - map.into_iter().collect::>(), - )]), + Value::Object(map) => { + let mut rec = StreamRecord::new(map.into_iter().collect::>()); + rec.meta = Some(rec_meta); + Ok(vec![rec]) + } Value::Array(items) => { let mut records = Vec::new(); for item in items { if let Value::Object(map) = item { - records.push(StreamRecord::new( - map.into_iter().collect::>(), - )); + let mut rec = StreamRecord::new(map.into_iter().collect::>()); + rec.meta = Some(rec_meta.clone()); + records.push(rec); } else { tracing::warn!("Skipping non-object element in Kafka message"); } @@ -373,24 +392,28 @@ mod tests { fn test_decode_kafka_payload() { // Single object let obj_payload = br#"{"sensor": "temp", "val": 25.5}"#; - let records = decode_kafka_payload(obj_payload).expect("decode object"); + let records = decode_kafka_payload(obj_payload, Some("test_topic")).expect("decode object"); assert_eq!(records.len(), 1); assert_eq!( records[0].data.get("sensor"), Some(&serde_json::json!("temp")) ); + assert_eq!( + records[0].meta.as_ref().and_then(|m| m.topic.as_deref()), + Some("test_topic") + ); // Array of objects let arr_payload = br#"[{"id": 1}, {"id": 2}]"#; - let records = decode_kafka_payload(arr_payload).expect("decode array"); + let records = decode_kafka_payload(arr_payload, Some("test_topic")).expect("decode array"); assert_eq!(records.len(), 2); assert_eq!(records[0].data.get("id"), Some(&serde_json::json!(1))); assert_eq!(records[1].data.get("id"), Some(&serde_json::json!(2))); // Invalid JSON - assert!(decode_kafka_payload(b"not json").is_err()); + assert!(decode_kafka_payload(b"not json", None).is_err()); // Invalid top-level scalar - assert!(decode_kafka_payload(b"123").is_err()); + assert!(decode_kafka_payload(b"123", None).is_err()); } } diff --git a/crates/rekuiper-connectors/src/lib.rs b/crates/rekuiper-connectors/src/lib.rs index db90236b..480683f3 100644 --- a/crates/rekuiper-connectors/src/lib.rs +++ b/crates/rekuiper-connectors/src/lib.rs @@ -3,6 +3,9 @@ use async_trait::async_trait; use rekuiper_core::model::StreamRecord; pub use rekuiper_core::RuleCounters; +pub type ConnectorStatusCallback = + std::sync::Arc) + Send + Sync>; + pub const META_KEY: &str = "__meta__"; pub mod codec; diff --git a/crates/rekuiper-connectors/src/modbus.rs b/crates/rekuiper-connectors/src/modbus.rs index 8378e584..0afe61dc 100644 --- a/crates/rekuiper-connectors/src/modbus.rs +++ b/crates/rekuiper-connectors/src/modbus.rs @@ -13,7 +13,7 @@ // limitations under the License. use anyhow::{bail, Context, Result}; -use rekuiper_core::model::StreamRecord; +use rekuiper_core::model::{RecordMeta, StreamRecord}; use rekuiper_core::StreamSender; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -1005,11 +1005,21 @@ pub async fn modbus_read_snapshot(config: &ModbusConfig) -> Result, } impl ModbusSource { pub fn new(config: ModbusConfig, tx: StreamSender) -> Self { - Self { config, tx } + Self { + config, + tx, + status_callback: None, + } + } + + pub fn with_status_callback(mut self, cb: Option) -> Self { + self.status_callback = cb; + self } pub fn spawn(self, mut cancel_rx: watch::Receiver) -> tokio::task::JoinHandle<()> { @@ -1104,6 +1114,14 @@ impl ModbusSource { range.function, e ); + if let Some(ref cb) = self.status_callback { + cb( + self.config.endpoint(), + "modbus", + "error", + Some(&e.to_string()), + ); + } decode_errors.push(format!( "Modbus poll failure at address {}: {}", range.start_address, e @@ -1112,6 +1130,12 @@ impl ModbusSource { } } + if decode_errors.is_empty() { + if let Some(ref cb) = self.status_callback { + cb(self.config.endpoint(), "modbus", "connected", None); + } + } + // If decode errors occurred (e.g. NaN/Inf or network truncation), // emit record with __raw_error__ and __raw_payload__ for Track C DLQ routing! if !decode_errors.is_empty() { @@ -1129,7 +1153,12 @@ impl ModbusSource { Value::String(format!("raw Modbus bytes: [{}]", raw_hex)), ); - let stream_rec = StreamRecord::new(err_record); + let mut stream_rec = StreamRecord::new(err_record); + stream_rec.meta = Some(Arc::new(RecordMeta { + topic: None, + source: Some(Arc::from("modbus")), + custom: None, + })); if matches!( self.tx.send(stream_rec).await, Err(rekuiper_core::PublishError::Closed) @@ -1137,7 +1166,12 @@ impl ModbusSource { break; } } else if !record_map.is_empty() { - let stream_rec = StreamRecord::new(record_map); + let mut stream_rec = StreamRecord::new(record_map); + stream_rec.meta = Some(Arc::new(RecordMeta { + topic: None, + source: Some(Arc::from("modbus")), + custom: None, + })); if matches!( self.tx.send(stream_rec).await, Err(rekuiper_core::PublishError::Closed) diff --git a/crates/rekuiper-connectors/src/mqtt.rs b/crates/rekuiper-connectors/src/mqtt.rs index 9affcbff..d706732e 100644 --- a/crates/rekuiper-connectors/src/mqtt.rs +++ b/crates/rekuiper-connectors/src/mqtt.rs @@ -6,7 +6,7 @@ use crate::{ use anyhow::{bail, Context, Result}; use async_trait::async_trait; use indexmap::IndexMap; -use rekuiper_core::model::StreamRecord; +use rekuiper_core::model::{RecordMeta, StreamRecord}; use rekuiper_core::{RuleCounters, StreamSender}; use rumqttc::{AsyncClient, Event, MqttOptions, Packet, QoS}; use serde::{Deserialize, Serialize}; @@ -791,12 +791,20 @@ pub fn decode_payload_into_with_mask( PayloadFormat::EdgeX => EdgeXCodec::decode(payload, meta.clone(), out)?, } if let Some(meta) = meta { + let topic_str = meta.get("topic").and_then(|v| v.as_str()); + let rec_meta = Arc::new(RecordMeta { + topic: topic_str.map(Arc::from), + source: Some(Arc::from("mqtt")), + custom: None, + }); let end = out.len(); if end > first { for record in &mut out[first..end - 1] { record.data.insert("__meta__".to_string(), meta.clone()); + record.meta = Some(rec_meta.clone()); } out[end - 1].data.insert("__meta__".to_string(), meta); + out[end - 1].meta = Some(rec_meta); } } Ok(()) @@ -835,6 +843,7 @@ fn push_decoded_with_cache_and_mask( meta_flag: Option<&std::sync::atomic::AtomicBool>, meta_cache: &mut Option<(String, u8, Value)>, ) { + let topic_arc: Arc = Arc::from(publish.topic.as_str()); let attach = config.attach_meta || meta_flag.is_some_and(|f| f.load(std::sync::atomic::Ordering::Relaxed)); let meta = if attach { @@ -860,6 +869,7 @@ fn push_decoded_with_cache_and_mask( None }; + let start_idx = batch.len(); if let Err(e) = decode_payload_into_with_mask(&config.format, field_mask, &publish.payload, meta, batch) { @@ -867,6 +877,16 @@ fn push_decoded_with_cache_and_mask( if let Some(counters) = counters { counters.inc_exceptions(1); } + } else { + for record in &mut batch[start_idx..] { + if record.meta.is_none() { + record.meta = Some(Arc::new(RecordMeta { + topic: Some(topic_arc.clone()), + source: Some(Arc::from("mqtt")), + custom: None, + })); + } + } } } @@ -988,6 +1008,7 @@ pub struct MqttSource { rule_counters: Option>, attach_meta_flag: Option>, field_mask_flag: Option>>>, + status_callback: Option, } impl MqttSource { @@ -998,9 +1019,15 @@ impl MqttSource { rule_counters: None, attach_meta_flag: None, field_mask_flag: None, + status_callback: None, } } + pub fn with_status_callback(mut self, cb: Option) -> Self { + self.status_callback = cb; + self + } + pub fn with_rule_counters(mut self, counters: Option>) -> Self { self.rule_counters = counters; self @@ -1047,11 +1074,17 @@ impl MqttSource { let (client, mut eventloop) = AsyncClient::new(opts, 64); if let Err(e) = subscribe_topics(&client, &self.config).await { tracing::warn!("{}", e); + if let Some(ref cb) = self.status_callback { + cb(&self.config.topic, "mqtt", "error", Some(&e.to_string())); + } if let Some(counters) = self.rule_counters.as_deref() { counters.inc_exceptions(1); } return; } + if let Some(ref cb) = self.status_callback { + cb(&self.config.topic, "mqtt", "connected", None); + } let mut meta_cache: Option<(String, u8, Value)> = None; loop { tokio::select! { @@ -1099,6 +1132,9 @@ impl MqttSource { // re-subscribe or the source goes silently deaf // after a broker restart. Ok(Event::Incoming(Packet::ConnAck(_))) => { + if let Some(ref cb) = self.status_callback { + cb(&self.config.topic, "mqtt", "connected", None); + } if let Err(e) = subscribe_topics(&client, &self.config).await { tracing::warn!("MQTT resubscribe: {}", e); if let Some(counters) = self.rule_counters.as_deref() { counters.inc_exceptions(1); } @@ -1107,6 +1143,9 @@ impl MqttSource { Ok(_) => {} Err(e) => { tracing::warn!("MQTT connection error: {}", e); + if let Some(ref cb) = self.status_callback { + cb(&self.config.topic, "mqtt", "disconnected", Some(&e.to_string())); + } if let Some(counters) = self.rule_counters.as_deref() { counters.inc_exceptions(1); } tokio::time::sleep(std::time::Duration::from_secs(1)).await; } diff --git a/crates/rekuiper-connectors/src/opcua.rs b/crates/rekuiper-connectors/src/opcua.rs index 1ed5ab30..eeab4a43 100644 --- a/crates/rekuiper-connectors/src/opcua.rs +++ b/crates/rekuiper-connectors/src/opcua.rs @@ -13,7 +13,7 @@ // limitations under the License. use anyhow::{bail, Context, Result}; -use rekuiper_core::model::StreamRecord; +use rekuiper_core::model::{RecordMeta, StreamRecord}; use rekuiper_core::StreamSender; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -1298,11 +1298,21 @@ pub async fn opcua_read_snapshot(config: &OpcUaConfig) -> Result, } impl OpcUaSource { pub fn new(config: OpcUaConfig, tx: StreamSender) -> Self { - Self { config, tx } + Self { + config, + tx, + status_callback: None, + } + } + + pub fn with_status_callback(mut self, cb: Option) -> Self { + self.status_callback = cb; + self } pub fn spawn(self, mut cancel_rx: watch::Receiver) -> tokio::task::JoinHandle<()> { @@ -1323,6 +1333,9 @@ impl OpcUaSource { match client.read_nodes(&self.config.nodes).await { Ok(read_map) => { + if let Some(ref cb) = self.status_callback { + cb(self.config.endpoint(), "opcua", "connected", None); + } let mut record = HashMap::new(); let mut decode_errors = Vec::new(); @@ -1375,7 +1388,12 @@ impl OpcUaSource { .collect::>() )), ); - let stream_rec = StreamRecord::new(err_rec); + let mut stream_rec = StreamRecord::new(err_rec); + stream_rec.meta = Some(Arc::new(RecordMeta { + topic: None, + source: Some(Arc::from("opcua")), + custom: None, + })); if matches!( self.tx.send(stream_rec).await, Err(rekuiper_core::PublishError::Closed) @@ -1383,7 +1401,12 @@ impl OpcUaSource { break; } } else if !record.is_empty() { - let stream_rec = StreamRecord::new(record); + let mut stream_rec = StreamRecord::new(record); + stream_rec.meta = Some(Arc::new(RecordMeta { + topic: None, + source: Some(Arc::from("opcua")), + custom: None, + })); if matches!( self.tx.send(stream_rec).await, Err(rekuiper_core::PublishError::Closed) @@ -1398,6 +1421,14 @@ impl OpcUaSource { self.config.endpoint(), e ); + if let Some(ref cb) = self.status_callback { + cb( + self.config.endpoint(), + "opcua", + "error", + Some(&e.to_string()), + ); + } let mut err_rec = HashMap::new(); err_rec.insert( "__raw_error__".to_string(), diff --git a/crates/rekuiper-core/src/model.rs b/crates/rekuiper-core/src/model.rs index e60d7248..94d1ec2b 100644 --- a/crates/rekuiper-core/src/model.rs +++ b/crates/rekuiper-core/src/model.rs @@ -149,10 +149,49 @@ impl<'de> Deserialize<'de> for SharedMap { } } +/// Immutable, Arc-shared metadata header carried across SQL/Graph hops. +/// Preserves connector metadata (e.g. topic, source) without cloning map allocations. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct RecordMeta { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub topic: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom: Option>>, +} + +impl RecordMeta { + #[inline] + pub fn new() -> Self { + Self::default() + } + + #[inline] + pub fn with_topic(mut self, topic: impl Into>) -> Self { + self.topic = Some(topic.into()); + self + } + + #[inline] + pub fn with_source(mut self, source: impl Into>) -> Self { + self.source = Some(source.into()); + self + } + + #[inline] + pub fn with_custom(mut self, custom: IndexMap) -> Self { + self.custom = Some(Arc::new(custom)); + self + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StreamRecord { pub timestamp: i64, pub data: SharedMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub meta: Option>, } impl StreamRecord { @@ -160,6 +199,7 @@ impl StreamRecord { Self { timestamp: chrono::Utc::now().timestamp_millis(), data: data.into(), + meta: None, } } @@ -167,6 +207,7 @@ impl StreamRecord { Self { timestamp: chrono::Utc::now().timestamp_millis(), data: SharedMap::from_hashmap(data), + meta: None, } } @@ -174,6 +215,7 @@ impl StreamRecord { Self { timestamp: chrono::Utc::now().timestamp_millis(), data, + meta: None, } } @@ -182,6 +224,7 @@ impl StreamRecord { Self { timestamp, data: data.into(), + meta: None, } } @@ -190,8 +233,33 @@ impl StreamRecord { Self { timestamp, data: SharedMap::default(), + meta: None, } } + + #[inline] + pub fn with_meta(mut self, meta: impl Into>) -> Self { + self.meta = Some(meta.into()); + self + } + + #[inline] + pub fn with_timestamp_and_meta( + data: impl Into, + timestamp: i64, + meta: Option>, + ) -> Self { + Self { + timestamp, + data: data.into(), + meta, + } + } + + #[inline] + pub fn set_meta(&mut self, meta: impl Into>) { + self.meta = Some(meta.into()); + } } /// One declared stream/table column, serialized eKuiper-style as @@ -297,12 +365,72 @@ pub struct GraphNode { pub props: HashMap, } +/// Multi-port output routing in Graph Rules. +/// Deserializes either legacy single-port targets (`["node_b", "node_c"]`) +/// or port-indexed output targets (`[["port0_target"], ["port1_target"]]`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum EdgeTargets { + Single(Vec), + Multi(Vec>), +} + +impl Default for EdgeTargets { + fn default() -> Self { + EdgeTargets::Single(Vec::new()) + } +} + +impl EdgeTargets { + pub fn all_targets(&self) -> Vec<&str> { + match self { + EdgeTargets::Single(targets) => targets.iter().map(|s| s.as_str()).collect(), + EdgeTargets::Multi(ports) => ports + .iter() + .flat_map(|port| port.iter().map(|s| s.as_str())) + .collect(), + } + } + + pub fn targets_for_port(&self, port: usize) -> &[String] { + match self { + EdgeTargets::Single(targets) => { + if port == 0 { + targets.as_slice() + } else { + &[] + } + } + EdgeTargets::Multi(ports) => ports.get(port).map(|v| v.as_slice()).unwrap_or(&[]), + } + } + + pub fn port_count(&self) -> usize { + match self { + EdgeTargets::Single(_) => 1, + EdgeTargets::Multi(ports) => ports.len(), + } + } +} + +impl From> for EdgeTargets { + fn from(v: Vec) -> Self { + EdgeTargets::Single(v) + } +} + +impl From>> for EdgeTargets { + fn from(v: Vec>) -> Self { + EdgeTargets::Multi(v) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] pub struct GraphTopo { #[serde(default)] pub sources: Vec, #[serde(default)] - pub edges: HashMap>, + pub edges: HashMap, } /// Compile a visual rule DAG into an equivalent SQL string plus sink actions. @@ -346,7 +474,7 @@ pub fn compile_graph_to_sql_and_actions( use std::collections::HashSet; fn visit<'a>( node: &'a str, - edges: &'a HashMap>, + edges: &'a HashMap, visiting: &mut HashSet<&'a str>, done: &mut HashSet<&'a str>, ) -> Result<(), String> { @@ -358,7 +486,7 @@ pub fn compile_graph_to_sql_and_actions( return Ok(()); } if let Some(next) = edges.get(node) { - for target in next { + for target in next.all_targets() { visit(target, edges, visiting, done)?; } } @@ -368,9 +496,9 @@ pub fn compile_graph_to_sql_and_actions( } let mut visiting = HashSet::new(); let mut done = HashSet::new(); - let mut endpoints: Vec<&String> = graph.topo.edges.keys().collect(); + let mut endpoints: Vec<&str> = graph.topo.edges.keys().map(|s| s.as_str()).collect(); for targets in graph.topo.edges.values() { - endpoints.extend(targets); + endpoints.extend(targets.all_targets()); } endpoints.sort(); endpoints.dedup(); @@ -571,9 +699,9 @@ mod tests { topo: GraphTopo { sources: vec!["src".to_string()], edges: [ - ("src".to_string(), vec!["f".to_string()]), - ("f".to_string(), vec!["p".to_string()]), - ("p".to_string(), vec!["out".to_string()]), + ("src".to_string(), EdgeTargets::from(vec!["f".to_string()])), + ("f".to_string(), EdgeTargets::from(vec!["p".to_string()])), + ("p".to_string(), EdgeTargets::from(vec!["out".to_string()])), ] .into_iter() .collect(), @@ -584,6 +712,53 @@ mod tests { assert_eq!(actions.len(), 1); assert!(actions[0].contains_key("log")); } + + #[test] + fn test_record_meta_zero_copy() { + let meta = Arc::new( + RecordMeta::new() + .with_topic("devices/sensor_01/telemetry") + .with_source("mqtt"), + ); + let rec = StreamRecord::new(HashMap::new()).with_meta(meta.clone()); + assert_eq!( + rec.meta.as_ref().unwrap().topic.as_deref(), + Some("devices/sensor_01/telemetry") + ); + assert_eq!(rec.meta.as_ref().unwrap().source.as_deref(), Some("mqtt")); + + // Zero-copy forward across simulated hop + let hop2 = StreamRecord::with_timestamp_and_meta( + rec.data.clone(), + rec.timestamp, + rec.meta.clone(), + ); + assert!(Arc::ptr_eq( + rec.meta.as_ref().unwrap(), + hop2.meta.as_ref().unwrap() + )); + } + + #[test] + fn test_edge_targets_multi_port() { + let json_single = r#"["node_b", "node_c"]"#; + let parsed_single: EdgeTargets = serde_json::from_str(json_single).unwrap(); + assert_eq!(parsed_single.all_targets(), vec!["node_b", "node_c"]); + assert_eq!(parsed_single.targets_for_port(0), &["node_b", "node_c"]); + assert_eq!(parsed_single.targets_for_port(1), &[] as &[String]); + + let json_multi = r#"[["port0_node"], ["port1_node_a", "port1_node_b"]]"#; + let parsed_multi: EdgeTargets = serde_json::from_str(json_multi).unwrap(); + assert_eq!( + parsed_multi.all_targets(), + vec!["port0_node", "port1_node_a", "port1_node_b"] + ); + assert_eq!(parsed_multi.targets_for_port(0), &["port0_node"]); + assert_eq!( + parsed_multi.targets_for_port(1), + &["port1_node_a", "port1_node_b"] + ); + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/rekuiper-core/tests/test_core_actors.rs b/crates/rekuiper-core/tests/test_core_actors.rs index a7f2ee03..ba70d3cf 100644 --- a/crates/rekuiper-core/tests/test_core_actors.rs +++ b/crates/rekuiper-core/tests/test_core_actors.rs @@ -45,7 +45,10 @@ fn dag( .map(|(k, v)| { ( k.to_string(), - v.into_iter().map(|t| t.to_string()).collect(), + v.into_iter() + .map(|t| t.to_string()) + .collect::>() + .into(), ) }) .collect::>(), @@ -195,14 +198,8 @@ async fn test_stream_bus_broadcast() { for i in 0..n { let mut data = HashMap::new(); data.insert("seq".to_string(), json!(i)); - bus.publish( - "events", - StreamRecord { - timestamp: i as i64, - data: data.into(), - }, - ) - .expect("publish"); + bus.publish("events", StreamRecord::with_timestamp(data, i as i64)) + .expect("publish"); } // Both listeners observe every event in order. diff --git a/crates/rekuiper-mcp/README.md b/crates/rekuiper-mcp/README.md index 450f66cf..a9cea70d 100644 --- a/crates/rekuiper-mcp/README.md +++ b/crates/rekuiper-mcp/README.md @@ -41,12 +41,21 @@ An enterprise-grade, native Rust **Model Context Protocol (MCP)** server providi - Full management of compiled `.wasm` modules (`register_wasm_plugin`, `list_wasm_plugins`, `delete_wasm_plugin`) with automatic UDF registration. - Static scanning and validation of dynamic secret syntax (`{{vault://...}}`, `{{env://...}}`) via `validate_secrets`. +- **Fleet Management & Edge Control Plane**: + - Full management of distributed edge fleet nodes (`list_fleet_devices`, `get_fleet_rollouts`, `trigger_fleet_rollout`, `list_fleet_snapshots`). + - Transparent HTTP reverse proxying (`proxy_fleet_device_api`) to individual edge instances across firewall boundaries. + +- **Visual Stream DAG Studio & Real-Time Wire Tap**: + - Multi-port visual palette inspection (`get_studio_palette`) for rapid pipeline design. + - Offline DAG topology and multi-port edge connectivity validation (`validate_graph_rule`). + - Live non-intrusive stream inspection probes (`toggle_wire_tap`, `get_wire_tap_status`) sampling tuple flows over WebSocket `/comms`. + - **Universal REST API Proxy (`execute_rekuiper_api`)**: - Allows executing arbitrary HTTP methods (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`) against any present or future `rekuiper` REST endpoint, ensuring 100% API coverage. -- **22 First-Class Resources & 10 Interactive Prompts**: - - Live queryable resources via `rekuiper://` URIs, including connector action schemas (`rabbitmq`, `parquet`, `edgex`) and Wasm plugins. - - Expert troubleshooting, query optimization, vector similarity search, CEP sequence matching, security audit, and RabbitMQ pipeline generation prompts. +- **25 First-Class Resources & 12 Interactive Prompts**: + - Live queryable resources via `rekuiper://` URIs, including fleet devices, rollouts, studio palette, connector action schemas (`rabbitmq`, `parquet`, `edgex`), and Wasm plugins. + - Expert troubleshooting, query optimization, vector similarity search, CEP sequence matching, security audit, staged fleet rollouts, and visual DAG design prompts. --- @@ -70,7 +79,7 @@ An enterprise-grade, native Rust **Model Context Protocol (MCP)** server providi --- -## Tool Catalog (48 Tools) +## Tool Catalog (57 Tools) ### 1. SQL Intelligence & Simulation | Tool | Description | @@ -151,7 +160,7 @@ An enterprise-grade, native Rust **Model Context Protocol (MCP)** server providi | `get_engine_metrics` | Fetch engine uptime, memory/CPU consumption, and global throughput. | | `ping_engine` | Test daemon connectivity and response latency. | -### 9. WebAssembly (WASM) & Dynamic Secrets Management +### 10. WebAssembly (WASM) & Dynamic Secrets Management | Tool | Description | | :--- | :--- | | `register_wasm_plugin` | Register a compiled `.wasm` module into rekuiper's embedded runtime via `POST /plugins/wasm`. | @@ -159,7 +168,24 @@ An enterprise-grade, native Rust **Model Context Protocol (MCP)** server providi | `delete_wasm_plugin` | Unload and delete a WebAssembly plugin from the engine via `DELETE /plugins/wasm/{name}`. | | `validate_secrets` | Offline syntax scanner and validator for dynamic secret templates (`{{vault://...}}`, `{{env://...}}`). | -### 10. Universal API Proxy +### 11. Fleet Management & Edge Control Plane +| Tool | Description | +| :--- | :--- | +| `list_fleet_devices` | Enumerate all managed edge devices with connection states, firmware versions, active rules, and heartbeat timestamps. | +| `get_fleet_rollouts` | Query active and historical phased rollout deployments across fleet device clusters. | +| `trigger_fleet_rollout` | Initiate a phased canary rollout of rules or binary versions with configurable step percentages and canary targets. | +| `list_fleet_snapshots` | List point-in-time state and rule configuration snapshots for disaster recovery and rollback. | +| `proxy_fleet_device_api` | Transparent HTTP reverse proxy routing REST requests to specific edge device instances across firewall boundaries. | + +### 12. Visual Stream DAG Studio & Real-Time Wire Tap +| Tool | Description | +| :--- | :--- | +| `get_studio_palette` | Catalog of Visual Stream Studio components, multi-port definitions, and property configuration schemas. | +| `validate_graph_rule` | Offline structural validator for Visual DAG rules: checks node types, required properties, and multi-port edge connectivity without server dependency. | +| `toggle_wire_tap` | Enable or disable non-intrusive real-time stream inspection probe on a rule's internal node or execution port with rate limiting. | +| `get_wire_tap_status` | Query active wire tap inspection probes, sample counters, and buffer overflow states. | + +### 13. Universal API Proxy | Tool | Description | | :--- | :--- | | `execute_rekuiper_api` | Unconstrained REST proxy: execute any HTTP method (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`) on any endpoint with arbitrary payloads. | @@ -171,6 +197,9 @@ An enterprise-grade, native Rust **Model Context Protocol (MCP)** server providi | URI | Description | | :--- | :--- | | `rekuiper://rules` | Active rules catalog with SQL queries and action sinks. | +| `rekuiper://fleet/devices` | Live fleet inventory of edge devices, connection statuses, versions, and active rules. | +| `rekuiper://fleet/rollouts` | Progressive canary rollouts, stage progression, and deployment history. | +| `rekuiper://studio/palette` | Visual Stream Studio component catalog, port descriptors, and property schemas. | | `rekuiper://streams` | Registered stream sources, schemas, and transport options. | | `rekuiper://tables` | Dimension lookup tables for enrichment joins. | | `rekuiper://connections` | Shared connection resource definitions. | @@ -209,6 +238,8 @@ An enterprise-grade, native Rust **Model Context Protocol (MCP)** server providi | `create_wasm_plugin_rule` | Guides registration and stream query generation for compiled WebAssembly UDF modules. | | `audit_security_posture` | Audits daemon runtime security configuration, network bindings, and authentication. | | `generate_cep_rule` | Constructs a Complex Event Processing (CEP) sequence matching rule with `sequence_match` or `sequence_count`. | +| `staged_fleet_rollout` | Guides progressive phased canary rollout of rules/binaries across fleet devices with health check verification. | +| `design_visual_dag_pipeline` | Architects a multi-port Visual Stream DAG rule structure with sources, transforms, routers, and sinks, validating topology and wire taps. | --- diff --git a/crates/rekuiper-mcp/src/prompts.rs b/crates/rekuiper-mcp/src/prompts.rs index e187ba22..d6e9ccee 100644 --- a/crates/rekuiper-mcp/src/prompts.rs +++ b/crates/rekuiper-mcp/src/prompts.rs @@ -151,6 +151,38 @@ pub fn get_prompt_definitions() -> Vec { }, ], }, + PromptDefinition { + name: "staged_fleet_rollout".to_string(), + description: Some("Guides progressive phased canary rollout of rules/binaries across fleet devices with health check verification and rollback validation.".to_string()), + arguments: vec![ + PromptArgument { + name: "version".to_string(), + description: Some("Target rollout version tag or configuration release.".to_string()), + required: true, + }, + PromptArgument { + name: "group_filter".to_string(), + description: Some("Optional fleet group or tag filter (e.g. 'edge-gateways', 'turbine-cluster').".to_string()), + required: false, + }, + ], + }, + PromptDefinition { + name: "design_visual_dag_pipeline".to_string(), + description: Some("Architects a multi-port Visual Stream DAG rule structure with sources, transforms, routers, and sinks, validating graph topology and wire-tap diagnostic points.".to_string()), + arguments: vec![ + PromptArgument { + name: "rule_name".to_string(), + description: Some("Unique identifier for the visual DAG rule.".to_string()), + required: true, + }, + PromptArgument { + name: "pipeline_goal".to_string(), + description: Some("Operational goal (e.g. 'Route high vibration to MQTT alert and raw telemetry to Parquet').".to_string()), + required: true, + }, + ], + }, ] } @@ -426,6 +458,61 @@ pub fn get_prompt_messages(name: &str, args: Option) -> Result { + let version = args + .as_ref() + .and_then(|a| a.get("version")) + .and_then(|v| v.as_str()) + .unwrap_or("latest"); + let group = args + .as_ref() + .and_then(|a| a.get("group_filter")) + .and_then(|v| v.as_str()) + .unwrap_or("all"); + + Ok(vec![ + PromptMessage { + role: "user".to_string(), + content: ContentItem::text(format!( + "Execute a staged canary rollout for fleet devices (group: '{}', target version: '{}'):\n\ + 1. Execute `list_fleet_devices` and read resource `rekuiper://fleet/devices` to audit current device states, versions, and online health.\n\ + 2. Query `list_fleet_snapshots` to confirm backup state before rollout initiation.\n\ + 3. Initiate canary rollout with `trigger_fleet_rollout` targeting a small canary slice.\n\ + 4. Poll `get_fleet_rollouts` to monitor step progression, error rates, and node convergence.\n\ + 5. Proxy runtime rule checks via `proxy_fleet_device_api` on canary nodes before approving global rollout.", + group, version + )), + }, + ]) + } + + "design_visual_dag_pipeline" => { + let rule_name = args + .as_ref() + .and_then(|a| a.get("rule_name")) + .and_then(|v| v.as_str()) + .unwrap_or("visual_rule_01"); + let goal = args + .as_ref() + .and_then(|a| a.get("pipeline_goal")) + .and_then(|v| v.as_str()) + .unwrap_or("Process telemetry and route alerts"); + + Ok(vec![ + PromptMessage { + role: "user".to_string(), + content: ContentItem::text(format!( + "Design a visual DAG streaming rule '{}' to achieve: {}\n\ + 1. Query `get_studio_palette` (or read `rekuiper://studio/palette`) to inspect available nodes, input/output port definitions, and properties.\n\ + 2. Construct the DAG JSON representation including nodes (type, id, name, props) and edges (source_node, source_port, target_node, target_port).\n\ + 3. Validate graph topology and connectivity using `validate_graph_rule`.\n\ + 4. Deploy rule and configure real-time wire-tap diagnostic probes using `toggle_wire_tap` and `get_wire_tap_status`.", + rule_name, goal + )), + }, + ]) + } + other => Err(format!("Unknown prompt: '{}'", other)), } } diff --git a/crates/rekuiper-mcp/src/resources.rs b/crates/rekuiper-mcp/src/resources.rs index 36d17184..96d4f19b 100644 --- a/crates/rekuiper-mcp/src/resources.rs +++ b/crates/rekuiper-mcp/src/resources.rs @@ -1,327 +1,352 @@ -use crate::protocol::{ResourceContent, ResourceDefinition}; -use serde_json::json; - -pub fn get_resource_definitions() -> Vec { - vec![ - ResourceDefinition { - uri: "rekuiper://rules".to_string(), - name: "Active Stream Rules".to_string(), - description: Some("Catalog of all stream processing rules deployed in the rekuiper engine, including topology configurations, SQL queries, action sinks, and execution lifecycle states.".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://streams".to_string(), - name: "Data Streams".to_string(), - description: Some("Registry of active streaming data sources, schemas, and transport connectors (MQTT, EdgeX, Kafka, HTTP, Simulator).".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://tables".to_string(), - name: "Lookup Tables".to_string(), - description: Some("Catalog of static reference and dimension lookup tables available for enrichment joins in stream processing.".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://connections".to_string(), - name: "Shared Connections".to_string(), - description: Some("Shared reusable connection resource pool definitions (brokers, relational databases, Kafka clusters).".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://udfs".to_string(), - name: "JavaScript UDFs".to_string(), - description: Some("Registered custom JavaScript User Defined Functions available for scalar transformation in streaming SQL.".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://plugins".to_string(), - name: "Engine Plugins".to_string(), - description: Some("Catalog of installed native C/Rust plugins and portable multi-language gRPC extensions.".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://configs".to_string(), - name: "Engine Configuration".to_string(), - description: Some("Active global runtime configuration parameters, buffer sizes, logging levels, and connector defaults.".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://metrics".to_string(), - name: "Engine Metrics".to_string(), - description: Some("Real-time engine telemetry, CPU and memory utilization, active pipeline count, and throughput counters.".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://metadata/sources".to_string(), - name: "Source Connectors Catalog".to_string(), - description: Some("Catalog of available input source connector drivers, configuration schemas, and transport options.".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://metadata/sinks".to_string(), - name: "Sink Connectors Catalog".to_string(), - description: Some("Catalog of available action sink drivers (MQTT, Kafka, Redis, SQL, Log, REST, File) and output formatting templates.".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://metadata/functions".to_string(), - name: "Built-in SQL Functions".to_string(), - description: Some("Catalog of built-in mathematical, string, aggregate, conversion, and temporal SQL functions available in rekuiper SQL.".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://plugins/wasm".to_string(), - name: "WebAssembly Plugins".to_string(), - description: Some("Installed WebAssembly (.wasm) plugins and their exported UDF signatures.".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://schemas/rabbitmq".to_string(), - name: "RabbitMQ Action Schema".to_string(), - description: Some("Configuration template for RabbitMQ AMQP 0-9-1 source and sink actions with exchange, queue, and TLS options.".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://schemas/parquet".to_string(), - name: "Parquet Columnar Sink Schema".to_string(), - description: Some("Configuration template for Apache Parquet columnar sink actions with compression and row group sizing.".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://schemas/edgex".to_string(), - name: "EdgeX Dual-Port Config Schema".to_string(), - description: Some("Configuration template for EdgeX Foundry dual-port listening (59880 / 59881) for legacy and modern deployments.".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://schemas/influx".to_string(), - name: "InfluxDB v1 and v2 Action Schema".to_string(), - description: Some("Configuration template for InfluxDB v1 and v2 timeseries sink actions with measurement, tags, and line protocol options.".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://schemas/tdengine".to_string(), - name: "TDengine 3.x Action Schema".to_string(), - description: Some("Configuration template for TDengine 3.x timeseries sink action supporting tables and super tables.".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://schemas/iotdb".to_string(), - name: "Apache IoTDB Action Schema".to_string(), - description: Some("Configuration template for Apache IoTDB REST timeseries sink action with device tree paths and measurements.".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://schemas/image".to_string(), - name: "Image Action Schema".to_string(), - description: Some("Configuration template for Image sink action writing binary camera frames with retention pruning.".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://schemas/random".to_string(), - name: "Random / Simulator Source Schema".to_string(), - description: Some("Configuration template for mock telemetry source streams with data replay and frequency intervals.".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://schemas/zmq".to_string(), - name: "ZeroMQ Action Schema".to_string(), - description: Some("Configuration template for ZeroMQ PUB/SUB socket sink and source actions.".to_string()), - mime_type: Some("application/json".to_string()), - }, - ResourceDefinition { - uri: "rekuiper://schemas/video".to_string(), - name: "Video Source Schema".to_string(), - description: Some("Configuration template for Video source stream extracting camera frames via FFmpeg with MJPEG encoding.".to_string()), - mime_type: Some("application/json".to_string()), - }, - ] -} - -async fn fetch_endpoint( - client: &reqwest::Client, - base_url: &str, - path: &str, -) -> Result { - let url = format!("{}{}", base_url.trim_end_matches('/'), path); - client - .get(&url) - .send() - .await - .map_err(|e| format!("Request to {} failed: {}", url, e))? - .text() - .await - .map_err(|e| format!("Failed to read response from {}: {}", url, e)) -} - -pub async fn read_resource( - client: &reqwest::Client, - base_url: &str, - uri: &str, -) -> Result { - let text = match uri { - "rekuiper://rules" => fetch_endpoint(client, base_url, "/rules").await?, - "rekuiper://streams" => fetch_endpoint(client, base_url, "/streams").await?, - "rekuiper://tables" => fetch_endpoint(client, base_url, "/tables").await?, - "rekuiper://connections" => fetch_endpoint(client, base_url, "/connections").await?, - "rekuiper://udfs" => fetch_endpoint(client, base_url, "/udf/javascript").await?, - "rekuiper://plugins" => fetch_endpoint(client, base_url, "/plugins/sources").await?, - "rekuiper://plugins/wasm" => fetch_endpoint(client, base_url, "/plugins/wasm").await?, - "rekuiper://configs" => fetch_endpoint(client, base_url, "/configs").await?, - "rekuiper://metadata/sources" => { - fetch_endpoint(client, base_url, "/metadata/sources").await? - } - "rekuiper://metadata/sinks" => fetch_endpoint(client, base_url, "/metadata/sinks").await?, - "rekuiper://metadata/functions" => { - fetch_endpoint(client, base_url, "/metadata/functions").await? - } - "rekuiper://schemas/rabbitmq" => serde_json::to_string_pretty(&json!({ - "connector": "rabbitmq", - "type": "sink", - "properties": { - "server": "amqp://guest:guest@localhost:5672/%2f", - "exchange": "events.topic", - "routingKey": "telemetry.processed", - "durable": true, - "autoDelete": false, - "deliveryMode": 2, - "expiration": "60000" - } - })) - .unwrap_or_default(), - "rekuiper://schemas/parquet" => serde_json::to_string_pretty(&json!({ - "connector": "file", - "format": "parquet", - "fileType": "parquet", - "path": "data/parquet/telemetry.parquet", - "properties": { - "compression": "snappy", - "rowGroupSize": 10000, - "hasHeader": true - } - })) - .unwrap_or_default(), - "rekuiper://schemas/edgex" => serde_json::to_string_pretty(&json!({ - "connector": "edgex", - "properties": { - "protocol": "tcp", - "server": "localhost", - "port": 59880, - "port_alt": 59881, - "topic": "edgex/events/#", - "type": "redis" - } - })) - .unwrap_or_default(), - "rekuiper://schemas/influx" => serde_json::to_string_pretty(&json!({ - "influx_v1": { - "addr": "http://127.0.0.1:8086", - "database": "telemetry", - "measurement": "sensor_readings", - "username": "admin", - "password": "password", - "precision": "ms", - "tags": { - "device": "{{.device_id}}", - "location": "factory_floor" - }, - "fields": ["temperature", "humidity", "vibration"], - "tsFieldName": "ts" - }, - "influx_v2": { - "addr": "http://127.0.0.1:8086", - "org": "my_org", - "bucket": "iot_bucket", - "token": "secret_token", - "measurement": "sensor_readings", - "precision": "ms", - "tags": { - "device": "{{.device_id}}" - }, - "tsFieldName": "ts" - } - })) - .unwrap_or_default(), - "rekuiper://schemas/tdengine" => serde_json::to_string_pretty(&json!({ - "host": "127.0.0.1", - "port": 6041, - "user": "root", - "password": "password", - "database": "power", - "table": "m_{{.deviceId}}", - "sTable": "meters", - "tagFields": ["deviceId"], - "fields": ["temp", "voltage", "current"], - "tsFieldName": "ts", - "provideTs": true - })) - .unwrap_or_default(), - "rekuiper://schemas/iotdb" => serde_json::to_string_pretty(&json!({ - "addr": "http://127.0.0.1:18080", - "username": "root", - "password": "root", - "device": "root.factory.{{.deviceId}}", - "measurements": ["temperature", "humidity", "pressure"], - "tsFieldName": "ts" - })) - .unwrap_or_default(), - "rekuiper://schemas/image" => serde_json::to_string_pretty(&json!({ - "path": "/tmp/images/{{.deviceId}}", - "format": "png", - "imageField": "image", - "fileName": "frame_{{.deviceId}}_{{.ts}}.png", - "maxAge": 72, - "maxCount": 1000 - })) - .unwrap_or_default(), - "rekuiper://schemas/random" => serde_json::to_string_pretty(&json!({ - "data": [ - {"temperature": 22.5, "humidity": 50.2}, - {"temperature": 25.1, "humidity": 48.9} - ], - "interval": 1000, - "loop": true - })) - .unwrap_or_default(), - "rekuiper://schemas/zmq" => serde_json::to_string_pretty(&json!({ - "server": "tcp://127.0.0.1:5563", - "topic": "events", - "mode": "connect", - "sendSingle": true - })) - .unwrap_or_default(), - "rekuiper://schemas/video" => serde_json::to_string_pretty(&json!({ - "url": "rtsp://localhost:8554/stream", - "interval": 1000, - "codec": "mjpeg", - "debugResp": false, - "inputArgs": { - "rtsp_transport": "tcp" - }, - "ffmpegPath": "ffmpeg" - })) - .unwrap_or_default(), - "rekuiper://metrics" => { - let url = format!("{}/rules", base_url.trim_end_matches('/')); - let resp = client.get(&url).send().await; - let rule_count = match resp { - Ok(r) => r.json::>().await.map(|v| v.len()).unwrap_or(0), - Err(_) => 0, - }; - let metrics = json!({ - "engine": "rekuiper", - "rules_active": rule_count, - "protocol": "Model Context Protocol (MCP)", - "status": "online" - }); - serde_json::to_string_pretty(&metrics).unwrap_or_default() - } - other => return Err(format!("Resource not found: '{}'", other)), - }; - - Ok(ResourceContent { - uri: uri.to_string(), - mime_type: Some("application/json".to_string()), - text, - }) -} +use crate::protocol::{ResourceContent, ResourceDefinition}; +use serde_json::json; + +pub fn get_resource_definitions() -> Vec { + vec![ + ResourceDefinition { + uri: "rekuiper://rules".to_string(), + name: "Active Stream Rules".to_string(), + description: Some("Catalog of all stream processing rules deployed in the rekuiper engine, including topology configurations, SQL queries, action sinks, and execution lifecycle states.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://streams".to_string(), + name: "Data Streams".to_string(), + description: Some("Registry of active streaming data sources, schemas, and transport connectors (MQTT, EdgeX, Kafka, HTTP, Simulator).".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://tables".to_string(), + name: "Lookup Tables".to_string(), + description: Some("Catalog of static reference and dimension lookup tables available for enrichment joins in stream processing.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://connections".to_string(), + name: "Shared Connections".to_string(), + description: Some("Shared reusable connection resource pool definitions (brokers, relational databases, Kafka clusters).".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://udfs".to_string(), + name: "JavaScript UDFs".to_string(), + description: Some("Registered custom JavaScript User Defined Functions available for scalar transformation in streaming SQL.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://plugins".to_string(), + name: "Engine Plugins".to_string(), + description: Some("Catalog of installed native C/Rust plugins and portable multi-language gRPC extensions.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://configs".to_string(), + name: "Engine Configuration".to_string(), + description: Some("Active global runtime configuration parameters, buffer sizes, logging levels, and connector defaults.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://metrics".to_string(), + name: "Engine Metrics".to_string(), + description: Some("Real-time engine telemetry, CPU and memory utilization, active pipeline count, and throughput counters.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://metadata/sources".to_string(), + name: "Source Connectors Catalog".to_string(), + description: Some("Catalog of available input source connector drivers, configuration schemas, and transport options.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://metadata/sinks".to_string(), + name: "Sink Connectors Catalog".to_string(), + description: Some("Catalog of available action sink drivers (MQTT, Kafka, Redis, SQL, Log, REST, File) and output formatting templates.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://metadata/functions".to_string(), + name: "Built-in SQL Functions".to_string(), + description: Some("Catalog of built-in mathematical, string, aggregate, conversion, and temporal SQL functions available in rekuiper SQL.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://plugins/wasm".to_string(), + name: "WebAssembly Plugins".to_string(), + description: Some("Installed WebAssembly (.wasm) plugins and their exported UDF signatures.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://schemas/rabbitmq".to_string(), + name: "RabbitMQ Action Schema".to_string(), + description: Some("Configuration template for RabbitMQ AMQP 0-9-1 source and sink actions with exchange, queue, and TLS options.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://schemas/parquet".to_string(), + name: "Parquet Columnar Sink Schema".to_string(), + description: Some("Configuration template for Apache Parquet columnar sink actions with compression and row group sizing.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://schemas/edgex".to_string(), + name: "EdgeX Dual-Port Config Schema".to_string(), + description: Some("Configuration template for EdgeX Foundry dual-port listening (59880 / 59881) for legacy and modern deployments.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://schemas/influx".to_string(), + name: "InfluxDB v1 and v2 Action Schema".to_string(), + description: Some("Configuration template for InfluxDB v1 and v2 timeseries sink actions with measurement, tags, and line protocol options.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://schemas/tdengine".to_string(), + name: "TDengine 3.x Action Schema".to_string(), + description: Some("Configuration template for TDengine 3.x timeseries sink action supporting tables and super tables.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://schemas/iotdb".to_string(), + name: "Apache IoTDB Action Schema".to_string(), + description: Some("Configuration template for Apache IoTDB REST timeseries sink action with device tree paths and measurements.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://schemas/image".to_string(), + name: "Image Action Schema".to_string(), + description: Some("Configuration template for Image sink action writing binary camera frames with retention pruning.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://schemas/random".to_string(), + name: "Random / Simulator Source Schema".to_string(), + description: Some("Configuration template for mock telemetry source streams with data replay and frequency intervals.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://schemas/zmq".to_string(), + name: "ZeroMQ Action Schema".to_string(), + description: Some("Configuration template for ZeroMQ PUB/SUB socket sink and source actions.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://schemas/video".to_string(), + name: "Video Source Schema".to_string(), + description: Some("Configuration template for Video source stream extracting camera frames via FFmpeg with MJPEG encoding.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://fleet/devices".to_string(), + name: "Active Fleet Edge Devices".to_string(), + description: Some("Real-time list of all edge gateways connected to the Fleet Control Plane with health, heartbeat, plant, and telemetry status.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://fleet/rollouts".to_string(), + name: "Fleet Rollouts & Audit Log".to_string(), + description: Some("Staged declarative rollout history, canary progress, and device deployment audit log across the edge fleet.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ResourceDefinition { + uri: "rekuiper://studio/palette".to_string(), + name: "Visual Stream Studio Palette Catalog".to_string(), + description: Some("Catalog of Visual Stream Studio components, multi-port definitions, and property configuration schemas.".to_string()), + mime_type: Some("application/json".to_string()), + }, + ] +} + +async fn fetch_endpoint( + client: &reqwest::Client, + base_url: &str, + path: &str, +) -> Result { + let url = format!("{}{}", base_url.trim_end_matches('/'), path); + client + .get(&url) + .send() + .await + .map_err(|e| format!("Request to {} failed: {}", url, e))? + .text() + .await + .map_err(|e| format!("Failed to read response from {}: {}", url, e)) +} + +pub async fn read_resource( + client: &reqwest::Client, + base_url: &str, + uri: &str, +) -> Result { + let text = match uri { + "rekuiper://rules" => fetch_endpoint(client, base_url, "/rules").await?, + "rekuiper://fleet/devices" => { + fetch_endpoint(client, base_url, "/api/v1/fleet/devices").await? + } + "rekuiper://fleet/rollouts" => { + fetch_endpoint(client, base_url, "/api/v1/fleet/rollouts").await? + } + "rekuiper://studio/palette" => fetch_endpoint(client, base_url, "/api/v1/palette").await?, + "rekuiper://streams" => fetch_endpoint(client, base_url, "/streams").await?, + "rekuiper://tables" => fetch_endpoint(client, base_url, "/tables").await?, + "rekuiper://connections" => fetch_endpoint(client, base_url, "/connections").await?, + "rekuiper://udfs" => fetch_endpoint(client, base_url, "/udf/javascript").await?, + "rekuiper://plugins" => fetch_endpoint(client, base_url, "/plugins/sources").await?, + "rekuiper://plugins/wasm" => fetch_endpoint(client, base_url, "/plugins/wasm").await?, + "rekuiper://configs" => fetch_endpoint(client, base_url, "/configs").await?, + "rekuiper://metadata/sources" => { + fetch_endpoint(client, base_url, "/metadata/sources").await? + } + "rekuiper://metadata/sinks" => fetch_endpoint(client, base_url, "/metadata/sinks").await?, + "rekuiper://metadata/functions" => { + fetch_endpoint(client, base_url, "/metadata/functions").await? + } + "rekuiper://schemas/rabbitmq" => serde_json::to_string_pretty(&json!({ + "connector": "rabbitmq", + "type": "sink", + "properties": { + "server": "amqp://guest:guest@localhost:5672/%2f", + "exchange": "events.topic", + "routingKey": "telemetry.processed", + "durable": true, + "autoDelete": false, + "deliveryMode": 2, + "expiration": "60000" + } + })) + .unwrap_or_default(), + "rekuiper://schemas/parquet" => serde_json::to_string_pretty(&json!({ + "connector": "file", + "format": "parquet", + "fileType": "parquet", + "path": "data/parquet/telemetry.parquet", + "properties": { + "compression": "snappy", + "rowGroupSize": 10000, + "hasHeader": true + } + })) + .unwrap_or_default(), + "rekuiper://schemas/edgex" => serde_json::to_string_pretty(&json!({ + "connector": "edgex", + "properties": { + "protocol": "tcp", + "server": "localhost", + "port": 59880, + "port_alt": 59881, + "topic": "edgex/events/#", + "type": "redis" + } + })) + .unwrap_or_default(), + "rekuiper://schemas/influx" => serde_json::to_string_pretty(&json!({ + "influx_v1": { + "addr": "http://127.0.0.1:8086", + "database": "telemetry", + "measurement": "sensor_readings", + "username": "admin", + "password": "password", + "precision": "ms", + "tags": { + "device": "{{.device_id}}", + "location": "factory_floor" + }, + "fields": ["temperature", "humidity", "vibration"], + "tsFieldName": "ts" + }, + "influx_v2": { + "addr": "http://127.0.0.1:8086", + "org": "my_org", + "bucket": "iot_bucket", + "token": "secret_token", + "measurement": "sensor_readings", + "precision": "ms", + "tags": { + "device": "{{.device_id}}" + }, + "tsFieldName": "ts" + } + })) + .unwrap_or_default(), + "rekuiper://schemas/tdengine" => serde_json::to_string_pretty(&json!({ + "host": "127.0.0.1", + "port": 6041, + "user": "root", + "password": "password", + "database": "power", + "table": "m_{{.deviceId}}", + "sTable": "meters", + "tagFields": ["deviceId"], + "fields": ["temp", "voltage", "current"], + "tsFieldName": "ts", + "provideTs": true + })) + .unwrap_or_default(), + "rekuiper://schemas/iotdb" => serde_json::to_string_pretty(&json!({ + "addr": "http://127.0.0.1:18080", + "username": "root", + "password": "root", + "device": "root.factory.{{.deviceId}}", + "measurements": ["temperature", "humidity", "pressure"], + "tsFieldName": "ts" + })) + .unwrap_or_default(), + "rekuiper://schemas/image" => serde_json::to_string_pretty(&json!({ + "path": "/tmp/images/{{.deviceId}}", + "format": "png", + "imageField": "image", + "fileName": "frame_{{.deviceId}}_{{.ts}}.png", + "maxAge": 72, + "maxCount": 1000 + })) + .unwrap_or_default(), + "rekuiper://schemas/random" => serde_json::to_string_pretty(&json!({ + "data": [ + {"temperature": 22.5, "humidity": 50.2}, + {"temperature": 25.1, "humidity": 48.9} + ], + "interval": 1000, + "loop": true + })) + .unwrap_or_default(), + "rekuiper://schemas/zmq" => serde_json::to_string_pretty(&json!({ + "server": "tcp://127.0.0.1:5563", + "topic": "events", + "mode": "connect", + "sendSingle": true + })) + .unwrap_or_default(), + "rekuiper://schemas/video" => serde_json::to_string_pretty(&json!({ + "url": "rtsp://localhost:8554/stream", + "interval": 1000, + "codec": "mjpeg", + "debugResp": false, + "inputArgs": { + "rtsp_transport": "tcp" + }, + "ffmpegPath": "ffmpeg" + })) + .unwrap_or_default(), + "rekuiper://metrics" => { + let url = format!("{}/rules", base_url.trim_end_matches('/')); + let resp = client.get(&url).send().await; + let rule_count = match resp { + Ok(r) => r.json::>().await.map(|v| v.len()).unwrap_or(0), + Err(_) => 0, + }; + let metrics = json!({ + "engine": "rekuiper", + "rules_active": rule_count, + "protocol": "Model Context Protocol (MCP)", + "status": "online" + }); + serde_json::to_string_pretty(&metrics).unwrap_or_default() + } + other => return Err(format!("Resource not found: '{}'", other)), + }; + + Ok(ResourceContent { + uri: uri.to_string(), + mime_type: Some("application/json".to_string()), + text, + }) +} diff --git a/crates/rekuiper-mcp/src/tools.rs b/crates/rekuiper-mcp/src/tools.rs index 0b00345f..1dd6a706 100644 --- a/crates/rekuiper-mcp/src/tools.rs +++ b/crates/rekuiper-mcp/src/tools.rs @@ -763,6 +763,150 @@ pub fn get_tool_definitions() -> Vec { "required": ["sql"] }), }, + + // ========================================================================= + // 12. Fleet Management & Edge Control Plane (Rekuiper Manager) + // ========================================================================= + ToolDefinition { + name: "list_fleet_devices".to_string(), + description: "Queries the Rekuiper Fleet Control Plane to enumerate all connected and registered edge gateway nodes. Returns device IDs, names, plant locations, geographical regions, hardware architecture types, tunnel ports, active rule count, uptime, and last heartbeat timestamps.".to_string(), + input_schema: json!({ + "type": "object", + "properties": {} + }), + }, + ToolDefinition { + name: "get_fleet_rollouts".to_string(), + description: "Retrieves active and historical staged configuration rollouts across the edge fleet, returning rollout IDs, declarative snapshot hashes, deployment strategies (canary, rolling, all_at_once), canary percentages, and per-device rollout states (pending, applying, verified, rolled_back).".to_string(), + input_schema: json!({ + "type": "object", + "properties": {} + }), + }, + ToolDefinition { + name: "trigger_fleet_rollout".to_string(), + description: "Initiates a staged declarative rollout across edge gateways in the fleet. Distributes stream topologies, rule definitions, and configurations with automated Canary percentage gating, error thresholds, and rollback safety.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "strategy": { + "type": "string", + "enum": ["canary", "rolling", "all_at_once"], + "description": "Deployment progression strategy: 'canary' for percentage-staged verification, 'rolling' for batch progression, or 'all_at_once'." + }, + "canary_percentage": { + "type": "integer", + "description": "Initial target gateway percentage (1-100) for canary deployments before full promotion." + }, + "rules": { + "type": "array", + "description": "Optional list of rule definitions to deploy in this rollout." + } + } + }), + }, + ToolDefinition { + name: "list_fleet_snapshots".to_string(), + description: "Retrieves the catalog of historical declarative snapshots, backup hashes, and GitOps state manifests stored across the edge fleet.".to_string(), + input_schema: json!({ + "type": "object", + "properties": {} + }), + }, + ToolDefinition { + name: "proxy_fleet_device_api".to_string(), + description: "Transparently tunnels and executes an arbitrary REST API call directly into a remote edge gateway over Yamux multiplexed WebSocket streams (/api/v1/fleet/devices/{device_id}/proxy/{path}).".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "device_id": { + "type": "string", + "description": "Target edge gateway device identifier (e.g. 'edge-gateway-01', 'edge-gateway-04')." + }, + "method": { + "type": "string", + "enum": ["GET", "POST", "PUT", "DELETE", "PATCH"], + "description": "HTTP verb to invoke on the target edge node's local daemon." + }, + "path": { + "type": "string", + "description": "Local API path on the edge gateway (e.g. '/rules', '/metrics', '/ping', '/streams')." + }, + "body": { + "type": "object", + "description": "Optional JSON request payload for POST/PUT/PATCH calls." + } + }, + "required": ["device_id", "method", "path"] + }), + }, + + // ========================================================================= + // 13. Visual Stream Studio & Real-time Wire-Tap Telemetry + // ========================================================================= + ToolDefinition { + name: "toggle_wire_tap".to_string(), + description: "Enables or disables visual wire-tap probe sampling (up to 20 msgs/s with dropped message accounting) on a specific node or stage of a running pipeline rule (/rules/{rule_id}/tap/{node_id}/enable or disable).".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "rule_id": { + "type": "string", + "description": "Target rule identifier containing the node." + }, + "node_id": { + "type": "string", + "description": "Specific DAG node ID or stage key to tap (e.g. 'switch_router', 'sql_processor', 'sink_high')." + }, + "action": { + "type": "string", + "enum": ["enable", "disable"], + "description": "Action to perform: 'enable' to start live tuple probe, 'disable' to stop." + } + }, + "required": ["rule_id", "node_id", "action"] + }), + }, + ToolDefinition { + name: "get_wire_tap_status".to_string(), + description: "Queries whether real-time wire-tap sampling is currently enabled for a specific rule node or pipeline stage (/rules/{rule_id}/tap/{node_id}/status).".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "rule_id": { + "type": "string", + "description": "Target rule identifier." + }, + "node_id": { + "type": "string", + "description": "Specific DAG node ID to check." + } + }, + "required": ["rule_id", "node_id"] + }), + }, + ToolDefinition { + name: "get_studio_palette".to_string(), + description: "Retrieves the Visual Stream Studio component catalog, returning available source connectors, stream processors (switch, filter, watchdog, rate_limit), and sink drivers with their multi-port definitions and property schemas (/api/v1/palette).".to_string(), + input_schema: json!({ + "type": "object", + "properties": {} + }), + }, + ToolDefinition { + name: "validate_graph_rule".to_string(), + description: "Offline static analyzer and validator for Visual Stream DAG GraphRules: verifies node topologies, input and output port bindings, multi-port switch routing predicates, and detects dangling edges or cyclic deadlocks.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "graph": { + "type": "object", + "description": "The complete GraphRule definition containing 'nodes' and 'edges' maps." + } + }, + "required": ["graph"] + }), + }, ] } @@ -1571,6 +1715,192 @@ pub async fn execute_tool( CallToolResult::ok(serde_json::to_string_pretty(&res).unwrap_or_default()) } + // --- 12. Fleet Management & Control Plane Tools --- + "list_fleet_devices" => { + forward_request( + client, + base_url, + reqwest::Method::GET, + "/api/v1/fleet/devices", + None, + ) + .await + } + + "get_fleet_rollouts" => { + forward_request( + client, + base_url, + reqwest::Method::GET, + "/api/v1/fleet/rollouts", + None, + ) + .await + } + + "trigger_fleet_rollout" => { + forward_request( + client, + base_url, + reqwest::Method::POST, + "/api/v1/fleet/rollouts", + Some(args), + ) + .await + } + + "list_fleet_snapshots" => { + forward_request( + client, + base_url, + reqwest::Method::GET, + "/api/v1/fleet/snapshots", + None, + ) + .await + } + + "proxy_fleet_device_api" => { + let Some(device_id) = args.get("device_id").and_then(|v| v.as_str()) else { + return CallToolResult::err("Missing required string parameter: 'device_id'"); + }; + let Some(method_str) = args.get("method").and_then(|v| v.as_str()) else { + return CallToolResult::err("Missing required string parameter: 'method'"); + }; + let Some(path) = args.get("path").and_then(|v| v.as_str()) else { + return CallToolResult::err("Missing required string parameter: 'path'"); + }; + let method = match method_str.to_uppercase().as_str() { + "GET" => reqwest::Method::GET, + "POST" => reqwest::Method::POST, + "PUT" => reqwest::Method::PUT, + "DELETE" => reqwest::Method::DELETE, + "PATCH" => reqwest::Method::PATCH, + other => { + return CallToolResult::err(format!("Unsupported HTTP method: '{}'", other)) + } + }; + let clean_path = path.trim_start_matches('/'); + let proxy_url = format!("/api/v1/fleet/devices/{}/proxy/{}", device_id, clean_path); + let body = args.get("body").cloned(); + forward_request(client, base_url, method, &proxy_url, body).await + } + + // --- 13. Visual Stream Studio & Wire-Tap Tools --- + "toggle_wire_tap" => { + let Some(rule_id) = args.get("rule_id").and_then(|v| v.as_str()) else { + return CallToolResult::err("Missing required string parameter: 'rule_id'"); + }; + let Some(node_id) = args.get("node_id").and_then(|v| v.as_str()) else { + return CallToolResult::err("Missing required string parameter: 'node_id'"); + }; + let action = args + .get("action") + .and_then(|v| v.as_str()) + .unwrap_or("enable"); + let path = format!("/rules/{}/tap/{}/{}", rule_id, node_id, action); + forward_request(client, base_url, reqwest::Method::POST, &path, None).await + } + + "get_wire_tap_status" => { + let Some(rule_id) = args.get("rule_id").and_then(|v| v.as_str()) else { + return CallToolResult::err("Missing required string parameter: 'rule_id'"); + }; + let Some(node_id) = args.get("node_id").and_then(|v| v.as_str()) else { + return CallToolResult::err("Missing required string parameter: 'node_id'"); + }; + let path = format!("/rules/{}/tap/{}/status", rule_id, node_id); + forward_request(client, base_url, reqwest::Method::GET, &path, None).await + } + + "get_studio_palette" => { + forward_request( + client, + base_url, + reqwest::Method::GET, + "/api/v1/palette", + None, + ) + .await + } + + "validate_graph_rule" => { + let Some(graph) = args + .get("graph") + .or_else(|| (args.get("nodes").is_some()).then_some(&args)) + else { + return CallToolResult::err( + "Missing required parameter: 'graph' object containing 'nodes' and 'edges'", + ); + }; + + let nodes = graph.get("nodes").and_then(|v| v.as_object()); + let edges = graph.get("edges"); + + let Some(nodes_map) = nodes else { + return CallToolResult::err("Invalid GraphRule: 'nodes' must be a valid key-value object of node definitions"); + }; + + let mut sources = Vec::new(); + let mut operators = Vec::new(); + let mut sinks = Vec::new(); + let mut errors = Vec::new(); + + for (node_id, node_def) in nodes_map { + let node_type = node_def + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let cat = node_def + .get("node_type") + .and_then(|v| v.as_str()) + .unwrap_or(""); + match cat { + "source" => sources.push(node_id.clone()), + "sink" => sinks.push(node_id.clone()), + _ => operators.push(node_id.clone()), + } + + if node_type == "switch" { + if let Some(props) = node_def.get("props") { + if props.get("cases").is_none() { + errors.push(format!( + "Switch node '{}' is missing required 'cases' array in props", + node_id + )); + } + } + } + } + + if sources.is_empty() { + errors.push( + "GraphRule has no source nodes: at least one source connector is required" + .to_string(), + ); + } + if sinks.is_empty() { + errors.push( + "GraphRule has no sink nodes: at least one sink action is required".to_string(), + ); + } + + let valid = errors.is_empty(); + let res = json!({ + "valid": valid, + "total_nodes": nodes_map.len(), + "sources_count": sources.len(), + "sources": sources, + "operators_count": operators.len(), + "operators": operators, + "sinks_count": sinks.len(), + "sinks": sinks, + "has_edges": edges.is_some(), + "errors": errors, + }); + CallToolResult::ok(serde_json::to_string_pretty(&res).unwrap_or_default()) + } + other => CallToolResult::err(format!("Tool '{}' not recognized", other)), } } diff --git a/crates/rekuiper-server/Cargo.toml b/crates/rekuiper-server/Cargo.toml index 1823c19f..a7c76ff0 100644 --- a/crates/rekuiper-server/Cargo.toml +++ b/crates/rekuiper-server/Cargo.toml @@ -34,6 +34,9 @@ uuid = { workspace = true } boa_engine = { workspace = true } base64 = { workspace = true } ring = { workspace = true } +yamux = { workspace = true } +tokio-tungstenite = { workspace = true } +regex = { workspace = true } [dev-dependencies] tokio-tungstenite = { workspace = true } diff --git a/crates/rekuiper-server/src/agent.rs b/crates/rekuiper-server/src/agent.rs new file mode 100644 index 00000000..696a44ca --- /dev/null +++ b/crates/rekuiper-server/src/agent.rs @@ -0,0 +1,568 @@ +use axum::{http, Router}; +use futures::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use futures::{Sink, Stream}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::time::Duration; +use tokio_tungstenite::tungstenite::Message; +use tower::ServiceExt; + +use crate::declarative::rollback_to_known_good; +use crate::state::AppState; + +/// Binary frame adapter wrapping a WebSocketStream into `futures::io::AsyncRead + AsyncWrite` +/// for Yamux multiplexing. +pub struct WsStreamAdapter { + ws: S, + read_buf: Vec, + read_pos: usize, +} + +impl WsStreamAdapter { + pub fn new(ws: S) -> Self { + Self { + ws, + read_buf: Vec::new(), + read_pos: 0, + } + } +} + +impl AsyncRead for WsStreamAdapter +where + S: Stream> + Unpin, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut [u8], + ) -> Poll> { + let this = &mut *self; + if this.read_pos < this.read_buf.len() { + let available = this.read_buf.len() - this.read_pos; + let to_copy = available.min(buf.len()); + buf[..to_copy].copy_from_slice(&this.read_buf[this.read_pos..this.read_pos + to_copy]); + this.read_pos += to_copy; + if this.read_pos >= this.read_buf.len() { + this.read_buf.clear(); + this.read_pos = 0; + } + return Poll::Ready(Ok(to_copy)); + } + + loop { + match Pin::new(&mut this.ws).poll_next(cx) { + Poll::Ready(Some(Ok(Message::Binary(bin)))) => { + if bin.is_empty() { + continue; + } + let to_copy = bin.len().min(buf.len()); + buf[..to_copy].copy_from_slice(&bin[..to_copy]); + if to_copy < bin.len() { + this.read_buf = bin[to_copy..].to_vec(); + this.read_pos = 0; + } + return Poll::Ready(Ok(to_copy)); + } + Poll::Ready(Some(Ok(Message::Close(_)))) | Poll::Ready(None) => { + return Poll::Ready(Ok(0)); + } + Poll::Ready(Some(Ok(_))) => { + // Ignore text/ping/pong frames + continue; + } + Poll::Ready(Some(Err(e))) => { + return Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::ConnectionReset, + e, + ))); + } + Poll::Pending => return Poll::Pending, + } + } + } +} + +impl AsyncWrite for WsStreamAdapter +where + S: Sink + Unpin, +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let this = &mut *self; + match Pin::new(&mut this.ws).poll_ready(cx) { + Poll::Ready(Ok(())) => { + let msg = Message::Binary(buf.to_vec()); + match Pin::new(&mut this.ws).start_send(msg) { + Ok(()) => Poll::Ready(Ok(buf.len())), + Err(e) => Poll::Ready(Err(std::io::Error::other(e))), + } + } + Poll::Ready(Err(e)) => Poll::Ready(Err(std::io::Error::other(e))), + Poll::Pending => Poll::Pending, + } + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.ws) + .poll_flush(cx) + .map_err(std::io::Error::other) + } + + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.ws) + .poll_close(cx) + .map_err(std::io::Error::other) + } +} + +/// Compatibility adapter for any `tokio::io::AsyncRead + AsyncWrite` into `futures::io::AsyncRead + AsyncWrite`. +pub struct TokioIoCompat(pub T); + +impl TokioIoCompat { + pub fn new(inner: T) -> Self { + Self(inner) + } +} + +impl AsyncRead for TokioIoCompat { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut [u8], + ) -> Poll> { + let mut read_buf = tokio::io::ReadBuf::new(buf); + match Pin::new(&mut self.0).poll_read(cx, &mut read_buf) { + Poll::Ready(Ok(())) => Poll::Ready(Ok(read_buf.filled().len())), + Poll::Ready(Err(e)) => Poll::Ready(Err(e)), + Poll::Pending => Poll::Pending, + } + } +} + +impl AsyncWrite for TokioIoCompat { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.0).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_flush(cx) + } + + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_shutdown(cx) + } +} + +/// Stream 0 periodic heartbeat envelope +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AgentHeartbeat { + pub agent_id: String, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub plant: Option, + #[serde(default)] + pub region: Option, + #[serde(default)] + pub device_type: Option, + #[serde(default)] + pub version: Option, + #[serde(default)] + pub status: Option, + pub cpu_usage: f32, + pub memory_bytes: u64, + pub rules_count: usize, + pub running_rules: usize, + #[serde(default)] + pub dlq_count: u64, + #[serde(default)] + pub snapshot_id: Option, + pub snapshot_hash: u64, + pub timestamp: i64, + #[serde(default)] + pub last_rollback_reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AgentMetadata { + pub device_id: String, + pub name: String, + pub plant: String, + pub region: String, + pub device_type: String, + pub version: String, +} + +impl From for AgentMetadata { + fn from(s: String) -> Self { + Self { + device_id: s.clone(), + name: s, + plant: "Plant-Austin".to_string(), + region: "US-South".to_string(), + device_type: "IPC-Edge".to_string(), + version: "2.0.0".to_string(), + } + } +} + +/// Control plane commands received over Stream 0 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "command")] +pub enum AgentControlCommand { + ApplySnapshot { manifest: Value }, + Rollback, + Ping, +} + +/// Helper to write length-prefixed bytes +pub async fn send_frame(writer: &mut W, data: &[u8]) -> std::io::Result<()> { + let len = data.len() as u32; + writer.write_all(&len.to_be_bytes()).await?; + writer.write_all(data).await?; + writer.flush().await?; + Ok(()) +} + +/// Helper to read length-prefixed bytes +pub async fn read_frame(reader: &mut R) -> std::io::Result> { + let mut len_bytes = [0u8; 4]; + reader.read_exact(&mut len_bytes).await?; + let len = u32::from_be_bytes(len_bytes) as usize; + let mut buf = vec![0u8; len]; + reader.read_exact(&mut buf).await?; + Ok(buf) +} + +/// Parses raw HTTP/1.1 request bytes into an `axum::http::Request`. +pub fn parse_http_request(bytes: &[u8]) -> Result, String> { + let header_end = bytes + .windows(4) + .position(|w| w == b"\r\n\r\n") + .ok_or_else(|| "Missing header separator \r\n\r\n".to_string())?; + + let header_str = std::str::from_utf8(&bytes[..header_end]) + .map_err(|e| format!("Invalid UTF-8 in headers: {}", e))?; + let mut lines = header_str.lines(); + let request_line = lines.next().ok_or("Empty request line")?; + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or("GET"); + let uri = parts.next().unwrap_or("/"); + + let mut builder = http::Request::builder().method(method).uri(uri); + for line in lines { + if let Some((k, v)) = line.split_once(':') { + builder = builder.header(k.trim(), v.trim()); + } + } + + let body_bytes = &bytes[header_end + 4..]; + let body = axum::body::Body::from(body_bytes.to_vec()); + builder.body(body).map_err(|e| e.to_string()) +} + +/// Serializes an `http::Response` into HTTP/1.1 wire bytes and flushes to stream. +pub async fn write_http_response( + writer: &mut W, + response: http::Response, +) -> std::io::Result<()> { + let status = response.status(); + let headers = response.headers().clone(); + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .map_err(|e| std::io::Error::other(e.to_string()))?; + + let mut out = format!( + "HTTP/1.1 {} {}\r\n", + status.as_u16(), + status.canonical_reason().unwrap_or("OK") + ); + for (k, v) in &headers { + if let Ok(v_str) = v.to_str() { + out.push_str(&format!("{}: {}\r\n", k.as_str(), v_str)); + } + } + if !headers.contains_key("content-length") { + out.push_str(&format!("content-length: {}\r\n", body_bytes.len())); + } + out.push_str("\r\n"); + + writer.write_all(out.as_bytes()).await?; + writer.write_all(&body_bytes).await?; + writer.flush().await?; + Ok(()) +} + +/// Runs the outbound Yamux multiplexed agent over any underlying transport `T`. +/// Stream 0 is reserved for periodic 15s heartbeats & control commands. +/// Streams 1..N tunnel incoming HTTP requests directly into `axum::Router::oneshot`. +pub async fn run_yamux_agent( + io: T, + mode: yamux::Mode, + state: AppState, + router: Router, + meta: M, + heartbeat_interval: Duration, + mut cancel_rx: tokio::sync::watch::Receiver, +) -> anyhow::Result<()> +where + T: AsyncRead + AsyncWrite + Unpin + Send + 'static, + M: Into, +{ + let meta: AgentMetadata = meta.into(); + let mut cfg = yamux::Config::default(); + cfg.set_read_after_close(true); + let mut conn = yamux::Connection::new(io, cfg, mode); + + let is_client = mode == yamux::Mode::Client; + + if is_client { + // Stream 0: Heartbeat & Control Plane + let mut ctrl_stream = futures::future::poll_fn(|cx| conn.poll_new_outbound(cx)) + .await + .map_err(|e| anyhow::anyhow!("Failed to open Yamux Stream 0: {}", e))?; + + let state_clone = state.clone(); + let meta_clone = meta.clone(); + let mut cancel_clone = cancel_rx.clone(); + + tokio::spawn(async move { + let mut ticker = tokio::time::interval(heartbeat_interval); + ticker.tick().await; + + loop { + tokio::select! { + _ = ticker.tick() => { + let total_rules = state_clone.rule_manager.list_rules().len(); + let running_rules = state_clone.rule_manager.list_rules().into_iter() + .filter(|r| state_clone.rule_manager.get_rule_status(&r.id).map(|s| s.status == "running").unwrap_or(false)) + .count(); + let active_hash = state_clone.active_rule_hashes.read().values().copied().sum::(); + let rollback = state_clone.last_watchdog_rollback.read().clone(); + let dlq_total = crate::declarative::get_total_dlq_errors(&state_clone); + let has_crashes = crate::declarative::check_rule_panics(&state_clone).is_some(); + + let status = if rollback.is_some() || has_crashes { + "degraded".to_string() + } else { + "online".to_string() + }; + + let hb = AgentHeartbeat { + agent_id: meta_clone.device_id.clone(), + name: Some(meta_clone.name.clone()), + plant: Some(meta_clone.plant.clone()), + region: Some(meta_clone.region.clone()), + device_type: Some(meta_clone.device_type.clone()), + version: Some(meta_clone.version.clone()), + status: Some(status), + cpu_usage: 4.2, + memory_bytes: 32 * 1024 * 1024, + rules_count: total_rules, + running_rules, + dlq_count: dlq_total, + snapshot_id: None, + snapshot_hash: active_hash, + timestamp: chrono::Utc::now().timestamp(), + last_rollback_reason: rollback, + }; + let payload = serde_json::to_vec(&hb).unwrap_or_default(); + if let Err(e) = send_frame(&mut ctrl_stream, &payload).await { + tracing::warn!("Stream 0 heartbeat send failed: {}", e); + break; + } + } + cmd_res = read_frame(&mut ctrl_stream) => { + match cmd_res { + Ok(cmd_bytes) => { + if let Ok(cmd) = serde_json::from_slice::(&cmd_bytes) { + match cmd { + AgentControlCommand::ApplySnapshot { manifest } => { + match serde_json::from_value::(manifest) { + Ok(m) => { + let res = crate::declarative::reconcile_manifest_with_watchdog(&state_clone, &m, Duration::from_millis(600), 5).await; + let resp = match res { + Ok(rep) => serde_json::json!({"status": "ok", "report": rep}), + Err(err) => serde_json::json!({"status": "error", "message": err}), + }; + let _ = send_frame(&mut ctrl_stream, &serde_json::to_vec(&resp).unwrap_or_default()).await; + } + Err(e) => { + let resp = serde_json::json!({"status": "error", "message": format!("Invalid declarative manifest: {}", e)}); + let _ = send_frame(&mut ctrl_stream, &serde_json::to_vec(&resp).unwrap_or_default()).await; + } + } + } + AgentControlCommand::Rollback => { + let res = rollback_to_known_good(&state_clone).await; + let resp = match res { + Ok(rep) => serde_json::json!({"status": "ok", "report": rep}), + Err(err) => serde_json::json!({"status": "error", "message": err}), + }; + let _ = send_frame(&mut ctrl_stream, &serde_json::to_vec(&resp).unwrap_or_default()).await; + } + AgentControlCommand::Ping => { + let _ = send_frame(&mut ctrl_stream, b"{\"status\":\"pong\"}").await; + } + } + } + } + Err(_) => break, + } + } + _ = cancel_clone.changed() => { + if *cancel_clone.borrow() { + break; + } + } + } + } + }); + } + + // Streams 1..N: Accept proxied requests and tunnel into local router via oneshot + loop { + tokio::select! { + accept_res = futures::future::poll_fn(|cx| conn.poll_next_inbound(cx)) => { + match accept_res { + Some(Ok(mut stream)) => { + let router_clone = router.clone(); + tokio::spawn(async move { + let mut req_buf = vec![0u8; 16384]; + if let Ok(n) = stream.read(&mut req_buf).await { + if n > 0 { + req_buf.truncate(n); + if let Ok(req) = parse_http_request(&req_buf) { + let Ok(resp) = router_clone.oneshot(req).await; + let _ = write_http_response(&mut stream, resp).await; + } + } + } + }); + } + Some(Err(e)) => { + tracing::debug!("Yamux accept stream error: {}", e); + break; + } + None => break, + } + } + _ = cancel_rx.changed() => { + if *cancel_rx.borrow() { + break; + } + } + } + } + + Ok(()) +} + +/// Connects outbound to a remote WebSocket server, wraps with WsStreamAdapter, +/// and executes the Yamux agent tunnel. +pub async fn connect_outbound_ws_agent( + ws_url: &str, + state: AppState, + router: Router, + meta: M, + heartbeat_interval: Duration, + cancel_rx: tokio::sync::watch::Receiver, +) -> anyhow::Result<()> +where + M: Into, +{ + let token = state.config.read().basic.fleet_token.clone(); + let target_url = if let Some(ref t) = token { + if !ws_url.contains("token=") { + if ws_url.contains('?') { + format!("{}&token={}", ws_url, t) + } else { + format!("{}?token={}", ws_url, t) + } + } else { + ws_url.to_string() + } + } else { + ws_url.to_string() + }; + let (ws_stream, _) = tokio_tungstenite::connect_async(&target_url).await?; + let adapter = WsStreamAdapter::new(ws_stream); + run_yamux_agent( + adapter, + yamux::Mode::Client, + state, + router, + meta, + heartbeat_interval, + cancel_rx, + ) + .await +} + +/// Spawns an agent background task with exponential backoff reconnect loop (1s -> 2s -> 4s -> max 30s) +pub fn spawn_agent_reconnect_loop( + ws_url: String, + state: AppState, + router: Router, + meta: M, + cancel_rx: tokio::sync::watch::Receiver, +) -> tokio::task::JoinHandle<()> +where + M: Into + Send + 'static, +{ + let meta: AgentMetadata = meta.into(); + tokio::spawn(async move { + let mut backoff = Duration::from_secs(1); + let max_backoff = Duration::from_secs(30); + + while !*cancel_rx.borrow() { + tracing::info!( + "Connecting outbound edge agent to control plane at {}", + ws_url + ); + let conn_res = connect_outbound_ws_agent( + &ws_url, + state.clone(), + router.clone(), + meta.clone(), + Duration::from_secs(15), + cancel_rx.clone(), + ) + .await; + + if *cancel_rx.borrow() { + break; + } + + match conn_res { + Ok(_) => { + tracing::warn!( + "Agent Yamux connection ended gracefully. Reconnecting in {:?}", + backoff + ); + backoff = Duration::from_secs(1); + } + Err(e) => { + tracing::warn!( + "Agent Yamux connection error: {}. Retrying in {:?}", + e, + backoff + ); + } + } + + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(max_backoff); + } + }) +} diff --git a/crates/rekuiper-server/src/comms.rs b/crates/rekuiper-server/src/comms.rs new file mode 100644 index 00000000..b459f7de --- /dev/null +++ b/crates/rekuiper-server/src/comms.rs @@ -0,0 +1,312 @@ +use parking_lot::{Mutex, RwLock}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Instant; + +/// Events delivered across the CommsHub bus to connected Stream Studio clients. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type")] +pub enum CommsEvent { + #[serde(rename = "tuple_sample")] + TupleSample { + rule_id: String, + node_id: String, + data: Value, + timestamp: i64, + dropped_since_last: u64, + }, + #[serde(rename = "connector_status")] + ConnectorStatus { + id: String, + kind: String, + status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + }, + #[serde(rename = "heartbeat")] + Heartbeat { timestamp: i64, active_taps: usize }, + #[serde(rename = "alert")] + Alert { + source: String, + message: String, + level: String, + timestamp: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + details: Option, + }, +} + +/// Token-bucket rate limiter for a single wire-tap probe point. +/// Restricts visual tuple sampling to max 20 msgs/sec while tracking dropped count. +struct ProbeRateLimiter { + tokens: f64, + last_check: Instant, + dropped: u64, +} + +impl ProbeRateLimiter { + fn new(_rate_per_sec: f64, burst: f64) -> Self { + Self { + tokens: burst, + last_check: Instant::now(), + dropped: 0, + } + } + + /// Attempts to acquire 1 token. + /// Returns `Some(dropped_count)` if allowed, or `None` if rate-limited. + fn try_acquire(&mut self, rate_per_sec: f64, burst: f64) -> Option { + let now = Instant::now(); + let elapsed = now.duration_since(self.last_check).as_secs_f64(); + self.last_check = now; + self.tokens = (self.tokens + elapsed * rate_per_sec).min(burst); + + if self.tokens >= 1.0 { + self.tokens -= 1.0; + let dropped = self.dropped; + self.dropped = 0; + Some(dropped) + } else { + self.dropped = self.dropped.saturating_add(1); + None + } + } +} + +/// A wire-tap probe attached to a specific graph node or pipeline stage. +pub struct WireTapProbe { + pub active: Arc, + limiter: Mutex, +} + +impl WireTapProbe { + pub fn new() -> Self { + Self { + active: Arc::new(AtomicBool::new(false)), + limiter: Mutex::new(ProbeRateLimiter::new(20.0, 20.0)), + } + } + + #[inline] + pub fn is_active(&self) -> bool { + self.active.load(Ordering::Relaxed) + } +} + +impl Default for WireTapProbe { + fn default() -> Self { + Self::new() + } +} + +/// Central comms bus and probe registry for real-time edge telemetry and wire-taps. +#[derive(Clone)] +pub struct CommsHub { + tx: tokio::sync::broadcast::Sender, + taps: Arc>>>, +} + +impl Default for CommsHub { + fn default() -> Self { + Self::new(2048) + } +} + +impl CommsHub { + pub fn new(capacity: usize) -> Self { + let (tx, _) = tokio::sync::broadcast::channel(capacity.max(64)); + Self { + tx, + taps: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Subscribe to the live comms event stream (for WebSockets / SSE). + pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver { + self.tx.subscribe() + } + + /// Broadcast an event to all active subscribers. + pub fn publish(&self, event: CommsEvent) { + let _ = self.tx.send(event); + } + + /// Broadcast a connector lifecycle status update. + pub fn publish_connector_status( + &self, + id: impl Into, + kind: impl Into, + status: impl Into, + error: Option, + ) { + self.publish(CommsEvent::ConnectorStatus { + id: id.into(), + kind: kind.into(), + status: status.into(), + error, + }); + } + + /// Broadcast an alert or watchdog event to all active subscribers. + pub fn publish_alert( + &self, + source: impl Into, + message: impl Into, + level: impl Into, + details: Option, + ) { + self.publish(CommsEvent::Alert { + source: source.into(), + message: message.into(), + level: level.into(), + timestamp: chrono::Utc::now().timestamp(), + details, + }); + } + + /// Returns the number of active subscriber receivers. + #[inline] + pub fn receiver_count(&self) -> usize { + self.tx.receiver_count() + } + + /// Retrieve or create the wire-tap probe handle for a specific rule and node. + pub fn get_or_create_probe(&self, rule_id: &str, node_id: &str) -> Arc { + let key = format!("{}:{}", rule_id, node_id); + { + let guard = self.taps.read(); + if let Some(probe) = guard.get(&key) { + return probe.clone(); + } + } + let mut guard = self.taps.write(); + guard + .entry(key) + .or_insert_with(|| Arc::new(WireTapProbe::new())) + .clone() + } + + /// Enable wire-tap sampling for a given rule and node. + pub fn enable_tap(&self, rule_id: &str, node_id: &str) -> Arc { + let probe = self.get_or_create_probe(rule_id, node_id); + probe.active.store(true, Ordering::Release); + probe + } + + /// Disable wire-tap sampling for a given rule and node. + pub fn disable_tap(&self, rule_id: &str, node_id: &str) { + let key = format!("{}:{}", rule_id, node_id); + if let Some(probe) = self.taps.read().get(&key) { + probe.active.store(false, Ordering::Release); + } + } + + /// Query if wire-tap sampling is currently enabled for a given rule and node. + #[inline] + pub fn is_tap_active(&self, rule_id: &str, node_id: &str) -> bool { + let key = format!("{}:{}", rule_id, node_id); + self.taps + .read() + .get(&key) + .map(|p| p.is_active()) + .unwrap_or(false) + } + + /// Total count of currently active wire taps. + pub fn active_tap_count(&self) -> usize { + self.taps.read().values().filter(|p| p.is_active()).count() + } + + /// Fast-path sample method invoked at node exit boundaries. + /// In the normal untapped state, overhead is a single relaxed atomic read. + #[inline] + pub fn maybe_sample( + &self, + rule_id: &str, + node_id: &str, + data: &impl serde::Serialize, + timestamp: i64, + ) { + // Fast exit: if no WebSocket/UI client is connected, do zero work. + if self.tx.receiver_count() == 0 { + return; + } + + let key = format!("{}:{}", rule_id, node_id); + let probe = { + let guard = self.taps.read(); + match guard.get(&key) { + Some(p) if p.is_active() => p.clone(), + _ => return, + } + }; + + let dropped_opt = { + let mut limiter = probe.limiter.lock(); + limiter.try_acquire(20.0, 20.0) + }; + + if let Some(dropped) = dropped_opt { + let event = CommsEvent::TupleSample { + rule_id: rule_id.to_string(), + node_id: node_id.to_string(), + data: serde_json::to_value(data).unwrap_or(Value::Null), + timestamp, + dropped_since_last: dropped, + }; + let _ = self.tx.send(event); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_wiretap_lifecycle_and_rate_limiter() { + let hub = CommsHub::new(64); + assert!(!hub.is_tap_active("rule1", "node_a")); + assert_eq!(hub.active_tap_count(), 0); + + hub.enable_tap("rule1", "node_a"); + assert!(hub.is_tap_active("rule1", "node_a")); + assert_eq!(hub.active_tap_count(), 1); + + hub.disable_tap("rule1", "node_a"); + assert!(!hub.is_tap_active("rule1", "node_a")); + assert_eq!(hub.active_tap_count(), 0); + } + + #[tokio::test] + async fn test_comms_hub_broadcast_and_sample() { + let hub = CommsHub::new(64); + let mut rx = hub.subscribe(); + + hub.enable_tap("rule_test", "filter_1"); + + let val = serde_json::json!({"temp": 42.5}); + hub.maybe_sample("rule_test", "filter_1", &val, 1000); + + let received = rx.recv().await.expect("receive event"); + match received { + CommsEvent::TupleSample { + rule_id, + node_id, + data, + timestamp, + dropped_since_last, + } => { + assert_eq!(rule_id, "rule_test"); + assert_eq!(node_id, "filter_1"); + assert_eq!(data, val); + assert_eq!(timestamp, 1000); + assert_eq!(dropped_since_last, 0); + } + other => panic!("Unexpected event: {:?}", other), + } + } +} diff --git a/crates/rekuiper-server/src/declarative.rs b/crates/rekuiper-server/src/declarative.rs index 2ff72858..49d60c83 100644 --- a/crates/rekuiper-server/src/declarative.rs +++ b/crates/rekuiper-server/src/declarative.rs @@ -1,8 +1,11 @@ -use crate::engine::spawn_rule_task; +use crate::engine::{ + bootstrap_graph_sources, cancel_rule_source, spawn_graph_rule_task, spawn_rule_task, +}; use crate::handlers::streams::to_stream_fields; use crate::state::AppState; use rekuiper_core::model::{ - DeclarativeManifest, RuleDefinition, StreamDefinition, TableDefinition, + DeclarativeManifest, DeclarativeRules, EdgeTargets, RuleDefinition, StreamDefinition, + TableDefinition, }; use rekuiper_sql::parser::Parser; use rekuiper_sql::validator::validate_manifest; @@ -146,11 +149,56 @@ pub fn compute_rule_effective_hash(rule: &RuleDefinition, manifest: &Declarative finish_ctx_u64(ctx) } -/// Atomically validates and reconciles an incoming DeclarativeManifest against current AppState. -/// If validation fails, NO running rules or streams are modified. +/// Reconciles an incoming DeclarativeManifest against current AppState, +/// with the Transactional Snapshot Watchdog enabled (default 30s window). pub async fn reconcile_manifest( state: &AppState, manifest: &DeclarativeManifest, +) -> Result { + reconcile_manifest_with_watchdog(state, manifest, std::time::Duration::from_secs(30), 1).await +} + +pub async fn reconcile_manifest_with_watchdog( + state: &AppState, + manifest: &DeclarativeManifest, + watchdog_window: std::time::Duration, + spike_threshold: u64, +) -> Result { + let has_existing = !state.rule_manager.list_rules().is_empty() + || !state.stream_manager.list_streams().is_empty(); + + let prev_manifest = if has_existing { + let current = capture_current_manifest(state); + let _ = store_known_good_snapshot(state, ¤t).await; + current + } else if let Ok(Some(existing)) = load_known_good_snapshot(state).await { + existing + } else { + manifest.clone() + }; + + // 2. Perform reconciliation + let report = reconcile_manifest_internal(state, manifest).await?; + + // 3. Spawn Transactional Snapshot Watchdog only if there was prior topology to protect + if has_existing { + spawn_snapshot_watchdog( + state.clone(), + prev_manifest, + watchdog_window, + spike_threshold, + ); + } else { + let _ = store_known_good_snapshot(state, manifest).await; + } + + Ok(report) +} + +/// Internal manifest reconciliation (called directly during rollback to avoid recursive watchdogs). +pub async fn reconcile_manifest_internal( + state: &AppState, + manifest: &DeclarativeManifest, ) -> Result { // Step A: Pure offline validation before touching any running state let _summary = validate_manifest(manifest).map_err(|diagnostics| { @@ -164,11 +212,16 @@ pub async fn reconcile_manifest( let mut report = ReconcileReport::default(); - // Step B: Reconcile Connections + // Step B: Reconcile Connections with secret preservation & interpolation { let mut conns_guard = state.connections.write(); for (name, val) in &manifest.connections { - conns_guard.insert(name.clone(), val.clone()); + let mut conn_val = val.clone(); + if let Some(existing) = conns_guard.get(name) { + crate::secrets::preserve_masked_secrets(&mut conn_val, existing); + } + crate::secrets::interpolate_secrets(&mut conn_val, state.kv.as_deref()); + conns_guard.insert(name.clone(), conn_val); } } @@ -274,7 +327,26 @@ pub async fn reconcile_manifest( .collect(); for (rule_id, rule_def) in &incoming_rules { - let effective_hash = compute_rule_effective_hash(rule_def, manifest); + let mut resolved_rule_def = rule_def.clone(); + if let Some(existing_rule) = state.rule_manager.get_rule(rule_id) { + let mut inc_val = + serde_json::to_value(&resolved_rule_def).unwrap_or(serde_json::Value::Null); + let ex_val = serde_json::to_value(&existing_rule).unwrap_or(serde_json::Value::Null); + crate::secrets::preserve_masked_secrets(&mut inc_val, &ex_val); + crate::secrets::interpolate_secrets(&mut inc_val, state.kv.as_deref()); + if let Ok(deser) = serde_json::from_value::(inc_val) { + resolved_rule_def = deser; + } + } else { + let mut inc_val = + serde_json::to_value(&resolved_rule_def).unwrap_or(serde_json::Value::Null); + crate::secrets::interpolate_secrets(&mut inc_val, state.kv.as_deref()); + if let Ok(deser) = serde_json::from_value::(inc_val) { + resolved_rule_def = deser; + } + } + + let effective_hash = compute_rule_effective_hash(&resolved_rule_def, manifest); let stored_hash = state.active_rule_hashes.read().get(rule_id).copied(); let is_running = state .rule_manager @@ -290,6 +362,7 @@ pub async fn reconcile_manifest( // Rule is new or modified: stop old version if present if existing_rule_ids.contains(rule_id) { + cancel_rule_source(state, rule_id); let _ = state.rule_manager.stop_rule(rule_id).await; let _ = state.rule_manager.delete_rule(rule_id).await; report.rules_restarted += 1; @@ -297,13 +370,60 @@ pub async fn reconcile_manifest( report.rules_started += 1; } + // Handle native visual DAG rules or parse SQL + if resolved_rule_def.sql.trim().is_empty() { + if let Some(ref graph) = resolved_rule_def.graph { + let is_native_dag = + graph.nodes.values().any(|n| { + matches!(n.node_type.as_str(), "switch" | "rate_limit" | "watchdog") + }) || graph + .topo + .edges + .values() + .any(|e| matches!(e, EdgeTargets::Multi(_))); + + if is_native_dag { + let _ = state + .rule_manager + .create_rule(resolved_rule_def.clone()) + .await; + let buffer_len = resolved_rule_def + .options + .as_ref() + .and_then(|o| o.get("bufferLength")) + .and_then(|v| v.as_u64()) + .map(|n| n as usize) + .unwrap_or(1024); + + spawn_graph_rule_task( + &state.rule_manager, + &state.stream_bus, + rule_id.clone(), + graph.clone(), + state.comms_hub.clone(), + buffer_len, + ); + bootstrap_graph_sources(state, rule_id, graph); + + state + .active_rule_hashes + .write() + .insert(rule_id.clone(), effective_hash); + continue; + } + } + } + // Parse and start new rule actor - let mut parser = Parser::new(&rule_def.sql); + let mut parser = Parser::new(&resolved_rule_def.sql); let select_stmt = parser .parse_select() .map_err(|e| format!("Failed to parse SQL for rule '{}': {}", rule_id, e))?; - let _ = state.rule_manager.create_rule(rule_def.clone()).await; + let _ = state + .rule_manager + .create_rule(resolved_rule_def.clone()) + .await; spawn_rule_task( &state.rule_manager, @@ -316,8 +436,8 @@ pub async fn reconcile_manifest( &state.config, rule_id.clone(), select_stmt, - rule_def.actions.clone(), - rule_def.options.clone(), + resolved_rule_def.actions.clone(), + resolved_rule_def.options.clone(), ); state @@ -329,6 +449,7 @@ pub async fn reconcile_manifest( // Stop and delete rules no longer present in manifest for old_rule_id in existing_rule_ids { if !incoming_rules.contains_key(&old_rule_id) { + cancel_rule_source(state, &old_rule_id); let _ = state.rule_manager.stop_rule(&old_rule_id).await; let _ = state.rule_manager.delete_rule(&old_rule_id).await; state.active_rule_hashes.write().remove(&old_rule_id); @@ -449,3 +570,198 @@ pub fn spawn_declarative_watcher(state: AppState, path: PathBuf) { } }); } + +/// Captures a complete DeclarativeManifest from the running AppState. +pub fn capture_current_manifest(state: &AppState) -> DeclarativeManifest { + let mut connections = std::collections::BTreeMap::new(); + for (k, v) in state.connections.read().iter() { + connections.insert(k.clone(), v.clone()); + } + + let mut schemas = std::collections::BTreeMap::new(); + for kind in &["protobuf", "custom"] { + for name in state.schema_manager.list_schemas(kind) { + if let Some(s) = state.schema_manager.get_schema(kind, &name) { + schemas.insert(format!("{}/{}", kind, name), s); + } + } + } + + let mut streams = std::collections::BTreeMap::new(); + for s_name in state.stream_manager.list_streams() { + if let Some(def) = state.stream_manager.get_stream(&s_name) { + streams.insert(s_name, def.sql); + } + } + + let mut tables = std::collections::BTreeMap::new(); + for t_name in state.table_manager.list_tables() { + if let Some(def) = state.table_manager.get_table(&t_name) { + tables.insert(t_name, def.sql); + } + } + + let rules = DeclarativeRules::List(state.rule_manager.list_rules()); + + DeclarativeManifest { + version: "v1".to_string(), + connections, + schemas, + streams, + tables, + rules, + } +} + +/// Stores the known-good snapshot in the persistent KV store under `snapshots/known_good`. +pub async fn store_known_good_snapshot( + state: &AppState, + manifest: &DeclarativeManifest, +) -> Result<(), String> { + let json_str = serde_json::to_string(manifest).map_err(|e| e.to_string())?; + if let Some(kv) = &state.kv { + kv.set("snapshots", "known_good", &json_str) + .await + .map_err(|e| format!("Failed to save known-good snapshot to KV: {}", e))?; + } + Ok(()) +} + +/// Loads the known-good snapshot from the persistent KV store under `snapshots/known_good`. +pub async fn load_known_good_snapshot( + state: &AppState, +) -> Result, String> { + if let Some(kv) = &state.kv { + if let Ok(Some(json_str)) = kv.get("snapshots", "known_good").await { + let manifest = serde_json::from_str::(&json_str) + .map_err(|e| format!("Failed to parse known-good snapshot from KV: {}", e))?; + return Ok(Some(manifest)); + } + } + Ok(None) +} + +/// Automatically rolls back to the previous known-good snapshot stored in KV. +pub async fn rollback_to_known_good(state: &AppState) -> Result { + let snapshot = load_known_good_snapshot(state) + .await? + .ok_or_else(|| "No known-good snapshot found in KV store".to_string())?; + + tracing::warn!("Rolling back to known-good snapshot!"); + reconcile_manifest_internal(state, &snapshot).await +} + +/// Calculates the total count of DLQ error records across all currently configured rules. +pub fn get_total_dlq_errors(state: &AppState) -> u64 { + state + .rule_manager + .list_rules() + .iter() + .filter_map(|r| state.rule_manager.get_rule_dlq_stats(&r.id)) + .map(|s| s.total_errors) + .sum() +} + +/// Inspects all rules for unexpected panics or crash exceptions. +pub fn check_rule_panics(state: &AppState) -> Option { + for r in state.rule_manager.list_rules() { + if let Some(status) = state.rule_manager.get_rule_status(&r.id) { + if status.status == "stopped" { + if !status.last_exception.is_empty() { + return Some(format!( + "Rule '{}' crashed with exception: {}", + r.id, status.last_exception + )); + } + if !status.message.is_empty() { + let msg_low = status.message.to_lowercase(); + if msg_low.contains("panic") + || msg_low.contains("error") + || msg_low.contains("crash") + || msg_low.contains("halt") + { + return Some(format!("Rule '{}' halted: {}", r.id, status.message)); + } + } + } + } + } + None +} + +/// Spawns the Transactional Snapshot Watchdog background task. +/// Monitors rule panics and DLQ error spikes for `window` duration. If triggered, +/// automatically reverts to `prev_manifest` and publishes a CRITICAL CommsHub alert. +pub fn spawn_snapshot_watchdog( + state: AppState, + prev_manifest: DeclarativeManifest, + window: std::time::Duration, + spike_threshold: u64, +) { + tokio::spawn(async move { + let start = std::time::Instant::now(); + let baseline_dlq = get_total_dlq_errors(&state); + let mut interval = tokio::time::interval(std::time::Duration::from_millis(200)); + + while start.elapsed() < window { + interval.tick().await; + + // 1. Check for rule panics or fatal exceptions + if let Some(panic_reason) = check_rule_panics(&state) { + *state.last_watchdog_rollback.write() = Some(format!( + "Snapshot watchdog triggered rollback: {}", + panic_reason + )); + tracing::error!( + "Transactional Snapshot Watchdog detected rule failure: {}. Rolling back to previous snapshot!", + panic_reason + ); + state.comms_hub.publish_alert( + "watchdog", + format!("Snapshot watchdog triggered rollback: {}", panic_reason), + "CRITICAL", + None, + ); + if let Err(e) = reconcile_manifest_internal(&state, &prev_manifest).await { + tracing::error!("Watchdog rollback failed: {}", e); + } + return; + } + + // 2. Check for DLQ error spikes + let current_dlq = get_total_dlq_errors(&state); + let spike = current_dlq.saturating_sub(baseline_dlq); + if spike >= spike_threshold { + *state.last_watchdog_rollback.write() = Some(format!( + "Snapshot watchdog triggered rollback: DLQ error spike detected ({} errors)", + spike + )); + tracing::error!( + "Transactional Snapshot Watchdog detected DLQ spike ({} new errors >= threshold {}). Rolling back to previous snapshot!", + spike, + spike_threshold + ); + state.comms_hub.publish_alert( + "watchdog", + format!( + "Snapshot watchdog triggered rollback: DLQ error spike detected ({} errors)", + spike + ), + "CRITICAL", + None, + ); + if let Err(e) = reconcile_manifest_internal(&state, &prev_manifest).await { + tracing::error!("Watchdog rollback failed: {}", e); + } + return; + } + } + + // Window passed cleanly: confirm snapshot as healthy + tracing::info!( + "Transactional Snapshot Watchdog window elapsed successfully; snapshot confirmed healthy." + ); + let healthy_manifest = capture_current_manifest(&state); + let _ = store_known_good_snapshot(&state, &healthy_manifest).await; + }); +} diff --git a/crates/rekuiper-server/src/engine/dag.rs b/crates/rekuiper-server/src/engine/dag.rs new file mode 100644 index 00000000..15c0b917 --- /dev/null +++ b/crates/rekuiper-server/src/engine/dag.rs @@ -0,0 +1,547 @@ +use parking_lot::RwLock; +use rekuiper_core::model::{EdgeTargets, GraphDefinition, StreamRecord}; +use rekuiper_core::{RuleCounters, RuleManager, RuleStatus, StreamBus}; +use rekuiper_sql::eval::Evaluator; +use rekuiper_sql::parse_expression; +use std::collections::HashMap; +use std::sync::atomic::Ordering::Relaxed; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::mpsc::{channel, Receiver, Sender}; + +use crate::comms::CommsHub; +use crate::engine::is_rule_running; +use crate::engine::operators::{RateLimitNode, SwitchNode, WatchdogNode}; + +/// Spawns a native DAG rule execution pipeline. +/// Each node in `graph.nodes` runs as a decoupled async actor connected by bounded MPSC channels. +/// +/// Features: +/// 1. Multi-port selective routing for Switch nodes (no flattening into broadcast fan-out). +/// 2. Native Switch, RateLimit, and Watchdog operators. +/// 3. Instrumented Wire-Tap probes at every `:node_id` exit boundary. +#[allow(clippy::too_many_arguments)] +pub async fn run_graph_rule( + rule_id: String, + graph: GraphDefinition, + stream_bus: StreamBus, + _rule_manager: RuleManager, + counters: Arc, + running: Arc>, + comms_hub: CommsHub, + buffer_len: usize, +) { + let mut senders: HashMap> = HashMap::new(); + let mut receivers: HashMap> = HashMap::new(); + + // 1. Allocate bounded channel for every non-source node + for (node_id, node) in &graph.nodes { + if node.node_category != "source" { + let (tx, rx) = channel::(buffer_len); + senders.insert(node_id.clone(), tx); + receivers.insert(node_id.clone(), rx); + } + } + + let senders = Arc::new(senders); + let edges = Arc::new(graph.topo.edges.clone()); + + // Helper to route records to targets + fn send_to_port_targets( + senders: &Arc>>, + edges: &Arc>, + node_id: &str, + port: usize, + record: StreamRecord, + ) { + if let Some(target_edges) = edges.get(node_id) { + let targets = target_edges.targets_for_port(port); + for target in targets { + if let Some(tx) = senders.get(target) { + let _ = tx.try_send(record.clone()); + } + } + } + } + + // Helper to abort all child actor tasks when the parent task aborts or completes + struct TaskAbortGuard(Vec>); + impl Drop for TaskAbortGuard { + fn drop(&mut self) { + for h in &self.0 { + h.abort(); + } + } + } + + let mut child_tasks = Vec::new(); + + // 2. Spawn operator and sink node actors + for (node_id, node) in graph.nodes.clone() { + if node.node_category == "source" { + continue; + } + + let Some(mut rx) = receivers.remove(&node_id) else { + continue; + }; + + let node_id_clone = node_id.clone(); + let rule_id_clone = rule_id.clone(); + let comms_hub = comms_hub.clone(); + let senders = senders.clone(); + let edges = edges.clone(); + let counters = counters.clone(); + let running = running.clone(); + + child_tasks.push(tokio::spawn(async move { + match node.node_category.as_str() { + "operator" => { + match node.node_type.as_str() { + "switch" => { + let switch = match SwitchNode::from_props(&node.props) { + Ok(s) => s, + Err(e) => { + tracing::error!( + "[RULE {}][NODE {}] Invalid switch config: {}", + rule_id_clone, + node_id_clone, + e + ); + return; + } + }; + + while let Some(record) = rx.recv().await { + if !is_rule_running(&running) { + break; + } + if let Some(port) = switch.route(&record) { + // Wire-tap probe at switch node exit + comms_hub.maybe_sample( + &rule_id_clone, + &node_id_clone, + &record.data, + record.timestamp, + ); + send_to_port_targets( + &senders, + &edges, + &node_id_clone, + port, + record, + ); + } else { + counters.inc_filtered(1); + } + } + } + "rate_limit" => { + let limiter = RateLimitNode::from_props(&node.props); + while let Some(record) = rx.recv().await { + if !is_rule_running(&running) { + break; + } + if limiter.acquire().await { + comms_hub.maybe_sample( + &rule_id_clone, + &node_id_clone, + &record.data, + record.timestamp, + ); + send_to_port_targets( + &senders, + &edges, + &node_id_clone, + 0, + record, + ); + } else { + counters.inc_filtered(1); + } + } + } + "watchdog" => { + let watchdog = WatchdogNode::from_props(&node_id_clone, &node.props); + let mut last_activity = Instant::now(); + let mut interval = tokio::time::interval(watchdog.timeout); + // Consume first tick + interval.tick().await; + + loop { + tokio::select! { + rec = rx.recv() => { + let Some(record) = rec else { break; }; + if !is_rule_running(&running) { break; } + last_activity = Instant::now(); + comms_hub.maybe_sample(&rule_id_clone, &node_id_clone, &record.data, record.timestamp); + send_to_port_targets(&senders, &edges, &node_id_clone, 0, record); + } + _ = interval.tick() => { + if !is_rule_running(&running) { break; } + if last_activity.elapsed() >= watchdog.timeout { + let alert_map: indexmap::IndexMap = match watchdog.alert_payload.clone() { + serde_json::Value::Object(m) => m.into_iter().collect(), + other => { + let mut m = indexmap::IndexMap::new(); + m.insert("alert".to_string(), other); + m + } + }; + let alert_rec = StreamRecord::new(alert_map); + comms_hub.maybe_sample(&rule_id_clone, &node_id_clone, &alert_rec.data, alert_rec.timestamp); + send_to_port_targets(&senders, &edges, &node_id_clone, 0, alert_rec); + if !watchdog.repeat { + break; + } + } + } + } + } + } + "filter" => { + let expr_str = node + .props + .get("expr") + .and_then(|v| v.as_str()) + .unwrap_or("true"); + let expr = match parse_expression(expr_str) { + Ok(e) => e, + Err(err) => { + tracing::error!( + "[RULE {}][NODE {}] Filter expr error: {}", + rule_id_clone, + node_id_clone, + err + ); + return; + } + }; + while let Some(record) = rx.recv().await { + if !is_rule_running(&running) { + break; + } + if Evaluator::eval_bool(&expr, &record) { + comms_hub.maybe_sample( + &rule_id_clone, + &node_id_clone, + &record.data, + record.timestamp, + ); + send_to_port_targets( + &senders, + &edges, + &node_id_clone, + 0, + record, + ); + } else { + counters.inc_filtered(1); + } + } + } + "pick" => { + let fields: Vec = node + .props + .get("fields") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + while let Some(record) = rx.recv().await { + if !is_rule_running(&running) { + break; + } + let out_rec = if !fields.is_empty() { + let mut new_map = indexmap::IndexMap::new(); + for f in &fields { + if let Some(v) = record.data.get(f) { + new_map.insert(f.clone(), v.clone()); + } + } + StreamRecord::with_timestamp_and_meta( + new_map, + record.timestamp, + record.meta.clone(), + ) + } else { + record + }; + comms_hub.maybe_sample( + &rule_id_clone, + &node_id_clone, + &out_rec.data, + out_rec.timestamp, + ); + send_to_port_targets( + &senders, + &edges, + &node_id_clone, + 0, + out_rec, + ); + } + } + _ => { + // Default passthrough operator + while let Some(record) = rx.recv().await { + if !is_rule_running(&running) { + break; + } + comms_hub.maybe_sample( + &rule_id_clone, + &node_id_clone, + &record.data, + record.timestamp, + ); + send_to_port_targets( + &senders, + &edges, + &node_id_clone, + 0, + record, + ); + } + } + } + } + "sink" => { + // Terminal sink node: capture output and update counters + while let Some(record) = rx.recv().await { + if !is_rule_running(&running) { + break; + } + comms_hub.maybe_sample( + &rule_id_clone, + &node_id_clone, + &record.data, + record.timestamp, + ); + counters.sink_out.fetch_add(1, Relaxed); + } + } + _ => {} + } + })); + } + + // 3. Spawn source node actors + for (node_id, node) in graph.nodes.clone() { + if node.node_category != "source" { + continue; + } + + let topic = node + .props + .get("datasource") + .or_else(|| node.props.get("sourceName")) + .or_else(|| node.props.get("topic")) + .and_then(|v| v.as_str()) + .unwrap_or(&node_id) + .to_string(); + + let mut bus_rx = stream_bus.subscribe_with_capacity(&topic, buffer_len); + let node_id_clone = node_id.clone(); + let rule_id_clone = rule_id.clone(); + let comms_hub = comms_hub.clone(); + let senders = senders.clone(); + let edges = edges.clone(); + let counters = counters.clone(); + let running = running.clone(); + + child_tasks.push(tokio::spawn(async move { + while let Some(record) = bus_rx.recv().await { + if !is_rule_running(&running) { + break; + } + counters.inc_source(1); + // Wire-tap probe at source output boundary + comms_hub.maybe_sample( + &rule_id_clone, + &node_id_clone, + &record.data, + record.timestamp, + ); + send_to_port_targets(&senders, &edges, &node_id_clone, 0, record); + } + })); + } + + let _guard = TaskAbortGuard(child_tasks); + while is_rule_running(&running) { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } +} + +pub(crate) fn spawn_graph_rule_task( + rule_manager: &RuleManager, + stream_bus: &StreamBus, + rule_id: String, + graph: GraphDefinition, + comms_hub: CommsHub, + buffer_len: usize, +) { + let loop_counters = rule_manager + .rule_counters(&rule_id) + .unwrap_or_else(|| Arc::new(RuleCounters::default())); + let loop_running = rule_manager + .rule_status_handle(&rule_id) + .unwrap_or_else(|| Arc::new(RwLock::new(RuleStatus::default()))); + + let handle = tokio::spawn(run_graph_rule( + rule_id.clone(), + graph, + stream_bus.clone(), + rule_manager.clone(), + loop_counters, + loop_running, + comms_hub, + buffer_len, + )); + + rule_manager.set_rule_handle(&rule_id, handle); +} + +#[cfg(test)] +mod tests { + use super::*; + use rekuiper_core::model::{EdgeTargets, GraphDefinition, GraphNode, GraphTopo}; + use serde_json::json; + + #[tokio::test] + async fn test_dag_multi_port_switch_and_wiretap() { + let stream_bus = StreamBus::new(); + let rule_mgr = RuleManager::new(stream_bus.clone()); + let comms_hub = CommsHub::new(64); + + // Build DAG: + // src -> switch1: + // port 0 (temp > 50) -> sink_high + // port 1 (temp <= 50) -> sink_low + let mut nodes = HashMap::new(); + nodes.insert( + "src".to_string(), + GraphNode { + node_category: "source".to_string(), + node_type: "mqtt".to_string(), + props: [("datasource".to_string(), json!("demo_topic"))] + .into_iter() + .collect(), + }, + ); + nodes.insert( + "switch1".to_string(), + GraphNode { + node_category: "operator".to_string(), + node_type: "switch".to_string(), + props: [("cases".to_string(), json!(["temp > 50", "temp <= 50"]))] + .into_iter() + .collect(), + }, + ); + nodes.insert( + "sink_high".to_string(), + GraphNode { + node_category: "sink".to_string(), + node_type: "log".to_string(), + props: HashMap::new(), + }, + ); + nodes.insert( + "sink_low".to_string(), + GraphNode { + node_category: "sink".to_string(), + node_type: "log".to_string(), + props: HashMap::new(), + }, + ); + + let mut edges = HashMap::new(); + edges.insert( + "src".to_string(), + EdgeTargets::Single(vec!["switch1".to_string()]), + ); + // Multi-port output: port 0 -> sink_high, port 1 -> sink_low + edges.insert( + "switch1".to_string(), + EdgeTargets::Multi(vec![ + vec!["sink_high".to_string()], + vec!["sink_low".to_string()], + ]), + ); + + let graph = GraphDefinition { + nodes, + topo: GraphTopo { + sources: vec!["src".to_string()], + edges, + }, + }; + + let counters = Arc::new(RuleCounters::default()); + let running = Arc::new(RwLock::new(RuleStatus { + status: "running".to_string(), + ..RuleStatus::default() + })); + + // Enable wire-tap on switch1 and sink_high + comms_hub.enable_tap("rule_test", "switch1"); + comms_hub.enable_tap("rule_test", "sink_high"); + + let mut comms_rx = comms_hub.subscribe(); + + // Spawn DAG + tokio::spawn(run_graph_rule( + "rule_test".to_string(), + graph, + stream_bus.clone(), + rule_mgr, + counters.clone(), + running.clone(), + comms_hub.clone(), + 64, + )); + + // Allow actors to spin up + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + + // Publish high temp record (temp = 75) + let rec_high = + StreamRecord::from_hashmap([("temp".to_string(), json!(75))].into_iter().collect()); + let _ = stream_bus.publish("demo_topic", rec_high); + + // Verify wire-tap events + let mut tap_events = Vec::new(); + for _ in 0..2 { + if let Ok(Ok(ev)) = + tokio::time::timeout(tokio::time::Duration::from_millis(500), comms_rx.recv()).await + { + tap_events.push(ev); + } + } + + assert_eq!( + tap_events.len(), + 2, + "Expected switch1 and sink_high wire-tap samples" + ); + match &tap_events[0] { + crate::comms::CommsEvent::TupleSample { node_id, data, .. } => { + assert_eq!(node_id, "switch1"); + assert_eq!(data["temp"], 75); + } + _ => panic!("Expected TupleSample"), + } + match &tap_events[1] { + crate::comms::CommsEvent::TupleSample { node_id, data, .. } => { + assert_eq!(node_id, "sink_high"); + assert_eq!(data["temp"], 75); + } + _ => panic!("Expected TupleSample"), + } + } +} diff --git a/crates/rekuiper-server/src/engine/mod.rs b/crates/rekuiper-server/src/engine/mod.rs index 68e80c6b..ff7d2066 100644 --- a/crates/rekuiper-server/src/engine/mod.rs +++ b/crates/rekuiper-server/src/engine/mod.rs @@ -1,11 +1,15 @@ +pub mod dag; pub mod dlq; pub mod joins; +pub mod operators; pub mod runtime; pub mod sinks; pub mod windows; +pub use dag::*; pub(crate) use dlq::*; pub(crate) use joins::*; +pub use operators::*; pub(crate) use runtime::*; pub(crate) use sinks::*; pub(crate) use windows::*; @@ -735,7 +739,7 @@ pub(crate) fn register_rule_stream_source( .write() .entry(stream_name.to_string()) .or_default() - .push(cancel_tx); + .push(cancel_tx.clone()); state .stream_active_rules .write() @@ -748,13 +752,12 @@ pub(crate) fn register_rule_stream_source( .entry(rule_id.to_string()) .or_default() .insert(stream_name.to_string()); - let (rule_cancel_tx, _rule_cancel_rx) = tokio::sync::watch::channel(false); state .source_cancels .write() .entry(rule_id.to_string()) .or_default() - .push(rule_cancel_tx); + .push(cancel_tx); } /// Start source producers for one stream (MQTT/file/HTTP-pull/WebSocket/ @@ -814,6 +817,11 @@ pub(crate) fn bootstrap_stream_sources( } let mask_flag = update_stream_field_mask(state, stream_name, rule_id, field_mask); + let comms = state.comms_hub.clone(); + let status_cb: rekuiper_connectors::ConnectorStatusCallback = + Arc::new(move |id, kind, status, err| { + comms.publish_connector_status(id, kind, status, err.map(|s| s.to_string())); + }); // HTTP Push source streams listen on the configured HTTP data server endpoint. if let Some(endpoint) = @@ -865,6 +873,7 @@ pub(crate) fn bootstrap_stream_sources( .with_rule_counters(state.rule_manager.rule_counters(rule_id)) .with_meta_flag(Some(meta_flag)) .with_field_mask_flag(Some(mask_flag)) + .with_status_callback(Some(status_cb.clone())) .spawn(cancel_rx); return; } @@ -921,6 +930,7 @@ pub(crate) fn bootstrap_stream_sources( HttpPullSource { config: conf, tx: stream_tx, + status_callback: Some(status_cb.clone()), } .spawn(cancel_rx); return; @@ -972,6 +982,7 @@ pub(crate) fn bootstrap_stream_sources( KafkaSource { config, tx: stream_tx, + status_callback: Some(status_cb.clone()), } .spawn(cancel_rx); return; @@ -1046,7 +1057,9 @@ pub(crate) fn bootstrap_stream_sources( let stream_tx = state.stream_bus.get_or_create(stream_name); let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); register_rule_stream_source(state, stream_name, rule_id, cancel_tx); - ModbusSource::new(config, stream_tx).spawn(cancel_rx); + ModbusSource::new(config, stream_tx) + .with_status_callback(Some(status_cb.clone())) + .spawn(cancel_rx); return; } @@ -1057,7 +1070,9 @@ pub(crate) fn bootstrap_stream_sources( let stream_tx = state.stream_bus.get_or_create(stream_name); let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); register_rule_stream_source(state, stream_name, rule_id, cancel_tx); - OpcUaSource::new(config, stream_tx).spawn(cancel_rx); + OpcUaSource::new(config, stream_tx) + .with_status_callback(Some(status_cb)) + .spawn(cancel_rx); return; } @@ -1154,6 +1169,97 @@ pub(crate) fn bootstrap_rule_sources(state: &AppState, rule_id: &str, select_stm } } +pub(crate) fn bootstrap_graph_sources( + state: &AppState, + rule_id: &str, + graph: &rekuiper_core::model::GraphDefinition, +) { + for node in graph.nodes.values() { + if node.node_category == "source" { + let stream_name = node + .props + .get("datasource") + .or_else(|| node.props.get("sourceName")) + .or_else(|| node.props.get("topic")) + .and_then(|v| v.as_str()); + let src = match stream_name { + Some(s) if !s.trim().is_empty() => s.trim(), + _ => continue, + }; + + if state.stream_manager.get_stream(src).is_some() { + bootstrap_stream_sources(state, rule_id, src, true, None); + } else { + let n_type = node.node_type.to_lowercase(); + let comms = state.comms_hub.clone(); + let status_cb: rekuiper_connectors::ConnectorStatusCallback = + Arc::new(move |id, kind, status, err| { + comms.publish_connector_status( + id, + kind, + status, + err.map(|s| s.to_string()), + ); + }); + let stream_tx = state.stream_bus.get_or_create(src); + let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); + register_rule_stream_source(state, src, rule_id, cancel_tx); + + if n_type == "mqtt" || n_type == "source" { + let mut config = MqttConfig::default(); + if let Some(srv) = node.props.get("server").and_then(|v| v.as_str()) { + config.server = srv.to_string(); + } + if let Some(topic) = node + .props + .get("topic") + .or_else(|| node.props.get("datasource")) + .and_then(|v| v.as_str()) + { + config.topic = topic.to_string(); + } + if let Some(u) = node.props.get("username").and_then(|v| v.as_str()) { + config.username = Some(u.to_string()); + } + if let Some(p) = node.props.get("password").and_then(|v| v.as_str()) { + config.password = Some(p.to_string()); + } + if let Some(qos) = node.props.get("qos").and_then(|v| v.as_u64()) { + config.qos = qos as u8; + } + config.attach_meta = true; + MqttSource::new(config, stream_tx) + .with_rule_counters(state.rule_manager.rule_counters(rule_id)) + .with_status_callback(Some(status_cb)) + .spawn(cancel_rx); + } else if n_type == "modbus" { + let conf_val = + serde_json::Value::Object(node.props.clone().into_iter().collect()); + if let Ok(config) = + serde_json::from_value::(conf_val) + { + ModbusSource::new(config, stream_tx) + .with_status_callback(Some(status_cb)) + .spawn(cancel_rx); + } + } else if n_type == "opcua" { + let conf_val = + serde_json::Value::Object(node.props.clone().into_iter().collect()); + if let Ok(config) = + serde_json::from_value::(conf_val) + { + OpcUaSource::new(config, stream_tx) + .with_status_callback(Some(status_cb)) + .spawn(cancel_rx); + } + } else { + bootstrap_stream_sources(state, rule_id, src, true, None); + } + } + } + } +} + /// Respawns execution tasks (plus source producers) for every rule whose /// persisted status is `running`, so a restarted daemon resumes processing /// without manual intervention. @@ -1169,6 +1275,38 @@ pub async fn restore_running_rules(state: &AppState) { if !running { continue; } + if rule.sql.trim().is_empty() { + if let Some(ref graph) = rule.graph { + let is_native_dag = + graph.nodes.values().any(|n| { + matches!(n.node_type.as_str(), "switch" | "rate_limit" | "watchdog") + }) || graph + .topo + .edges + .values() + .any(|e| matches!(e, rekuiper_core::model::EdgeTargets::Multi(_))); + if is_native_dag { + let buffer_len = rule + .options + .as_ref() + .and_then(|o| o.get("bufferLength")) + .and_then(|v| v.as_u64()) + .map(|n| n as usize) + .unwrap_or(1024); + spawn_graph_rule_task( + &state.rule_manager, + &state.stream_bus, + rule.id.clone(), + graph.clone(), + state.comms_hub.clone(), + buffer_len, + ); + bootstrap_graph_sources(state, &rule.id, graph); + tracing::info!("Restored running graph rule {}", rule.id); + continue; + } + } + } let mut parser = Parser::new(&rule.sql); let select_stmt = match parser.parse_select() { Ok(stmt) => stmt, diff --git a/crates/rekuiper-server/src/engine/operators.rs b/crates/rekuiper-server/src/engine/operators.rs new file mode 100644 index 00000000..13e4bf9e --- /dev/null +++ b/crates/rekuiper-server/src/engine/operators.rs @@ -0,0 +1,265 @@ +use anyhow::Result; +use rekuiper_core::model::StreamRecord; +use rekuiper_sql::{eval::Evaluator, parse_expression, Expr}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +/// Native Stream Control: Switch (Multi-port ordered predicate routing) +#[derive(Debug, Clone)] +pub struct SwitchNode { + pub cases: Vec<(Expr, usize)>, + pub default_port: Option, +} + +impl SwitchNode { + pub fn from_props(props: &HashMap) -> Result { + let mut cases = Vec::new(); + if let Some(cases_val) = props.get("cases").or_else(|| props.get("rules")) { + if let Some(arr) = cases_val.as_array() { + for (port_idx, item) in arr.iter().enumerate() { + let expr_str = if let Some(s) = item.as_str() { + s.trim() + } else if let Some(obj) = item.as_object() { + obj.get("expr") + .or_else(|| obj.get("condition")) + .and_then(|v| v.as_str()) + .map(|s| s.trim()) + .unwrap_or("") + } else { + "" + }; + if !expr_str.is_empty() { + let parsed = parse_expression(expr_str).map_err(|e| { + anyhow::anyhow!("Invalid switch case expr '{}': {}", expr_str, e) + })?; + cases.push((parsed, port_idx)); + } + } + } + } + let default_port = props + .get("defaultPort") + .or_else(|| props.get("default_port")) + .and_then(|v| v.as_u64()) + .map(|n| n as usize); + + Ok(Self { + cases, + default_port, + }) + } + + /// Evaluates input record against ordered predicates. + /// Returns matching output port index (0..N). + /// If no predicate matches and no default port is configured, returns None (dropped). + pub fn route(&self, record: &StreamRecord) -> Option { + for (expr, port) in &self.cases { + if Evaluator::eval_bool(expr, record) { + return Some(*port); + } + } + self.default_port + } +} + +/// Strategy for RateLimitNode when limit is exceeded. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RateLimitStrategy { + Drop, + Buffer, +} + +/// Native Stream Control: RateLimit (Token-bucket drop or buffer policy) +pub struct RateLimitNode { + rate: f64, + burst: f64, + strategy: RateLimitStrategy, + _max_buffer: usize, + tokens: parking_lot::Mutex, + pub dropped: Arc, +} + +struct RateBucketState { + tokens: f64, + last_check: Instant, +} + +impl RateLimitNode { + pub fn new(rate: f64, burst: f64, strategy: RateLimitStrategy, max_buffer: usize) -> Self { + Self { + rate: rate.max(0.001), + burst: burst.max(1.0), + strategy, + _max_buffer: max_buffer.max(1), + tokens: parking_lot::Mutex::new(RateBucketState { + tokens: burst.max(1.0), + last_check: Instant::now(), + }), + dropped: Arc::new(AtomicU64::new(0)), + } + } + + pub fn from_props(props: &HashMap) -> Self { + let rate = props + .get("rate") + .or_else(|| props.get("limit")) + .and_then(|v| v.as_f64().or_else(|| v.as_u64().map(|n| n as f64))) + .unwrap_or(100.0); + let burst = props + .get("burst") + .and_then(|v| v.as_f64().or_else(|| v.as_u64().map(|n| n as f64))) + .unwrap_or(rate); + let strat_str = props + .get("strategy") + .and_then(|v| v.as_str()) + .unwrap_or("drop"); + let strategy = if strat_str.eq_ignore_ascii_case("buffer") { + RateLimitStrategy::Buffer + } else { + RateLimitStrategy::Drop + }; + let max_buffer = props + .get("max_buffer") + .or_else(|| props.get("maxBuffer")) + .and_then(|v| v.as_u64()) + .map(|n| n as usize) + .unwrap_or(1024); + + Self::new(rate, burst, strategy, max_buffer) + } + + /// Try to acquire token. If permitted, returns true. + /// If not permitted: + /// - If Drop: increments dropped counter and returns false. + /// - If Buffer: sleeps until token is available. + pub async fn acquire(&self) -> bool { + loop { + let wait_duration = { + let mut state = self.tokens.lock(); + let now = Instant::now(); + let elapsed = now.duration_since(state.last_check).as_secs_f64(); + state.last_check = now; + state.tokens = (state.tokens + elapsed * self.rate).min(self.burst); + + if state.tokens >= 1.0 { + state.tokens -= 1.0; + return true; + } + + if self.strategy == RateLimitStrategy::Drop { + self.dropped.fetch_add(1, Ordering::Relaxed); + return false; + } + + let needed = 1.0 - state.tokens; + Duration::from_secs_f64(needed / self.rate) + }; + + tokio::time::sleep(wait_duration).await; + } + } +} + +/// Native Stream Control: Watchdog (Inactivity timeout dead-man alert) +#[derive(Debug, Clone)] +pub struct WatchdogNode { + pub timeout: Duration, + pub alert_payload: Value, + pub repeat: bool, +} + +impl WatchdogNode { + pub fn new(timeout: Duration, alert_payload: Value, repeat: bool) -> Self { + Self { + timeout, + alert_payload, + repeat, + } + } + + pub fn from_props(node_id: &str, props: &HashMap) -> Self { + let timeout_ms = props + .get("timeout") + .or_else(|| props.get("timeout_ms")) + .or_else(|| props.get("interval")) + .and_then(|v| { + v.as_u64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + }) + .unwrap_or(5000); + + let alert_payload = props + .get("alert_payload") + .or_else(|| props.get("alertPayload")) + .cloned() + .unwrap_or_else(|| { + serde_json::json!({ + "event": "inactivity_timeout", + "node_id": node_id, + "timeout_ms": timeout_ms + }) + }); + + let repeat = props + .get("repeat") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + Self::new(Duration::from_millis(timeout_ms), alert_payload, repeat) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_switch_routing_ordered_predicates() { + let mut props = HashMap::new(); + props.insert( + "cases".to_string(), + serde_json::json!(["temp > 50", "temp > 20"]), + ); + props.insert("default_port".to_string(), serde_json::json!(2)); + + let switch = SwitchNode::from_props(&props).expect("parse switch"); + + // Record with temp = 60 matches first case (port 0) + let rec1 = StreamRecord::from_hashmap( + [("temp".to_string(), serde_json::json!(60))] + .into_iter() + .collect(), + ); + assert_eq!(switch.route(&rec1), Some(0)); + + // Record with temp = 30 matches second case (port 1) + let rec2 = StreamRecord::from_hashmap( + [("temp".to_string(), serde_json::json!(30))] + .into_iter() + .collect(), + ); + assert_eq!(switch.route(&rec2), Some(1)); + + // Record with temp = 10 falls back to default port 2 + let rec3 = StreamRecord::from_hashmap( + [("temp".to_string(), serde_json::json!(10))] + .into_iter() + .collect(), + ); + assert_eq!(switch.route(&rec3), Some(2)); + } + + #[tokio::test] + async fn test_rate_limit_token_bucket_drop() { + // 2 tokens burst, 1 token/sec refill + let rl = RateLimitNode::new(1.0, 2.0, RateLimitStrategy::Drop, 10); + assert!(rl.acquire().await); + assert!(rl.acquire().await); + // Third token immediately depleted -> drops + assert!(!rl.acquire().await); + assert_eq!(rl.dropped.load(Ordering::Relaxed), 1); + } +} diff --git a/crates/rekuiper-server/src/engine/runtime.rs b/crates/rekuiper-server/src/engine/runtime.rs index cbb36981..d97f6100 100644 --- a/crates/rekuiper-server/src/engine/runtime.rs +++ b/crates/rekuiper-server/src/engine/runtime.rs @@ -352,7 +352,12 @@ pub(crate) async fn run_stateless_rule( } match eval_res { Ok(Some(output)) => { - if !enqueue_sink_record(&counters, &sink, StreamRecord::new(output)).await { + let out_rec = StreamRecord::with_timestamp_and_meta( + output, + record.timestamp, + record.meta.clone(), + ); + if !enqueue_sink_record(&counters, &sink, out_rec).await { return; } } @@ -482,7 +487,11 @@ pub(crate) async fn run_stateless_rule( } match eval_res { Ok(Some(output)) => { - let out_rec = StreamRecord::with_timestamp(output, record.timestamp); + let out_rec = StreamRecord::with_timestamp_and_meta( + output, + record.timestamp, + record.meta.clone(), + ); match sink.try_send(out_rec) { Ok(()) => { batch_enqueued += 1; diff --git a/crates/rekuiper-server/src/engine/sinks.rs b/crates/rekuiper-server/src/engine/sinks.rs index 9bec4122..e22cd87d 100644 --- a/crates/rekuiper-server/src/engine/sinks.rs +++ b/crates/rekuiper-server/src/engine/sinks.rs @@ -958,10 +958,8 @@ impl FileBatchWriter { } pub(crate) fn push_parquet_record(&mut self, data: HashMap, ts: i64) { - self.parquet_buf.push(StreamRecord { - timestamp: ts, - data: data.into(), - }); + self.parquet_buf + .push(StreamRecord::with_timestamp(data, ts)); self.pending += 1; } diff --git a/crates/rekuiper-server/src/fleet.rs b/crates/rekuiper-server/src/fleet.rs new file mode 100644 index 00000000..696f732b --- /dev/null +++ b/crates/rekuiper-server/src/fleet.rs @@ -0,0 +1,1552 @@ +use anyhow::{Context, Result}; +use axum::{ + body::Body, + extract::{Path, Query, State, WebSocketUpgrade}, + http::{Request, StatusCode}, + response::{IntoResponse, Response}, + routing::{any, get, post}, + Json, Router, +}; +use futures::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use futures::{Sink, Stream}; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use crate::agent::{read_frame, send_frame, AgentControlCommand, AgentHeartbeat}; +use crate::state::AppState; +use rekuiper_core::kv::KvStore; + +// ============================================================================ +// Data Models for Fleet Management +// ============================================================================ + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FleetDevice { + pub device_id: String, + pub name: String, + pub plant: String, + pub region: String, + pub device_type: String, + pub status: String, // "online", "offline", "degraded" + pub version: String, + pub cpu_usage: f32, + pub memory_bytes: u64, + pub active_snapshot_id: Option, + pub active_snapshot_hash: u64, + pub rules_count: usize, + pub running_rules: usize, + pub dlq_count: u64, + pub last_heartbeat: i64, + pub registered_at: i64, + #[serde(default)] + pub last_rollback_reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FleetGroupSummary { + pub name: String, + pub category: String, // "plant", "region", "device_type" + pub total_devices: usize, + pub online_devices: usize, + pub offline_devices: usize, + pub degraded_devices: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FleetSnapshot { + pub snapshot_id: String, + pub name: String, + pub description: String, + pub declarative: Value, + pub sha256: String, + pub created_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum RolloutStage { + Dev, + Staging, + Canary, + Production, + Completed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum RolloutStatus { + Pending, + InProgress, + Promoted, + RolledBack, + Halted, + Completed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FleetRollout { + pub rollout_id: String, + pub snapshot_id: String, + pub target_group: Option, + pub current_stage: RolloutStage, + pub status: RolloutStatus, + pub canary_devices: Vec, + pub production_devices: Vec, + pub created_at: i64, + pub updated_at: i64, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FleetAuditEntry { + pub id: String, + pub timestamp: i64, + pub action: String, + pub actor: String, + pub target_id: String, + pub details: Value, +} + +pub enum FleetServerCmd { + OpenOutbound(tokio::sync::oneshot::Sender>), + SendControl( + AgentControlCommand, + tokio::sync::oneshot::Sender>, + ), +} + +#[derive(Clone)] +pub struct ConnectedDeviceHandle { + pub cmd_tx: tokio::sync::mpsc::Sender, + pub connected_at: i64, +} + +// ============================================================================ +// Fleet Manager Engine +// ============================================================================ + +#[derive(Clone)] +pub struct FleetManager { + pub devices: Arc>>, + pub snapshots: Arc>>, + pub rollouts: Arc>>, + pub audit_log: Arc>>, + pub active_connections: Arc>>, + pub kv: Option>, +} + +impl Default for FleetManager { + fn default() -> Self { + Self::new() + } +} + +impl FleetManager { + pub fn new() -> Self { + Self { + devices: Arc::new(RwLock::new(HashMap::new())), + snapshots: Arc::new(RwLock::new(HashMap::new())), + rollouts: Arc::new(RwLock::new(HashMap::new())), + audit_log: Arc::new(RwLock::new(Vec::new())), + active_connections: Arc::new(RwLock::new(HashMap::new())), + kv: None, + } + } + + pub fn new_with_kv(kv: Arc) -> Self { + let mut mgr = Self::new(); + mgr.kv = Some(kv); + mgr + } + + pub fn record_audit(&self, action: &str, actor: &str, target_id: &str, details: Value) { + let entry = FleetAuditEntry { + id: format!("aud_{}", uuid::Uuid::new_v4().simple()), + timestamp: chrono::Utc::now().timestamp(), + action: action.to_string(), + actor: actor.to_string(), + target_id: target_id.to_string(), + details, + }; + + { + let mut log = self.audit_log.write(); + log.push(entry.clone()); + if log.len() > 1000 { + log.remove(0); + } + } + + if let Some(kv) = &self.kv { + let kv_clone = kv.clone(); + let key = entry.id.clone(); + let json_str = serde_json::to_string(&entry).unwrap_or_default(); + tokio::spawn(async move { + let _ = kv_clone.set("fleet_audit", &key, &json_str).await; + }); + } + } + + pub fn register_or_update_heartbeat(&self, hb: AgentHeartbeat) { + let now = chrono::Utc::now().timestamp(); + let device_id = hb.agent_id.clone(); + + let mut devices = self.devices.write(); + let status = + if hb.status.as_deref() == Some("degraded") || hb.last_rollback_reason.is_some() { + "degraded".to_string() + } else { + "online".to_string() + }; + + if let Some(existing) = devices.get_mut(&device_id) { + existing.status = status; + existing.cpu_usage = hb.cpu_usage; + existing.memory_bytes = hb.memory_bytes; + existing.rules_count = hb.rules_count; + existing.running_rules = hb.running_rules; + existing.dlq_count = hb.dlq_count; + existing.active_snapshot_hash = hb.snapshot_hash; + if hb.snapshot_id.is_some() { + existing.active_snapshot_id = hb.snapshot_id; + } + if hb.last_rollback_reason.is_some() { + existing.last_rollback_reason = hb.last_rollback_reason; + } + existing.last_heartbeat = now; + } else { + let dev = FleetDevice { + device_id: device_id.clone(), + name: hb.name.unwrap_or_else(|| device_id.clone()), + plant: hb.plant.unwrap_or_else(|| "Plant-Austin".to_string()), + region: hb.region.unwrap_or_else(|| "US-South".to_string()), + device_type: hb.device_type.unwrap_or_else(|| "IPC-Edge".to_string()), + status, + version: hb.version.unwrap_or_else(|| "2.0.0".to_string()), + cpu_usage: hb.cpu_usage, + memory_bytes: hb.memory_bytes, + active_snapshot_id: hb.snapshot_id, + active_snapshot_hash: hb.snapshot_hash, + rules_count: hb.rules_count, + running_rules: hb.running_rules, + dlq_count: hb.dlq_count, + last_heartbeat: now, + registered_at: now, + last_rollback_reason: hb.last_rollback_reason, + }; + devices.insert(device_id.clone(), dev.clone()); + } + + if let Some(kv) = &self.kv { + if let Some(dev) = devices.get(&device_id) { + let kv_clone = kv.clone(); + let key = device_id.clone(); + let json_str = serde_json::to_string(dev).unwrap_or_default(); + tokio::spawn(async move { + let _ = kv_clone.set("fleet_devices", &key, &json_str).await; + }); + } + } + } + + pub fn set_device_status(&self, device_id: &str, status: &str) { + let mut devices = self.devices.write(); + if let Some(dev) = devices.get_mut(device_id) { + dev.status = status.to_string(); + if let Some(kv) = &self.kv { + let kv_clone = kv.clone(); + let key = device_id.to_string(); + let json_str = serde_json::to_string(dev).unwrap_or_default(); + tokio::spawn(async move { + let _ = kv_clone.set("fleet_devices", &key, &json_str).await; + }); + } + } + } + + pub fn create_snapshot( + &self, + name: String, + description: String, + declarative: Value, + ) -> Result { + let canon_bytes = + serde_json::to_vec(&declarative).context("Failed to serialize declarative snapshot")?; + let digest = ring::digest::digest(&ring::digest::SHA256, &canon_bytes); + let sha256 = digest + .as_ref() + .iter() + .map(|b| format!("{:02x}", b)) + .collect::(); + let snapshot_id = format!("snap_{}", uuid::Uuid::new_v4().simple()); + let now = chrono::Utc::now().timestamp(); + + let snap = FleetSnapshot { + snapshot_id: snapshot_id.clone(), + name, + description, + declarative, + sha256, + created_at: now, + }; + + self.snapshots + .write() + .insert(snapshot_id.clone(), snap.clone()); + self.record_audit( + "snapshot_created", + "admin", + &snapshot_id, + json!({ + "name": snap.name, + "sha256": snap.sha256, + }), + ); + + if let Some(kv) = &self.kv { + let kv_clone = kv.clone(); + let key = snapshot_id; + let json_str = serde_json::to_string(&snap).unwrap_or_default(); + tokio::spawn(async move { + let _ = kv_clone.set("fleet_snapshots", &key, &json_str).await; + }); + } + + Ok(snap) + } + + pub async fn start_staged_rollout( + &self, + snapshot_id: &str, + target_group: Option, + auto_promote: bool, + ) -> Result { + let snap = { + let guard = self.snapshots.read(); + guard + .get(snapshot_id) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Snapshot '{}' not found", snapshot_id))? + }; + + let all_devs: Vec = self.devices.read().values().cloned().collect(); + let target_devs: Vec = all_devs + .into_iter() + .filter(|d| { + if let Some(ref grp) = target_group { + d.plant == *grp || d.region == *grp || d.device_type == *grp || d.name == *grp + } else { + true + } + }) + .collect(); + + if target_devs.is_empty() { + return Err(anyhow::anyhow!("No target devices found matching criteria")); + } + + // Staged cohort calculation: Canary (5% of target devices, at least 1) -> Production (remaining 95%) + let canary_count = ((target_devs.len() as f32 * 0.05).ceil() as usize) + .max(1) + .min(target_devs.len()); + let canary_devs: Vec = target_devs[..canary_count] + .iter() + .map(|d| d.device_id.clone()) + .collect(); + let prod_devs: Vec = target_devs[canary_count..] + .iter() + .map(|d| d.device_id.clone()) + .collect(); + + let rollout_id = format!("ro_{}", uuid::Uuid::new_v4().simple()); + let now = chrono::Utc::now().timestamp(); + let rollout = FleetRollout { + rollout_id: rollout_id.clone(), + snapshot_id: snapshot_id.to_string(), + target_group: target_group.clone(), + current_stage: RolloutStage::Canary, + status: RolloutStatus::InProgress, + canary_devices: canary_devs.clone(), + production_devices: prod_devs.clone(), + created_at: now, + updated_at: now, + message: format!( + "Deploying to Canary cohort ({} device(s))", + canary_devs.len() + ), + }; + + self.rollouts + .write() + .insert(rollout_id.clone(), rollout.clone()); + self.record_audit( + "rollout_started", + "admin", + &rollout_id, + json!({ + "snapshot_id": snapshot_id, + "target_group": target_group, + "canary_devices": canary_devs, + "production_devices": prod_devs, + }), + ); + + // 1. Deploy to Canary cohort first + let mut canary_failed = false; + let mut failure_reason = String::new(); + + for dev_id in &canary_devs { + let cmd = AgentControlCommand::ApplySnapshot { + manifest: snap.declarative.clone(), + }; + let res = self.send_device_control(dev_id, cmd).await; + match res { + Ok(val) => { + if let Some(status_str) = val.get("status").and_then(|s| s.as_str()) { + if status_str == "error" { + canary_failed = true; + failure_reason = val + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("ApplySnapshot error") + .to_string(); + break; + } + } + } + Err(e) => { + canary_failed = true; + failure_reason = e.to_string(); + break; + } + } + } + + // 2. Health check Canary cohort (detect degraded status or watchdog auto-rollback) + if !canary_failed { + for dev_id in &canary_devs { + if let Some(dev) = self.devices.read().get(dev_id) { + if dev.status == "degraded" || dev.last_rollback_reason.is_some() { + canary_failed = true; + failure_reason = dev + .last_rollback_reason + .clone() + .unwrap_or_else(|| "Device entered degraded status".to_string()); + break; + } + } + } + } + + // 3. Health-gated branch: auto-halt on failure, otherwise promote to production + if canary_failed { + let halt_msg = format!( + "Canary failure: {}. Rollout automatically halted & marked rolled_back.", + failure_reason + ); + { + let mut r_guard = self.rollouts.write(); + if let Some(r) = r_guard.get_mut(&rollout_id) { + r.status = RolloutStatus::RolledBack; + r.message = halt_msg.clone(); + r.updated_at = chrono::Utc::now().timestamp(); + } + } + self.record_audit( + "rollout_halted", + "system", + &rollout_id, + json!({ + "reason": failure_reason, + "canary_devices": canary_devs, + }), + ); + // Trigger rollback on canary devices + for dev_id in &canary_devs { + let _ = self + .send_device_control(dev_id, AgentControlCommand::Rollback) + .await; + } + return Ok(self.rollouts.read().get(&rollout_id).cloned().unwrap()); + } + + // Canary healthy! If auto_promote, proceed to Production cohort + if auto_promote { + { + let mut r_guard = self.rollouts.write(); + if let Some(r) = r_guard.get_mut(&rollout_id) { + r.current_stage = RolloutStage::Production; + r.message = format!( + "Canary healthy. Promoting to 100% Production ({} devices)", + prod_devs.len() + ); + r.updated_at = chrono::Utc::now().timestamp(); + } + } + self.record_audit( + "rollout_promoted", + "system", + &rollout_id, + json!({ + "stage": "production", + "production_devices": prod_devs, + }), + ); + + // Deploy to Production cohort + for dev_id in &prod_devs { + let cmd = AgentControlCommand::ApplySnapshot { + manifest: snap.declarative.clone(), + }; + let _ = self.send_device_control(dev_id, cmd).await; + } + + // Mark completed + { + let mut r_guard = self.rollouts.write(); + if let Some(r) = r_guard.get_mut(&rollout_id) { + r.current_stage = RolloutStage::Completed; + r.status = RolloutStatus::Completed; + r.message = + "Staged rollout promoted to 100% production successfully.".to_string(); + r.updated_at = chrono::Utc::now().timestamp(); + } + } + self.record_audit( + "rollout_completed", + "system", + &rollout_id, + json!({ + "canary_count": canary_devs.len(), + "production_count": prod_devs.len(), + }), + ); + } + + Ok(self.rollouts.read().get(&rollout_id).cloned().unwrap()) + } + + pub async fn promote_rollout(&self, rollout_id: &str) -> Result { + let (snap_id, prod_devs) = { + let mut r_guard = self.rollouts.write(); + let r = r_guard + .get_mut(rollout_id) + .ok_or_else(|| anyhow::anyhow!("Rollout '{}' not found", rollout_id))?; + r.current_stage = RolloutStage::Production; + r.message = "Promoted manually to 100% Production".to_string(); + r.updated_at = chrono::Utc::now().timestamp(); + (r.snapshot_id.clone(), r.production_devices.clone()) + }; + + let snap = { + let guard = self.snapshots.read(); + guard + .get(&snap_id) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Snapshot not found"))? + }; + + for dev_id in &prod_devs { + let cmd = AgentControlCommand::ApplySnapshot { + manifest: snap.declarative.clone(), + }; + let _ = self.send_device_control(dev_id, cmd).await; + } + + { + let mut r_guard = self.rollouts.write(); + if let Some(r) = r_guard.get_mut(rollout_id) { + r.current_stage = RolloutStage::Completed; + r.status = RolloutStatus::Completed; + r.message = "Rollout completed successfully across entire fleet.".to_string(); + r.updated_at = chrono::Utc::now().timestamp(); + } + } + + self.record_audit( + "rollout_promoted", + "admin", + rollout_id, + json!({"stage": "production"}), + ); + Ok(self.rollouts.read().get(rollout_id).cloned().unwrap()) + } + + pub async fn rollback_rollout(&self, rollout_id: &str) -> Result { + let (canary_devs, prod_devs) = { + let mut r_guard = self.rollouts.write(); + let r = r_guard + .get_mut(rollout_id) + .ok_or_else(|| anyhow::anyhow!("Rollout not found"))?; + r.status = RolloutStatus::RolledBack; + r.message = "Rollout halted & rolled back manually by administrator.".to_string(); + r.updated_at = chrono::Utc::now().timestamp(); + (r.canary_devices.clone(), r.production_devices.clone()) + }; + + for dev_id in canary_devs.iter().chain(prod_devs.iter()) { + let _ = self + .send_device_control(dev_id, AgentControlCommand::Rollback) + .await; + } + + self.record_audit("rollout_rollback", "admin", rollout_id, json!({})); + Ok(self.rollouts.read().get(rollout_id).cloned().unwrap()) + } + + pub fn get_groups(&self) -> Vec { + let devices = self.devices.read(); + let mut map: HashMap<(String, String), (usize, usize, usize, usize)> = HashMap::new(); + + for dev in devices.values() { + let is_online = dev.status == "online"; + let is_degraded = dev.status == "degraded"; + let is_offline = !is_online && !is_degraded; + + // Plant category + let entry = map + .entry(("plant".to_string(), dev.plant.clone())) + .or_insert((0, 0, 0, 0)); + entry.0 += 1; + if is_online { + entry.1 += 1; + } + if is_offline { + entry.2 += 1; + } + if is_degraded { + entry.3 += 1; + } + + // Region category + let entry_reg = map + .entry(("region".to_string(), dev.region.clone())) + .or_insert((0, 0, 0, 0)); + entry_reg.0 += 1; + if is_online { + entry_reg.1 += 1; + } + if is_offline { + entry_reg.2 += 1; + } + if is_degraded { + entry_reg.3 += 1; + } + + // Device Type category + let entry_type = map + .entry(("device_type".to_string(), dev.device_type.clone())) + .or_insert((0, 0, 0, 0)); + entry_type.0 += 1; + if is_online { + entry_type.1 += 1; + } + if is_offline { + entry_type.2 += 1; + } + if is_degraded { + entry_type.3 += 1; + } + } + + map.into_iter() + .map( + |((category, name), (total, online, offline, degraded))| FleetGroupSummary { + name, + category, + total_devices: total, + online_devices: online, + offline_devices: offline, + degraded_devices: degraded, + }, + ) + .collect() + } + + pub fn get_fleet_metrics(&self) -> Value { + let devices = self.devices.read(); + let total_devices = devices.len(); + let online_devices = devices.values().filter(|d| d.status == "online").count(); + let degraded_devices = devices.values().filter(|d| d.status == "degraded").count(); + let offline_devices = total_devices.saturating_sub(online_devices + degraded_devices); + + let total_rules: usize = devices.values().map(|d| d.rules_count).sum(); + let total_running_rules: usize = devices.values().map(|d| d.running_rules).sum(); + let total_memory: u64 = devices.values().map(|d| d.memory_bytes).sum(); + let total_dlq: u64 = devices.values().map(|d| d.dlq_count).sum(); + let avg_cpu: f32 = if total_devices > 0 { + devices.values().map(|d| d.cpu_usage).sum::() / total_devices as f32 + } else { + 0.0 + }; + + let rollouts = self.rollouts.read(); + let active_rollouts = rollouts + .values() + .filter(|r| r.status == RolloutStatus::InProgress) + .count(); + + json!({ + "total_devices": total_devices, + "online_devices": online_devices, + "offline_devices": offline_devices, + "degraded_devices": degraded_devices, + "total_rules": total_rules, + "total_running_rules": total_running_rules, + "total_memory_bytes": total_memory, + "avg_cpu_percent": avg_cpu, + "total_dlq_errors": total_dlq, + "active_rollouts": active_rollouts, + "snapshots_count": self.snapshots.read().len() + }) + } + + pub fn get_fleet_dlq_alerts(&self) -> Vec { + let devices = self.devices.read(); + devices + .values() + .filter(|d| { + d.dlq_count > 0 || d.status == "degraded" || d.last_rollback_reason.is_some() + }) + .map(|d| { + json!({ + "device_id": d.device_id, + "name": d.name, + "plant": d.plant, + "region": d.region, + "status": d.status, + "dlq_count": d.dlq_count, + "last_rollback_reason": d.last_rollback_reason, + "last_heartbeat": d.last_heartbeat + }) + }) + .collect() + } + + pub async fn open_proxy_stream(&self, device_id: &str) -> Result { + let handle = { + let guard = self.active_connections.read(); + guard.get(device_id).cloned().ok_or_else(|| { + anyhow::anyhow!( + "Device '{}' is not currently connected to Control Plane", + device_id + ) + })? + }; + + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + handle + .cmd_tx + .send(FleetServerCmd::OpenOutbound(reply_tx)) + .await + .map_err(|_| { + anyhow::anyhow!("Connection actor to device '{}' has terminated", device_id) + })?; + + let res = reply_rx + .await + .map_err(|_| anyhow::anyhow!("Proxy stream open request cancelled"))??; + Ok(res) + } + + pub async fn send_device_control( + &self, + device_id: &str, + cmd: AgentControlCommand, + ) -> Result { + let handle = { + let guard = self.active_connections.read(); + guard + .get(device_id) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Device '{}' is not connected", device_id))? + }; + + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + handle + .cmd_tx + .send(FleetServerCmd::SendControl(cmd, reply_tx)) + .await + .map_err(|_| anyhow::anyhow!("Device command channel dropped"))?; + + let res = reply_rx + .await + .map_err(|_| anyhow::anyhow!("Device command timed out or cancelled"))? + .map_err(|e| anyhow::anyhow!("{}", e))?; + Ok(res) + } +} + +// ============================================================================ +// Axum WebSocket Adapter for Yamux Server Mode +// ============================================================================ + +use axum::extract::ws::{Message as AxumWsMessage, WebSocket as AxumWebSocket}; + +pub struct AxumWsStreamAdapter { + ws: AxumWebSocket, + read_buf: Vec, + read_pos: usize, +} + +impl AxumWsStreamAdapter { + pub fn new(ws: AxumWebSocket) -> Self { + Self { + ws, + read_buf: Vec::new(), + read_pos: 0, + } + } +} + +impl AsyncRead for AxumWsStreamAdapter { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut [u8], + ) -> std::task::Poll> { + let this = &mut *self; + if this.read_pos < this.read_buf.len() { + let available = this.read_buf.len() - this.read_pos; + let to_copy = available.min(buf.len()); + buf[..to_copy].copy_from_slice(&this.read_buf[this.read_pos..this.read_pos + to_copy]); + this.read_pos += to_copy; + if this.read_pos >= this.read_buf.len() { + this.read_buf.clear(); + this.read_pos = 0; + } + return std::task::Poll::Ready(Ok(to_copy)); + } + + loop { + match std::pin::Pin::new(&mut this.ws).poll_next(cx) { + std::task::Poll::Ready(Some(Ok(AxumWsMessage::Binary(bin)))) => { + if bin.is_empty() { + continue; + } + let to_copy = bin.len().min(buf.len()); + buf[..to_copy].copy_from_slice(&bin[..to_copy]); + if to_copy < bin.len() { + this.read_buf = bin[to_copy..].to_vec(); + this.read_pos = 0; + } + return std::task::Poll::Ready(Ok(to_copy)); + } + std::task::Poll::Ready(Some(Ok(AxumWsMessage::Close(_)))) + | std::task::Poll::Ready(None) => { + return std::task::Poll::Ready(Ok(0)); + } + std::task::Poll::Ready(Some(Ok(_))) => { + continue; + } + std::task::Poll::Ready(Some(Err(e))) => { + return std::task::Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::ConnectionReset, + e, + ))); + } + std::task::Poll::Pending => return std::task::Poll::Pending, + } + } + } +} + +impl AsyncWrite for AxumWsStreamAdapter { + fn poll_write( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + let this = &mut *self; + match std::pin::Pin::new(&mut this.ws).poll_ready(cx) { + std::task::Poll::Ready(Ok(())) => { + let msg = AxumWsMessage::Binary(buf.to_vec()); + match std::pin::Pin::new(&mut this.ws).start_send(msg) { + Ok(()) => std::task::Poll::Ready(Ok(buf.len())), + Err(e) => std::task::Poll::Ready(Err(std::io::Error::other(e))), + } + } + std::task::Poll::Ready(Err(e)) => std::task::Poll::Ready(Err(std::io::Error::other(e))), + std::task::Poll::Pending => std::task::Poll::Pending, + } + } + + fn poll_flush( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(&mut self.ws) + .poll_flush(cx) + .map_err(std::io::Error::other) + } + + fn poll_close( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(&mut self.ws) + .poll_close(cx) + .map_err(std::io::Error::other) + } +} + +// ============================================================================ +// Server-Side WebSocket Acceptor (/fleet/ws) +// ============================================================================ + +pub async fn fleet_ws_acceptor(ws: WebSocketUpgrade, State(state): State) -> Response { + ws.on_upgrade(move |socket| handle_fleet_ws_client(socket, state)) +} + +pub async fn handle_fleet_ws_client(socket: AxumWebSocket, state: AppState) { + let adapter = AxumWsStreamAdapter::new(socket); + let mut cfg = yamux::Config::default(); + cfg.set_read_after_close(true); + let mut server_conn = yamux::Connection::new(adapter, cfg, yamux::Mode::Server); + + let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::channel::(32); + let (inbound_tx, mut inbound_rx) = tokio::sync::mpsc::channel::(32); + let (ctrl_cmd_tx, mut ctrl_cmd_rx) = tokio::sync::mpsc::channel::<( + AgentControlCommand, + tokio::sync::oneshot::Sender>, + )>(16); + + // Driver task: continuously polls inbound streams & processes outbound requests + let ctrl_cmd_tx_clone = ctrl_cmd_tx.clone(); + let driver_handle = tokio::spawn(async move { + loop { + tokio::select! { + inbound_res = futures::future::poll_fn(|cx| server_conn.poll_next_inbound(cx)) => { + match inbound_res { + Some(Ok(stream)) => { + let _ = inbound_tx.send(stream).await; + } + Some(Err(_)) | None => break, + } + } + cmd = cmd_rx.recv() => { + match cmd { + Some(FleetServerCmd::OpenOutbound(reply)) => { + let res = futures::future::poll_fn(|cx| server_conn.poll_new_outbound(cx)).await; + let _ = reply.send(res); + } + Some(FleetServerCmd::SendControl(cmd, reply)) => { + let _ = ctrl_cmd_tx_clone.send((cmd, reply)).await; + } + None => break, + } + } + } + } + }); + + // Await Stream 0 (Client initiates Stream 0 immediately upon connection) + let ctrl_stream = match inbound_rx.recv().await { + Some(s) => s, + None => return, + }; + + let (mut read_half, mut write_half) = futures::AsyncReadExt::split(ctrl_stream); + + // Read initial registration frame + let first_frame = match read_frame(&mut read_half).await { + Ok(f) => f, + Err(_) => return, + }; + + let initial_hb: AgentHeartbeat = match serde_json::from_slice(&first_frame) { + Ok(hb) => hb, + Err(e) => { + tracing::warn!("Failed to parse initial fleet registration frame: {}", e); + return; + } + }; + + let device_id = initial_hb.agent_id.clone(); + state.fleet.register_or_update_heartbeat(initial_hb); + + // Register active connection + state.fleet.active_connections.write().insert( + device_id.clone(), + ConnectedDeviceHandle { + cmd_tx: cmd_tx.clone(), + connected_at: chrono::Utc::now().timestamp(), + }, + ); + state + .fleet + .record_audit("device_connected", "system", &device_id, json!({})); + + // Stream 0 session loop: manages incoming telemetry frames and pending command dispatches + let fleet_clone_session = state.fleet.clone(); + let write_task = tokio::spawn(async move { + let mut pending_reply: Option>> = None; + + loop { + tokio::select! { + cmd_req = ctrl_cmd_rx.recv() => { + match cmd_req { + Some((cmd, reply)) => { + let payload = serde_json::to_vec(&cmd).unwrap_or_default(); + if let Err(e) = send_frame(&mut write_half, &payload).await { + let _ = reply.send(Err(format!("Failed to write command to Stream 0: {}", e))); + break; + } + pending_reply = Some(reply); + } + None => break, + } + } + frame_res = read_frame(&mut read_half) => { + match frame_res { + Ok(bytes) => { + if let Ok(val) = serde_json::from_slice::(&bytes) { + if val.get("status").is_some() && pending_reply.is_some() { + if let Some(reply) = pending_reply.take() { + let _ = reply.send(Ok(val)); + } + continue; + } + } + if let Ok(hb) = serde_json::from_slice::(&bytes) { + fleet_clone_session.register_or_update_heartbeat(hb); + } + } + Err(_) => break, + } + } + } + } + }); + + // Monitor heartbeat updates and connection closure + let fleet_clone = state.fleet.clone(); + let dev_id_clone = device_id.clone(); + + // Wait until driver or write task exits + let _ = driver_handle.await; + write_task.abort(); + + // Cleanup on disconnect + fleet_clone.active_connections.write().remove(&dev_id_clone); + fleet_clone.set_device_status(&dev_id_clone, "offline"); + fleet_clone.record_audit("device_disconnected", "system", &dev_id_clone, json!({})); +} + +// ============================================================================ +// REST Handlers for Fleet Catalog & Observability +// ============================================================================ + +#[derive(Debug, Deserialize)] +pub struct DeviceFilterParams { + pub plant: Option, + pub region: Option, + pub device_type: Option, + pub status: Option, + pub q: Option, +} + +pub async fn list_devices( + State(state): State, + Query(params): Query, +) -> impl IntoResponse { + let devices = state.fleet.devices.read(); + let mut list: Vec = devices.values().cloned().collect(); + + if let Some(ref p) = params.plant { + list.retain(|d| d.plant.eq_ignore_ascii_case(p)); + } + if let Some(ref r) = params.region { + list.retain(|d| d.region.eq_ignore_ascii_case(r)); + } + if let Some(ref t) = params.device_type { + list.retain(|d| d.device_type.eq_ignore_ascii_case(t)); + } + if let Some(ref s) = params.status { + list.retain(|d| d.status.eq_ignore_ascii_case(s)); + } + if let Some(ref q) = params.q { + let q_low = q.to_lowercase(); + list.retain(|d| { + d.device_id.to_lowercase().contains(&q_low) || d.name.to_lowercase().contains(&q_low) + }); + } + + Json(list) +} + +pub async fn get_device(State(state): State, Path(id): Path) -> Response { + let devices = state.fleet.devices.read(); + if let Some(dev) = devices.get(&id) { + Json(dev).into_response() + } else { + ( + StatusCode::NOT_FOUND, + Json(json!({"error": format!("Device '{}' not found", id)})), + ) + .into_response() + } +} + +pub async fn register_device( + State(state): State, + Json(payload): Json, +) -> impl IntoResponse { + let dev_id = payload.device_id.clone(); + state + .fleet + .devices + .write() + .insert(dev_id.clone(), payload.clone()); + state + .fleet + .record_audit("device_registered", "admin", &dev_id, json!(payload)); + (StatusCode::CREATED, Json(payload)) +} + +pub async fn delete_device( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + let removed = state.fleet.devices.write().remove(&id); + state.fleet.active_connections.write().remove(&id); + if removed.is_some() { + state + .fleet + .record_audit("device_deleted", "admin", &id, json!({})); + ( + StatusCode::OK, + Json(json!({"status": "deleted", "device_id": id})), + ) + } else { + ( + StatusCode::NOT_FOUND, + Json(json!({"error": format!("Device '{}' not found", id)})), + ) + } +} + +pub async fn list_groups(State(state): State) -> impl IntoResponse { + let groups = state.fleet.get_groups(); + Json(groups) +} + +// ---------------------------------------------------------------------------- +// Snapshot Endpoints +// ---------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct CreateSnapshotRequest { + pub name: String, + #[serde(default)] + pub description: String, + pub declarative: Value, +} + +pub async fn create_snapshot_handler( + State(state): State, + Json(req): Json, +) -> Response { + match state + .fleet + .create_snapshot(req.name, req.description, req.declarative) + { + Ok(snap) => (StatusCode::CREATED, Json(snap)).into_response(), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +pub async fn list_snapshots(State(state): State) -> impl IntoResponse { + let snaps = state.fleet.snapshots.read(); + let list: Vec = snaps.values().cloned().collect(); + Json(list) +} + +pub async fn get_snapshot(State(state): State, Path(id): Path) -> Response { + let snaps = state.fleet.snapshots.read(); + if let Some(snap) = snaps.get(&id) { + Json(snap).into_response() + } else { + ( + StatusCode::NOT_FOUND, + Json(json!({"error": format!("Snapshot '{}' not found", id)})), + ) + .into_response() + } +} + +// ---------------------------------------------------------------------------- +// Rollout Endpoints +// ---------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct CreateRolloutRequest { + pub snapshot_id: String, + pub target_group: Option, + #[serde(default = "default_true")] + pub auto_promote: bool, +} + +fn default_true() -> bool { + true +} + +pub async fn create_rollout_handler( + State(state): State, + Json(req): Json, +) -> Response { + match state + .fleet + .start_staged_rollout(&req.snapshot_id, req.target_group, req.auto_promote) + .await + { + Ok(rollout) => (StatusCode::CREATED, Json(rollout)).into_response(), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +pub async fn list_rollouts(State(state): State) -> impl IntoResponse { + let rollouts = state.fleet.rollouts.read(); + let list: Vec = rollouts.values().cloned().collect(); + Json(list) +} + +pub async fn get_rollout(State(state): State, Path(id): Path) -> Response { + let rollouts = state.fleet.rollouts.read(); + if let Some(r) = rollouts.get(&id) { + Json(r).into_response() + } else { + ( + StatusCode::NOT_FOUND, + Json(json!({"error": format!("Rollout '{}' not found", id)})), + ) + .into_response() + } +} + +pub async fn promote_rollout_handler( + State(state): State, + Path(id): Path, +) -> Response { + match state.fleet.promote_rollout(&id).await { + Ok(r) => Json(r).into_response(), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +pub async fn rollback_rollout_handler( + State(state): State, + Path(id): Path, +) -> Response { + match state.fleet.rollback_rollout(&id).await { + Ok(r) => Json(r).into_response(), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +// ---------------------------------------------------------------------------- +// Remote HTTP Proxy Tunneling (/api/v1/fleet/devices/:id/proxy/*path) +// ---------------------------------------------------------------------------- + +pub async fn proxy_root_handler( + State(state): State, + Path(device_id): Path, + req: Request, +) -> Response { + proxy_internal(state, device_id, "".to_string(), req).await +} + +pub async fn proxy_path_handler( + State(state): State, + Path((device_id, path)): Path<(String, String)>, + req: Request, +) -> Response { + proxy_internal(state, device_id, path, req).await +} + +async fn proxy_internal( + state: AppState, + device_id: String, + path: String, + req: Request, +) -> Response { + let method = req.method().clone(); + let query = req + .uri() + .query() + .map(|q| format!("?{}", q)) + .unwrap_or_default(); + let clean_path = path.trim_start_matches('/'); + let target_path = if clean_path == "version" || clean_path.is_empty() { + format!("/{}", query) + } else { + format!("/{}{}", clean_path, query) + }; + + // Open outbound Yamux stream (1..N) to target connected edge device + let mut stream = match state.fleet.open_proxy_stream(&device_id).await { + Ok(s) => s, + Err(e) => { + return ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": format!("Cannot proxy to device '{}': {}", device_id, e)})), + ) + .into_response(); + } + }; + + // Format HTTP/1.1 wire request + let (parts, body) = req.into_parts(); + let body_bytes = match axum::body::to_bytes(body, 32 * 1024 * 1024).await { + Ok(b) => b, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": e.to_string()})), + ) + .into_response(); + } + }; + + let mut wire = format!("{} {} HTTP/1.1\r\n", parts.method, target_path); + for (k, v) in parts.headers.iter() { + if k != "host" { + if let Ok(v_str) = v.to_str() { + wire.push_str(&format!("{}: {}\r\n", k.as_str(), v_str)); + } + } + } + if !parts.headers.contains_key("content-length") { + wire.push_str(&format!("content-length: {}\r\n", body_bytes.len())); + } + wire.push_str("\r\n"); + + let mut out_bytes = wire.into_bytes(); + out_bytes.extend_from_slice(&body_bytes); + + if let Err(e) = stream.write_all(&out_bytes).await { + return ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": format!("Proxy write failed: {}", e)})), + ) + .into_response(); + } + let _ = stream.flush().await; + + // Read HTTP response from edge agent's local router + let mut resp_bytes = Vec::new(); + let mut buf = [0u8; 8192]; + let mut expected_total: Option = None; + + let read_result = tokio::time::timeout(Duration::from_secs(10), async { + loop { + let n = stream.read(&mut buf).await?; + if n == 0 { + break; + } + resp_bytes.extend_from_slice(&buf[..n]); + + if expected_total.is_none() { + if let Some(pos) = resp_bytes.windows(4).position(|w| w == b"\r\n\r\n") { + let header_part = std::str::from_utf8(&resp_bytes[..pos]).unwrap_or(""); + let mut cl: Option = None; + for line in header_part.lines() { + if let Some((k, v)) = line.split_once(':') { + if k.trim().eq_ignore_ascii_case("content-length") { + cl = v.trim().parse().ok(); + break; + } + } + } + if let Some(len) = cl { + expected_total = Some(pos + 4 + len); + } + } + } + + if let Some(tot) = expected_total { + if resp_bytes.len() >= tot { + break; + } + } + } + Ok::<(), std::io::Error>(()) + }) + .await; + + if read_result.is_err() || resp_bytes.is_empty() { + return ( + StatusCode::GATEWAY_TIMEOUT, + Json(json!({"error": "Edge gateway proxy timed out"})), + ) + .into_response(); + } + + state.fleet.record_audit( + "proxy_request", + "api", + &device_id, + json!({ + "method": method.to_string(), + "path": target_path, + }), + ); + + match parse_http_response_bytes(&resp_bytes) { + Ok(r) => r, + Err(e) => (StatusCode::BAD_GATEWAY, Json(json!({"error": e}))).into_response(), + } +} + +pub fn parse_http_response_bytes(bytes: &[u8]) -> Result { + let header_end = bytes + .windows(4) + .position(|w| w == b"\r\n\r\n") + .ok_or_else(|| "Missing header separator \r\n\r\n in response".to_string())?; + + let header_str = std::str::from_utf8(&bytes[..header_end]) + .map_err(|e| format!("Invalid UTF-8 in response headers: {}", e))?; + let mut lines = header_str.lines(); + let status_line = lines.next().ok_or("Empty response status line")?; + let mut parts = status_line.split_whitespace(); + let _proto = parts.next().unwrap_or("HTTP/1.1"); + let code_str = parts.next().unwrap_or("200"); + let status_code = code_str.parse::().unwrap_or(200); + + let status = StatusCode::from_u16(status_code).unwrap_or(StatusCode::OK); + let mut builder = Response::builder().status(status); + + for line in lines { + if let Some((k, v)) = line.split_once(':') { + builder = builder.header(k.trim(), v.trim()); + } + } + + let body_bytes = bytes[header_end + 4..].to_vec(); + builder + .body(Body::from(body_bytes)) + .map_err(|e| e.to_string()) +} + +// ---------------------------------------------------------------------------- +// Fleet Observability Endpoints (/metrics, /dlq, /audit) +// ---------------------------------------------------------------------------- + +pub async fn fleet_metrics_handler( + State(state): State, + headers: axum::http::HeaderMap, +) -> Response { + let metrics = state.fleet.get_fleet_metrics(); + let accept = headers + .get(axum::http::header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if accept.contains("text/plain") { + let text = format!( + "# HELP rekuiper_fleet_connected_gateways Total gateways connected to Control Plane\n\ + # TYPE rekuiper_fleet_connected_gateways gauge\n\ + rekuiper_fleet_connected_gateways {}\n\ + # HELP rekuiper_fleet_gateways_online Online fleet gateways\n\ + # TYPE rekuiper_fleet_gateways_online gauge\n\ + rekuiper_fleet_gateways_online {}\n\ + # HELP rekuiper_fleet_total_rules Total rules deployed across fleet\n\ + # TYPE rekuiper_fleet_total_rules gauge\n\ + rekuiper_fleet_total_rules {}\n\ + # HELP rekuiper_fleet_total_dlq_count Total DLQ dropped records across fleet\n\ + # TYPE rekuiper_fleet_total_dlq_count counter\n\ + rekuiper_fleet_total_dlq_count {}\n", + metrics["total_devices"], + metrics["online_devices"], + metrics["total_rules"], + metrics["total_dlq_errors"] + ); + ( + StatusCode::OK, + [( + axum::http::header::CONTENT_TYPE, + "text/plain; version=0.0.4", + )], + text, + ) + .into_response() + } else { + Json(metrics).into_response() + } +} + +pub async fn fleet_dlq_handler(State(state): State) -> impl IntoResponse { + let dlq = state.fleet.get_fleet_dlq_alerts(); + Json(dlq) +} + +pub async fn fleet_audit_handler(State(state): State) -> impl IntoResponse { + let log = state.fleet.audit_log.read().clone(); + Json(log) +} + +// ---------------------------------------------------------------------------- +// Fleet Router Mount +// ---------------------------------------------------------------------------- + +pub fn create_fleet_router(state: AppState) -> Router { + Router::new() + .route("/fleet/ws", get(fleet_ws_acceptor)) + .route( + "/api/v1/fleet/devices", + get(list_devices).post(register_device), + ) + .route( + "/api/v1/fleet/devices/:id", + get(get_device).delete(delete_device), + ) + .route("/api/v1/fleet/groups", get(list_groups)) + .route( + "/api/v1/fleet/snapshots", + get(list_snapshots).post(create_snapshot_handler), + ) + .route("/api/v1/fleet/snapshots/:id", get(get_snapshot)) + .route( + "/api/v1/fleet/rollouts", + get(list_rollouts).post(create_rollout_handler), + ) + .route("/api/v1/fleet/rollouts/:id", get(get_rollout)) + .route( + "/api/v1/fleet/rollouts/:id/promote", + post(promote_rollout_handler), + ) + .route( + "/api/v1/fleet/rollouts/:id/rollback", + post(rollback_rollout_handler), + ) + .route("/api/v1/fleet/devices/:id/proxy", any(proxy_root_handler)) + .route("/api/v1/fleet/devices/:id/proxy/", any(proxy_root_handler)) + .route( + "/api/v1/fleet/devices/:id/proxy/*path", + any(proxy_path_handler), + ) + .route("/api/v1/fleet/metrics", get(fleet_metrics_handler)) + .route("/api/v1/fleet/dlq", get(fleet_dlq_handler)) + .route("/api/v1/fleet/audit", get(fleet_audit_handler)) + .with_state(state) +} diff --git a/crates/rekuiper-server/src/handlers/comms.rs b/crates/rekuiper-server/src/handlers/comms.rs new file mode 100644 index 00000000..a1e16495 --- /dev/null +++ b/crates/rekuiper-server/src/handlers/comms.rs @@ -0,0 +1,150 @@ +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + Path, State, + }, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use futures::{SinkExt, StreamExt}; +use serde_json::json; +use std::time::Duration; +use tokio::time::interval; + +use crate::comms::CommsEvent; +use crate::state::AppState; + +pub async fn enable_rule_tap( + State(state): State, + Path((rule_id, node_id)): Path<(String, String)>, +) -> Response { + state.comms_hub.enable_tap(&rule_id, &node_id); + ( + StatusCode::OK, + Json(json!({ + "status": "enabled", + "rule": rule_id, + "node": node_id + })), + ) + .into_response() +} + +pub async fn disable_rule_tap( + State(state): State, + Path((rule_id, node_id)): Path<(String, String)>, +) -> Response { + state.comms_hub.disable_tap(&rule_id, &node_id); + ( + StatusCode::OK, + Json(json!({ + "status": "disabled", + "rule": rule_id, + "node": node_id + })), + ) + .into_response() +} + +pub async fn get_rule_tap_status( + State(state): State, + Path((rule_id, node_id)): Path<(String, String)>, +) -> Response { + let active = state.comms_hub.is_tap_active(&rule_id, &node_id); + ( + StatusCode::OK, + Json(json!({ + "active": active, + "rule": rule_id, + "node": node_id + })), + ) + .into_response() +} + +pub async fn comms_websocket_handler( + ws: WebSocketUpgrade, + State(state): State, +) -> Response { + ws.on_upgrade(move |socket| handle_comms_socket(socket, state)) +} + +async fn handle_comms_socket(socket: WebSocket, state: AppState) { + let (mut sender, mut receiver) = socket.split(); + let mut comms_rx = state.comms_hub.subscribe(); + let mut ticker = interval(Duration::from_secs(15)); + // Consume immediate first tick + ticker.tick().await; + + loop { + tokio::select! { + event = comms_rx.recv() => { + match event { + Ok(event) => { + if let Ok(msg_text) = serde_json::to_string(&event) { + if sender.send(Message::Text(msg_text)).await.is_err() { + break; + } + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + tracing::debug!("CommsHub client lagged by {} messages", skipped); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + break; + } + } + } + _ = ticker.tick() => { + let hb = CommsEvent::Heartbeat { + timestamp: chrono::Utc::now().timestamp_millis(), + active_taps: state.comms_hub.active_tap_count(), + }; + if let Ok(msg_text) = serde_json::to_string(&hb) { + if sender.send(Message::Text(msg_text)).await.is_err() { + break; + } + } + } + msg = receiver.next() => { + let Some(msg) = msg else { + break; + }; + let Ok(msg) = msg else { + break; + }; + match msg { + Message::Ping(payload) => { + if sender.send(Message::Pong(payload)).await.is_err() { + break; + } + } + Message::Close(_) => { + break; + } + Message::Text(text) => { + if let Ok(cmd) = serde_json::from_str::(&text) { + if let (Some(action), Some(rule), Some(node)) = ( + cmd.get("action").and_then(|v| v.as_str()), + cmd.get("rule").and_then(|v| v.as_str()), + cmd.get("node").and_then(|v| v.as_str()), + ) { + match action { + "enable" | "tap_enable" => { + state.comms_hub.enable_tap(rule, node); + } + "disable" | "tap_disable" => { + state.comms_hub.disable_tap(rule, node); + } + _ => {} + } + } + } + } + _ => {} + } + } + } + } +} diff --git a/crates/rekuiper-server/src/handlers/connections.rs b/crates/rekuiper-server/src/handlers/connections.rs index 9ed56650..89dfd547 100644 --- a/crates/rekuiper-server/src/handlers/connections.rs +++ b/crates/rekuiper-server/src/handlers/connections.rs @@ -270,7 +270,7 @@ pub async fn save_connection_conf_key( if let Err(resp) = check_valid_name(&conf_key) { return resp; } - let payload = if body.is_empty() { + let mut payload = if body.is_empty() { json!({}) } else { serde_json::from_slice::(&body).unwrap_or_else(|_| json!({})) @@ -279,6 +279,18 @@ pub async fn save_connection_conf_key( let dot_key = format!("{}.{}", name, conf_key); let _guard = state.config_op_lock.lock().await; + // Preserve existing secrets when updated with "******" placeholder + let existing_val = { + let conns = state.connections.read(); + conns + .get(&canonical_key) + .or_else(|| conns.get(&dot_key)) + .cloned() + }; + if let Some(existing) = existing_val { + crate::secrets::preserve_masked_secrets(&mut payload, &existing); + } + if let Some(kv) = state.kv.as_ref() { let ops = [ KvOperation::Set { @@ -326,15 +338,17 @@ pub async fn get_connection_conf_key( .or_else(|| conns.get(&dot_key)) .cloned() }; - if let Some(val) = mem_val { + if let Some(mut val) = mem_val { + crate::secrets::mask_secrets(&mut val); return Json(val).into_response(); } if let Some(kv) = state.kv.as_ref() { match kv.get("connections", &canonical_key).await { Ok(Some(raw)) => { - if let Ok(val) = serde_json::from_str::(&raw) { + if let Ok(mut val) = serde_json::from_str::(&raw) { state.connections.write().insert(canonical_key, val.clone()); + crate::secrets::mask_secrets(&mut val); return Json(val).into_response(); } } @@ -349,8 +363,9 @@ pub async fn get_connection_conf_key( } match kv.get("connections", &dot_key).await { Ok(Some(raw)) => { - if let Ok(val) = serde_json::from_str::(&raw) { + if let Ok(mut val) = serde_json::from_str::(&raw) { state.connections.write().insert(dot_key, val.clone()); + crate::secrets::mask_secrets(&mut val); return Json(val).into_response(); } } @@ -519,13 +534,16 @@ pub async fn register_lookup_connection( } pub async fn list_connections(State(state): State) -> impl IntoResponse { - let conns: Vec = state.connections.read().values().cloned().collect(); + let mut conns: Vec = state.connections.read().values().cloned().collect(); + for c in &mut conns { + crate::secrets::mask_secrets(c); + } Json(conns) } pub async fn create_connection( State(state): State, - Json(payload): Json, + Json(mut payload): Json, ) -> Response { let id = payload .get("id") @@ -554,6 +572,7 @@ pub async fn create_connection( .into_response(); } } + crate::secrets::interpolate_secrets(&mut payload, state.kv.as_deref()); let _guard = state.config_op_lock.lock().await; if let Err(e) = persist_config_entry(&state, "connections", &id, &payload).await { return ( @@ -579,7 +598,8 @@ pub async fn get_connection(State(state): State, Path(id): Path, Path(id): Path { - if let Ok(val) = serde_json::from_str::(&raw) { + if let Ok(mut val) = serde_json::from_str::(&raw) { state .connections .write() .insert(candidate.to_string(), val.clone()); + crate::secrets::mask_secrets(&mut val); return Json(val).into_response(); } } @@ -674,7 +695,7 @@ pub async fn delete_connection(State(state): State, Path(id): Path, Path(id): Path, - Json(payload): Json, + Json(mut payload): Json, ) -> Response { let _guard = state.config_op_lock.lock().await; let mut stored = state @@ -683,6 +704,8 @@ pub async fn update_connection( .get(&id) .cloned() .unwrap_or_else(|| json!({"id": id})); + crate::secrets::preserve_masked_secrets(&mut payload, &stored); + crate::secrets::interpolate_secrets(&mut payload, state.kv.as_deref()); merge_json_object(&mut stored, &payload); if let Some(obj) = stored.as_object_mut() { obj.insert("id".to_string(), Value::String(id.clone())); @@ -695,7 +718,9 @@ pub async fn update_connection( .into_response(); } state.connections.write().insert(id, stored.clone()); - Json(stored).into_response() + let mut masked = stored; + crate::secrets::mask_secrets(&mut masked); + Json(masked).into_response() } #[cfg(test)] diff --git a/crates/rekuiper-server/src/handlers/data.rs b/crates/rekuiper-server/src/handlers/data.rs index 727169b7..2ac1268e 100644 --- a/crates/rekuiper-server/src/handlers/data.rs +++ b/crates/rekuiper-server/src/handlers/data.rs @@ -146,7 +146,9 @@ pub async fn export_ruleset(State(state): State) -> Response { } let mut rules_map = serde_json::Map::new(); for rule in state.rule_manager.list_rules() { - let rule_json = serde_json::to_string(&rule).unwrap_or_default(); + let mut r_val = serde_json::to_value(&rule).unwrap_or(Value::Null); + crate::secrets::mask_secrets(&mut r_val); + let rule_json = serde_json::to_string(&r_val).unwrap_or_default(); rules_map.insert(rule.id, Value::String(rule_json)); } ( @@ -181,7 +183,9 @@ pub async fn export_data(State(state): State) -> Response { } let mut rules_map = serde_json::Map::new(); for rule in state.rule_manager.list_rules() { - let rule_json = serde_json::to_string(&rule).unwrap_or_default(); + let mut r_val = serde_json::to_value(&rule).unwrap_or(Value::Null); + crate::secrets::mask_secrets(&mut r_val); + let rule_json = serde_json::to_string(&r_val).unwrap_or_default(); rules_map.insert(rule.id, Value::String(rule_json)); } ( @@ -225,7 +229,8 @@ pub async fn export_data_v2(State(state): State) -> Response { } let mut rules_map = serde_json::Map::new(); for rule in state.rule_manager.list_rules() { - let rule_val = serde_json::to_value(&rule).unwrap_or(Value::Null); + let mut rule_val = serde_json::to_value(&rule).unwrap_or(Value::Null); + crate::secrets::mask_secrets(&mut rule_val); rules_map.insert(rule.id, rule_val); } let doc = json!({ diff --git a/crates/rekuiper-server/src/handlers/metadata.rs b/crates/rekuiper-server/src/handlers/metadata.rs index e7db2233..55e414a7 100644 --- a/crates/rekuiper-server/src/handlers/metadata.rs +++ b/crates/rekuiper-server/src/handlers/metadata.rs @@ -202,6 +202,473 @@ pub async fn list_operator_metadata() -> impl IntoResponse { ])) } +/// Dynamic Palette Schema for Visual Stream Studio +pub async fn get_palette_metadata() -> impl IntoResponse { + let palette = json!({ + "categories": ["sources", "operators", "sinks"], + "nodes": [ + // Sources + { + "category": "source", + "type": "mqtt", + "label": "MQTT Source", + "icon": "radio", + "color": "#0284c7", + "description": "Subscribes to an MQTT broker topic and streams incoming payloads.", + "inputs": 0, + "outputs": 1, + "multi_port": false, + "properties": [ + { "name": "datasource", "type": "string", "label": "Stream / Topic", "default": "demo", "required": true }, + { "name": "server", "type": "string", "label": "Broker URL", "default": "tcp://127.0.0.1:1883" }, + { "name": "qos", "type": "number", "label": "QoS Level (0-2)", "default": 1 } + ] + }, + { + "category": "source", + "type": "modbus", + "label": "Modbus PLC", + "icon": "cpu", + "color": "#0284c7", + "description": "Polls industrial Modbus TCP / RTU coils and holding registers.", + "inputs": 0, + "outputs": 1, + "multi_port": false, + "properties": [ + { "name": "datasource", "type": "string", "label": "Stream Name", "default": "modbus_plc", "required": true }, + { "name": "endpoint", "type": "string", "label": "TCP Endpoint", "default": "127.0.0.1:502" }, + { "name": "intervalMs", "type": "number", "label": "Poll Interval (ms)", "default": 1000 }, + { "name": "unitId", "type": "number", "label": "Unit / Slave ID", "default": 1 } + ] + }, + { + "category": "source", + "type": "opcua", + "label": "OPC-UA Source", + "icon": "server", + "color": "#0284c7", + "description": "Polls or subscribes to OPC-UA node IDs via opc.tcp.", + "inputs": 0, + "outputs": 1, + "multi_port": false, + "properties": [ + { "name": "datasource", "type": "string", "label": "Stream Name", "default": "opcua_stream", "required": true }, + { "name": "endpoint", "type": "string", "label": "Server URL", "default": "opc.tcp://127.0.0.1:4840" }, + { "name": "intervalMs", "type": "number", "label": "Sampling Interval (ms)", "default": 1000 } + ] + }, + { + "category": "source", + "type": "httppull", + "label": "HTTP Pull", + "icon": "globe", + "color": "#0284c7", + "description": "Periodically polls a REST JSON endpoint.", + "inputs": 0, + "outputs": 1, + "multi_port": false, + "properties": [ + { "name": "datasource", "type": "string", "label": "Stream Name", "default": "http_api", "required": true }, + { "name": "url", "type": "string", "label": "Target URL", "default": "http://127.0.0.1:8080/data" }, + { "name": "interval", "type": "number", "label": "Poll Interval (ms)", "default": 2000 } + ] + }, + { + "category": "source", + "type": "kafka", + "label": "Kafka Source", + "icon": "layers", + "color": "#0284c7", + "description": "Consumes messages from Apache Kafka topic partitions.", + "inputs": 0, + "outputs": 1, + "multi_port": false, + "properties": [ + { "name": "datasource", "type": "string", "label": "Stream / Topic", "default": "kafka_stream", "required": true }, + { "name": "brokers", "type": "string", "label": "Brokers", "default": "127.0.0.1:9092" }, + { "name": "group_id", "type": "string", "label": "Consumer Group", "default": "rekuiper_group" } + ] + }, + { + "category": "source", + "type": "simulator", + "label": "Simulator / Mock", + "icon": "play-circle", + "color": "#0284c7", + "description": "Generates synthetic or replayed test telemetry events.", + "inputs": 0, + "outputs": 1, + "multi_port": false, + "properties": [ + { "name": "datasource", "type": "string", "label": "Stream Name", "default": "sim_stream", "required": true }, + { "name": "interval", "type": "number", "label": "Event Spacing (ms)", "default": 100 } + ] + }, + // Operators + { + "category": "operator", + "type": "switch", + "label": "Switch Router", + "icon": "git-branch", + "color": "#ea580c", + "description": "Multi-port selective predicate routing without broadcast fan-out.", + "inputs": 1, + "outputs": 2, + "multi_port": true, + "properties": [ + { + "name": "cases", + "type": "json", + "label": "Routing Cases [{\"case\": \"expr\", \"port\": 0}]", + "default": [ + { "case": "temperature > 50", "port": 0 }, + { "case": "temperature <= 50", "port": 1 } + ] + }, + { "name": "default_port", "type": "number", "label": "Default Port", "default": 1 } + ] + }, + { + "category": "operator", + "type": "rate_limit", + "label": "Rate Limiter", + "icon": "shield", + "color": "#6366f1", + "description": "Token-bucket stream throttle: admits allowed messages and drops or sheds excess bursts.", + "inputs": 1, + "outputs": 1, + "multi_port": false, + "properties": [ + { "name": "rate_limit", "type": "number", "label": "Limit (msgs/sec)", "default": 10.0 }, + { "name": "burst", "type": "number", "label": "Max Burst Tokens", "default": 10.0 } + ] + }, + { + "category": "operator", + "type": "watchdog", + "label": "Watchdog Timeout", + "icon": "clock", + "color": "#e11d48", + "description": "Inactivity timer: raises synthetic alert tuples if input stream goes silent.", + "inputs": 1, + "outputs": 1, + "multi_port": false, + "properties": [ + { "name": "timeout_ms", "type": "number", "label": "Silence Timeout (ms)", "default": 5000 }, + { + "name": "alert_payload", + "type": "json", + "label": "Alert Payload", + "default": { "status": "INACTIVE_TIMEOUT", "severity": "HIGH" } + }, + { "name": "repeat", "type": "boolean", "label": "Repeat Alert Ticks", "default": true } + ] + }, + { + "category": "operator", + "type": "filter", + "label": "Filter", + "icon": "filter", + "color": "#7c3aed", + "description": "Filters stream records based on a boolean SQL expression.", + "inputs": 1, + "outputs": 1, + "multi_port": false, + "properties": [ + { "name": "expr", "type": "string", "label": "Filter Expression", "default": "temperature > 25" } + ] + }, + { + "category": "operator", + "type": "pick", + "label": "Pick Projection", + "icon": "columns", + "color": "#7c3aed", + "description": "Projects a subset of record fields forward.", + "inputs": 1, + "outputs": 1, + "multi_port": false, + "properties": [ + { "name": "fields", "type": "json", "label": "Field Names", "default": ["temperature", "humidity"] } + ] + }, + { + "category": "operator", + "type": "window", + "label": "Window Aggregator", + "icon": "box", + "color": "#7c3aed", + "description": "Tumbling, hopping, sliding or session time/count windows.", + "inputs": 1, + "outputs": 1, + "multi_port": false, + "properties": [ + { "name": "window_type", "type": "string", "label": "Window Type (tumbling/hopping/sliding)", "default": "tumbling" }, + { "name": "length", "type": "number", "label": "Duration (ms) or Count", "default": 10000 } + ] + }, + { + "category": "operator", + "type": "sql", + "label": "SQL Transform", + "icon": "database", + "color": "#7c3aed", + "description": "Embedded SQL projection and aggregation statement.", + "inputs": 1, + "outputs": 1, + "multi_port": false, + "properties": [ + { "name": "sql", "type": "string", "label": "SQL Statement", "default": "SELECT * FROM src WHERE temp > 20" } + ] + }, + // Sinks + { + "category": "sink", + "type": "mqtt", + "label": "MQTT Sink", + "icon": "send", + "color": "#059669", + "description": "Publishes processed records to an MQTT broker topic.", + "inputs": 1, + "outputs": 0, + "multi_port": false, + "properties": [ + { "name": "server", "type": "string", "label": "Broker URL", "default": "tcp://127.0.0.1:1883" }, + { "name": "topic", "type": "string", "label": "Target Topic", "default": "rekuiper/alerts" }, + { "name": "qos", "type": "number", "label": "QoS Level", "default": 1 } + ] + }, + { + "category": "sink", + "type": "rest", + "label": "REST / HTTP Sink", + "icon": "globe", + "color": "#059669", + "description": "Dispatches HTTP POST requests with output JSON payloads.", + "inputs": 1, + "outputs": 0, + "multi_port": false, + "properties": [ + { "name": "url", "type": "string", "label": "Target URL", "default": "http://127.0.0.1:8080/ingest" }, + { "name": "method", "type": "string", "label": "HTTP Method", "default": "POST" } + ] + }, + { + "category": "sink", + "type": "log", + "label": "Log Sink", + "icon": "terminal", + "color": "#059669", + "description": "Emits output tuples to the daemon tracing console.", + "inputs": 1, + "outputs": 0, + "multi_port": false, + "properties": [] + }, + { + "category": "sink", + "type": "file", + "label": "File Sink", + "icon": "file-text", + "color": "#059669", + "description": "Appends records to rolling JSON / Parquet files.", + "inputs": 1, + "outputs": 0, + "multi_port": false, + "properties": [ + { "name": "path", "type": "string", "label": "File Path", "default": "output.json" } + ] + }, + { + "category": "sink", + "type": "kafka", + "label": "Kafka Sink", + "icon": "layers", + "color": "#059669", + "description": "Publishes output records to an Apache Kafka topic.", + "inputs": 1, + "outputs": 0, + "multi_port": false, + "properties": [ + { "name": "brokers", "type": "string", "label": "Brokers", "default": "127.0.0.1:9092" }, + { "name": "topic", "type": "string", "label": "Kafka Topic", "default": "rekuiper_out" } + ] + }, + { + "category": "sink", + "type": "influx", + "label": "InfluxDB v1", + "icon": "database", + "color": "#059669", + "description": "Writes stream records to InfluxDB 1.x using Line Protocol over HTTP.", + "inputs": 1, + "outputs": 0, + "multi_port": false, + "properties": [ + { "name": "addr", "type": "string", "label": "Server URL", "default": "http://127.0.0.1:8086", "required": true }, + { "name": "measurement", "type": "string", "label": "Measurement", "default": "rekuiper_metrics" }, + { "name": "precision", "type": "string", "label": "Precision", "default": "ms" } + ] + }, + { + "category": "sink", + "type": "influx2", + "label": "InfluxDB v2", + "icon": "database", + "color": "#059669", + "description": "Writes stream records to InfluxDB 2.x/3.x with Org and Bucket authentication.", + "inputs": 1, + "outputs": 0, + "multi_port": false, + "properties": [ + { "name": "addr", "type": "string", "label": "Server URL", "default": "http://127.0.0.1:8086", "required": true }, + { "name": "org", "type": "string", "label": "Organization", "default": "my-org" }, + { "name": "bucket", "type": "string", "label": "Bucket", "default": "iot_bucket" }, + { "name": "token", "type": "string", "label": "API Token", "default": "******" }, + { "name": "measurement", "type": "string", "label": "Measurement", "default": "rekuiper_metrics" } + ] + }, + { + "category": "sink", + "type": "tdengine", + "label": "TDengine 3.x", + "icon": "database", + "color": "#059669", + "description": "Writes high-throughput time-series records to TDengine database.", + "inputs": 1, + "outputs": 0, + "multi_port": false, + "properties": [ + { "name": "host", "type": "string", "label": "Host", "default": "localhost" }, + { "name": "port", "type": "number", "label": "Port", "default": 6041 }, + { "name": "database", "type": "string", "label": "Database", "default": "power" }, + { "name": "table", "type": "string", "label": "Table", "default": "meters" } + ] + }, + { + "category": "sink", + "type": "iotdb", + "label": "Apache IoTDB", + "icon": "database", + "color": "#059669", + "description": "Streams device telemetry records directly to Apache IoTDB REST service.", + "inputs": 1, + "outputs": 0, + "multi_port": false, + "properties": [ + { "name": "addr", "type": "string", "label": "Server URL", "default": "http://127.0.0.1:18080" }, + { "name": "device", "type": "string", "label": "Device Path", "default": "root.factory.d1" } + ] + }, + { + "category": "sink", + "type": "opcua", + "label": "OPC-UA Sink", + "icon": "server", + "color": "#059669", + "description": "Writes values to OPC-UA node attributes on industrial servers.", + "inputs": 1, + "outputs": 0, + "multi_port": false, + "properties": [ + { "name": "endpoint", "type": "string", "label": "Server URL", "default": "opc.tcp://127.0.0.1:4840" }, + { "name": "nodeId", "type": "string", "label": "Target Node ID", "default": "ns=2;s=Device1.SetPoint" } + ] + }, + { + "category": "sink", + "type": "modbus", + "label": "Modbus PLC Sink", + "icon": "cpu", + "color": "#059669", + "description": "Writes output values to Modbus TCP coils or holding registers.", + "inputs": 1, + "outputs": 0, + "multi_port": false, + "properties": [ + { "name": "endpoint", "type": "string", "label": "TCP Endpoint", "default": "127.0.0.1:502" }, + { "name": "slaveId", "type": "number", "label": "Slave ID", "default": 1 }, + { "name": "address", "type": "number", "label": "Register Address", "default": 40001 } + ] + }, + { + "category": "sink", + "type": "redis", + "label": "Redis Sink", + "icon": "layers", + "color": "#059669", + "description": "Publishes to Redis pub/sub channel or appends to Redis stream/list.", + "inputs": 1, + "outputs": 0, + "multi_port": false, + "properties": [ + { "name": "addr", "type": "string", "label": "Redis URL", "default": "redis://127.0.0.1:6379" }, + { "name": "topic", "type": "string", "label": "Channel / Stream Key", "default": "rekuiper_alerts" }, + { "name": "datatype", "type": "string", "label": "Data Type (channel/stream/list)", "default": "channel" } + ] + }, + { + "category": "sink", + "type": "parquet", + "label": "Parquet Rolling Sink", + "icon": "file-text", + "color": "#059669", + "description": "Rolls high-velocity records into compressed Apache Parquet columnar files.", + "inputs": 1, + "outputs": 0, + "multi_port": false, + "properties": [ + { "name": "path", "type": "string", "label": "Directory Path", "default": "/data/parquet/telemetry" }, + { "name": "rollingCount", "type": "number", "label": "Rolling Record Count", "default": 100000 }, + { "name": "rollingIntervalMs", "type": "number", "label": "Rolling Interval (ms)", "default": 60000 } + ] + }, + { + "category": "sink", + "type": "otlp", + "label": "OpenTelemetry OTLP", + "icon": "activity", + "color": "#059669", + "description": "Exports edge stream metrics, logs, and spans to OpenTelemetry collectors.", + "inputs": 1, + "outputs": 0, + "multi_port": false, + "properties": [ + { "name": "endpoint", "type": "string", "label": "Collector Endpoint", "default": "http://127.0.0.1:4318/v1/metrics" }, + { "name": "protocol", "type": "string", "label": "Protocol", "default": "http/json" } + ] + }, + { + "category": "sink", + "type": "websocket", + "label": "WebSocket Sink", + "icon": "radio", + "color": "#059669", + "description": "Broadcasts stream records in real-time to connected WebSocket clients.", + "inputs": 1, + "outputs": 0, + "multi_port": false, + "properties": [ + { "name": "addr", "type": "string", "label": "WebSocket URL / Path", "default": "ws://127.0.0.1:9090/ws/alerts" } + ] + }, + { + "category": "sink", + "type": "nop", + "label": "NOP Drop", + "icon": "slash", + "color": "#64748b", + "description": "Black-hole drop sink for benchmarking and dead-ends.", + "inputs": 1, + "outputs": 0, + "multi_port": false, + "properties": [] + } + ] + }); + Json(palette) +} + pub async fn list_metadata_connections(State(state): State) -> impl IntoResponse { let conns: Vec = state.connections.read().values().cloned().collect(); Json(conns) diff --git a/crates/rekuiper-server/src/handlers/mod.rs b/crates/rekuiper-server/src/handlers/mod.rs index 6dbb6de6..c2c40776 100644 --- a/crates/rekuiper-server/src/handlers/mod.rs +++ b/crates/rekuiper-server/src/handlers/mod.rs @@ -1,3 +1,4 @@ +pub mod comms; pub mod connections; pub mod data; pub mod metadata; @@ -7,6 +8,7 @@ pub mod rules; pub mod ruletest; pub mod streams; +pub use comms::*; pub use connections::*; pub use data::*; pub use metadata::*; diff --git a/crates/rekuiper-server/src/handlers/rules.rs b/crates/rekuiper-server/src/handlers/rules.rs index 5baa5ea3..b76caf56 100644 --- a/crates/rekuiper-server/src/handlers/rules.rs +++ b/crates/rekuiper-server/src/handlers/rules.rs @@ -1,4 +1,7 @@ -use crate::engine::{bootstrap_rule_sources, cancel_rule_source, spawn_rule_task}; +use crate::engine::{ + bootstrap_graph_sources, bootstrap_rule_sources, cancel_rule_source, spawn_graph_rule_task, + spawn_rule_task, +}; use crate::state::{check_valid_name, AppState, TracerConfig}; use axum::{ body::Bytes, @@ -7,7 +10,7 @@ use axum::{ response::{IntoResponse, Response}, Json, }; -use rekuiper_core::model::{compile_graph_to_sql_and_actions, RuleDefinition}; +use rekuiper_core::model::{compile_graph_to_sql_and_actions, EdgeTargets, RuleDefinition}; use rekuiper_core::{PluginDefinition, PluginManager}; use rekuiper_sql::{is_builtin_function, Evaluator, Expr, Parser, SelectStmt, TimeUnit, WindowDef}; use serde::Deserialize; @@ -52,6 +55,12 @@ pub async fn create_rule( State(state): State, Json(mut rule): Json, ) -> Response { + let mut inc_val = serde_json::to_value(&rule).unwrap_or(Value::Null); + crate::secrets::interpolate_secrets(&mut inc_val, state.kv.as_deref()); + if let Ok(deser) = serde_json::from_value::(inc_val) { + rule = deser; + } + if rule.id.trim().is_empty() { return ( StatusCode::BAD_REQUEST, @@ -82,9 +91,62 @@ pub async fn create_rule( ) .into_response(); } - // Graph rules carry no SQL: compile the DAG into SQL + actions first. + // Graph rules carry no SQL: compile linear DAGs or spawn native DAG executor. if rule.sql.trim().is_empty() { if let Some(ref graph) = rule.graph { + let is_native_dag = graph + .nodes + .values() + .any(|n| matches!(n.node_type.as_str(), "switch" | "rate_limit" | "watchdog")) + || graph + .topo + .edges + .values() + .any(|e| matches!(e, EdgeTargets::Multi(_))); + + if is_native_dag { + let rule_id = rule.id.clone(); + if let Err(e) = state.rule_manager.create_rule(rule.clone()).await { + let msg = if e.to_string().contains("already exists") { + format!("Rule {} already exists", rule_id) + } else { + e.to_string() + }; + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": 1000, + "message": msg + })), + ) + .into_response(); + } + + let buffer_len = rule + .options + .as_ref() + .and_then(|o| o.get("bufferLength")) + .and_then(|v| v.as_u64()) + .map(|n| n as usize) + .unwrap_or(1024); + + spawn_graph_rule_task( + &state.rule_manager, + &state.stream_bus, + rule_id.clone(), + graph.clone(), + state.comms_hub.clone(), + buffer_len, + ); + bootstrap_graph_sources(&state, &rule_id, graph); + + return ( + StatusCode::CREATED, + format!("Rule {} was created successfully.", rule_id), + ) + .into_response(); + } + match compile_graph_to_sql_and_actions(graph) { Ok((sql, actions)) => { rule.sql = sql; @@ -182,7 +244,9 @@ pub async fn get_rule(State(state): State, Path(name): Path) - return resp; } if let Some(rule) = state.rule_manager.get_rule(&name) { - Json(rule).into_response() + let mut val = serde_json::to_value(&rule).unwrap_or(Value::Null); + crate::secrets::mask_secrets(&mut val); + Json(val).into_response() } else { ( StatusCode::NOT_FOUND, @@ -1285,6 +1349,37 @@ pub fn expr_to_string(expr: &Expr) -> String { pub fn activate_rule(state: &AppState, rule_id: &str) { if let Some(rule) = state.rule_manager.get_rule(rule_id) { + if rule.sql.trim().is_empty() { + if let Some(ref graph) = rule.graph { + let is_native_dag = + graph.nodes.values().any(|n| { + matches!(n.node_type.as_str(), "switch" | "rate_limit" | "watchdog") + }) || graph + .topo + .edges + .values() + .any(|e| matches!(e, EdgeTargets::Multi(_))); + if is_native_dag { + let buffer_len = rule + .options + .as_ref() + .and_then(|o| o.get("bufferLength")) + .and_then(|v| v.as_u64()) + .map(|n| n as usize) + .unwrap_or(1024); + spawn_graph_rule_task( + &state.rule_manager, + &state.stream_bus, + rule_id.to_string(), + graph.clone(), + state.comms_hub.clone(), + buffer_len, + ); + bootstrap_graph_sources(state, rule_id, graph); + return; + } + } + } let mut parser = Parser::new(&rule.sql); if let Ok(select_stmt) = parser.parse_select() { spawn_rule_task( @@ -1445,9 +1540,69 @@ pub async fn update_rule( .into_response(); } rule.id = name.clone(); - // Graph rules carry no SQL: compile the DAG first (mirrors creation). + if let Some(existing_rule) = state.rule_manager.get_rule(&name) { + let mut inc_val = serde_json::to_value(&rule).unwrap_or(Value::Null); + let ex_val = serde_json::to_value(&existing_rule).unwrap_or(Value::Null); + crate::secrets::preserve_masked_secrets(&mut inc_val, &ex_val); + crate::secrets::interpolate_secrets(&mut inc_val, state.kv.as_deref()); + if let Ok(deser) = serde_json::from_value::(inc_val) { + rule = deser; + } + } + // Graph rules carry no SQL: compile the DAG first or handle native DAG. if rule.sql.trim().is_empty() { if let Some(ref graph) = rule.graph { + let is_native_dag = graph + .nodes + .values() + .any(|n| matches!(n.node_type.as_str(), "switch" | "rate_limit" | "watchdog")) + || graph + .topo + .edges + .values() + .any(|e| matches!(e, EdgeTargets::Multi(_))); + + if is_native_dag { + let was_running = match state.rule_manager.update_rule(rule.clone()).await { + Ok(running) => running, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": 1000, + "message": e.to_string() + })), + ) + .into_response(); + } + }; + cancel_rule_source(&state, &name); + state.trace_manager.stop_trace(&name); + if was_running { + let buffer_len = rule + .options + .as_ref() + .and_then(|o| o.get("bufferLength")) + .and_then(|v| v.as_u64()) + .map(|n| n as usize) + .unwrap_or(1024); + spawn_graph_rule_task( + &state.rule_manager, + &state.stream_bus, + name.clone(), + graph.clone(), + state.comms_hub.clone(), + buffer_len, + ); + bootstrap_graph_sources(&state, &name, graph); + } + return ( + StatusCode::OK, + format!("Rule {} was updated successfully.", name), + ) + .into_response(); + } + match compile_graph_to_sql_and_actions(graph) { Ok((sql, actions)) => { rule.sql = sql; diff --git a/crates/rekuiper-server/src/lib.rs b/crates/rekuiper-server/src/lib.rs index 17a894f4..97efae20 100644 --- a/crates/rekuiper-server/src/lib.rs +++ b/crates/rekuiper-server/src/lib.rs @@ -1,7 +1,13 @@ +#![recursion_limit = "256"] + +pub mod agent; +pub mod comms; pub mod declarative; pub mod engine; +pub mod fleet; pub mod handlers; pub mod routes; +pub mod secrets; pub mod sink_cache; pub mod state; @@ -27,6 +33,16 @@ use tower_http::trace::TraceLayer; pub struct ServerOptions { pub declarative_path: Option, pub declarative_watch: bool, + pub control_plane_url: Option, + pub device_id: Option, + pub device_name: Option, + pub plant: Option, + pub region: Option, + pub device_type: Option, + pub fleet_token: Option, + pub enable_ui: bool, + pub ui_allow_ip: Option>, + pub ui_password: Option, } pub async fn start_server(config: KuiperConfig, version: String) -> Result<()> { @@ -34,10 +50,22 @@ pub async fn start_server(config: KuiperConfig, version: String) -> Result<()> { } pub async fn start_server_with_options( - config: KuiperConfig, + mut config: KuiperConfig, version: String, options: ServerOptions, ) -> Result<()> { + if options.fleet_token.is_some() { + config.basic.fleet_token = options.fleet_token.clone(); + } + if options.enable_ui { + config.basic.enable_ui = true; + } + if let Some(ref allow) = options.ui_allow_ip { + config.basic.ui_ip_allowlist = allow.clone(); + } + if options.ui_password.is_some() { + config.basic.ui_password = options.ui_password.clone(); + } let init_start = Instant::now(); let is_declarative = options.declarative_path.is_some(); @@ -56,6 +84,8 @@ pub async fn start_server_with_options( let schema_manager = SchemaManager::new_with_kv(kv.clone()); let plugin_manager = PluginManager::new_with_kv(kv.clone()); + let fleet = Arc::new(fleet::FleetManager::new_with_kv(kv.clone())); + let state = AppState { start_time: Instant::now(), version, @@ -95,6 +125,9 @@ pub async fn start_server_with_options( declarative_watch: options.declarative_watch, active_rule_hashes: Arc::new(RwLock::new(HashMap::new())), declarative_reload_failures: Arc::new(std::sync::atomic::AtomicU64::new(0)), + comms_hub: comms::CommsHub::default(), + last_watchdog_rollback: Arc::new(RwLock::new(None)), + fleet: fleet.clone(), }; if let Some(ref d_path) = options.declarative_path { @@ -151,6 +184,45 @@ pub async fn start_server_with_options( .layer(CorsLayer::permissive()) .layer(TraceLayer::new_for_http()); + // Connect outbound edge agent loop if control_plane_url is provided + let cp_url = options + .control_plane_url + .clone() + .or_else(|| config.basic.control_plane_url.clone()); + if let Some(url) = cp_url { + let dev_id = options + .device_id + .clone() + .or_else(|| config.basic.device_id.clone()) + .unwrap_or_else(|| format!("device-{}", uuid::Uuid::new_v4().simple())); + let meta = agent::AgentMetadata { + device_id: dev_id.clone(), + name: options + .device_name + .clone() + .or_else(|| config.basic.device_name.clone()) + .unwrap_or_else(|| dev_id.clone()), + plant: options + .plant + .clone() + .or_else(|| config.basic.device_plant.clone()) + .unwrap_or_else(|| "Plant-Austin".to_string()), + region: options + .region + .clone() + .or_else(|| config.basic.device_region.clone()) + .unwrap_or_else(|| "US-South".to_string()), + device_type: options + .device_type + .clone() + .or_else(|| config.basic.device_type.clone()) + .unwrap_or_else(|| "IPC-Edge".to_string()), + version: state.version.clone(), + }; + let (_cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); + agent::spawn_agent_reconnect_loop(url, state.clone(), app.clone(), meta, cancel_rx); + } + // Dedicated Prometheus listener when enabled on its own port, mirroring // eKuiper's prome_init behaviour. if config.basic.prometheus && config.basic.prometheus_port != config.basic.rest_port { diff --git a/crates/rekuiper-server/src/routes.rs b/crates/rekuiper-server/src/routes.rs index 3062de46..acf8f916 100644 --- a/crates/rekuiper-server/src/routes.rs +++ b/crates/rekuiper-server/src/routes.rs @@ -11,6 +11,8 @@ pub fn create_router(state: AppState) -> Router { Router::new() .route("/ui", get(ui_handler)) .route("/ui/", get(ui_handler)) + .route("/api/ui/auth-status", get(ui_auth_status)) + .route("/api/ui/login", post(ui_login)) .route("/v1/metrics", post(push_otlp_data)) .route("/v1/logs", post(push_otlp_data)) .route("/v1/traces", post(push_otlp_data)) @@ -18,6 +20,7 @@ pub fn create_router(state: AppState) -> Router { .route("/otlp/v1/logs", post(push_otlp_data)) .route("/otlp/v1/traces", post(push_otlp_data)) .route("/ping", get(ping_handler)) + .route("/version", get(root_handler)) .route("/", get(root_handler).post(root_handler)) .route("/streams", get(list_streams).post(create_stream)) .route( @@ -66,6 +69,10 @@ pub fn create_router(state: AppState) -> Router { .patch(patch_rule_tags) .delete(delete_rule_tags), ) + .route("/rules/:id/tap/:node_id/enable", post(enable_rule_tap)) + .route("/rules/:id/tap/:node_id/disable", post(disable_rule_tap)) + .route("/rules/:id/tap/:node_id/status", get(get_rule_tap_status)) + .route("/comms", get(comms_websocket_handler)) .route("/v2/rules/:name/status", get(get_rule_status)) .route("/ruletest", post(create_ruletest)) .route("/ruletest/:name/start", post(start_ruletest)) @@ -110,6 +117,8 @@ pub fn create_router(state: AppState) -> Router { .route("/metadata/sinks/:name", get(get_sink_metadata)) .route("/metadata/functions", get(list_function_metadata)) .route("/metadata/operators", get(list_operator_metadata)) + .route("/metadata/palette", get(get_palette_metadata)) + .route("/api/v1/palette", get(get_palette_metadata)) .route("/metadata/connections", get(list_metadata_connections)) .route("/metadata/resource", get(list_metadata_resources)) .route("/metadata/resources", get(list_metadata_resources)) @@ -252,6 +261,7 @@ pub fn create_router(state: AppState) -> Router { .route("/metrics/dump/check", get(metrics_dump_check)) .route("/metrics", get(prometheus_metrics_handler)) .route("/declarative/reload", post(declarative_reload_handler)) + .merge(crate::fleet::create_fleet_router(state.clone())) .fallback(create_router_fallback) .layer(axum::middleware::from_fn_with_state( state.clone(), @@ -287,6 +297,11 @@ pub async fn declarative_mutation_guard( return next.run(req).await; } + // Allow: Fleet Management Control Plane APIs & Proxy + if path.starts_with("/api/v1/fleet/") || path == "/fleet/ws" { + return next.run(req).await; + } + // Allow: POST /streams/:name/data and POST /tables/:name/data (data plane ingestion) if method == axum::http::Method::POST && (path.starts_with("/streams/") || path.starts_with("/tables/")) @@ -400,9 +415,202 @@ pub async fn declarative_reload_handler( } } -pub async fn ui_handler() -> impl axum::response::IntoResponse { +pub fn ip_matches_entry(client_ip: &std::net::IpAddr, entry: &str) -> bool { + let entry = entry.trim(); + if entry.is_empty() { + return false; + } + // Check CIDR (e.g. 192.168.1.0/24 or 10.0.0.0/8) + if let Some((subnet_str, mask_str)) = entry.split_once('/') { + if let (Ok(subnet), Ok(prefix_len)) = ( + subnet_str.parse::(), + mask_str.parse::(), + ) { + match (client_ip, subnet) { + (std::net::IpAddr::V4(c), std::net::IpAddr::V4(s)) if prefix_len <= 32 => { + let mask = if prefix_len == 0 { + 0 + } else { + (!0u32) << (32 - prefix_len) + }; + return (u32::from(*c) & mask) == (u32::from(s) & mask); + } + (std::net::IpAddr::V6(c), std::net::IpAddr::V6(s)) if prefix_len <= 128 => { + let mask = if prefix_len == 0 { + 0 + } else { + (!0u128) << (128 - prefix_len) + }; + return (u128::from(*c) & mask) == (u128::from(s) & mask); + } + _ => {} + } + } + } + // Exact IP match (e.g. "127.0.0.1", "::1", "192.168.1.100") + if let Ok(allowed_ip) = entry.parse::() { + return client_ip == &allowed_ip; + } + false +} + +pub fn extract_client_ip(headers: &axum::http::HeaderMap) -> Option { + if let Some(x_forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { + if let Some(first_ip) = x_forwarded.split(',').next() { + if let Ok(ip) = first_ip.trim().parse::() { + return Some(ip); + } + } + } + if let Some(x_real_ip) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) { + if let Ok(ip) = x_real_ip.trim().parse::() { + return Some(ip); + } + } + // Loopback fallback for local connections + Some(std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1))) +} + +pub async fn ui_handler( + axum::extract::State(state): axum::extract::State, + headers: axum::http::HeaderMap, +) -> axum::response::Response { + use axum::response::IntoResponse; + let cfg = state.config.read(); + + // 1. Check if Web UI is enabled (default is false for edge security) + if !cfg.basic.enable_ui { + return ( + axum::http::StatusCode::FORBIDDEN, + [(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")], + "Web UI is disabled by default for security. Start rekuiper with --enable-ui or set 'basic.enableUi: true' in etc/kuiper.yaml to enable the web console.\n", + ) + .into_response(); + } + + // 2. Check IP allowlist (if configured) + if !cfg.basic.ui_ip_allowlist.is_empty() { + let client_ip = extract_client_ip(&headers) + .unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1))); + let mut allowed = false; + for entry in &cfg.basic.ui_ip_allowlist { + if ip_matches_entry(&client_ip, entry) { + allowed = true; + break; + } + } + if !allowed { + return ( + axum::http::StatusCode::FORBIDDEN, + [(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")], + format!( + "Access denied: client IP {} is not permitted to access the web UI by ui_ip_allowlist\n", + client_ip + ), + ) + .into_response(); + } + } + + drop(cfg); + + for candidate in &[ + "etc/ui.html", + "/home/paanday/fleet/control/etc/ui.html", + "/home/paanday/fleet/ui.html", + "/tmp/fleet/control/etc/ui.html", + ] { + if let Ok(content) = std::fs::read_to_string(candidate) { + return ( + [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")], + content, + ) + .into_response(); + } + } ( [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")], include_str!("ui.html"), ) + .into_response() +} + +pub async fn ui_auth_status( + axum::extract::State(state): axum::extract::State, +) -> axum::response::Response { + use axum::response::IntoResponse; + let cfg = state.config.read(); + let auth_required = cfg.basic.authentication + || cfg.basic.fleet_token.is_some() + || cfg.basic.ui_password.is_some(); + ( + axum::http::StatusCode::OK, + axum::Json(serde_json::json!({ + "auth_required": auth_required, + "has_jwt": cfg.basic.authentication, + "has_fleet_token": cfg.basic.fleet_token.is_some(), + "has_ui_password": cfg.basic.ui_password.is_some(), + "enable_ui": cfg.basic.enable_ui, + "version": state.version, + })), + ) + .into_response() +} + +#[derive(serde::Deserialize)] +pub struct UiLoginPayload { + pub token: String, +} + +pub async fn ui_login( + axum::extract::State(state): axum::extract::State, + axum::Json(payload): axum::Json, +) -> axum::response::Response { + use axum::response::IntoResponse; + let cfg = state.config.read(); + let token = payload.token.trim(); + + // 1. Check custom UI password + if let Some(ref pw) = cfg.basic.ui_password { + if token == pw { + return ( + axum::http::StatusCode::OK, + axum::Json(serde_json::json!({ "status": "ok", "token": token })), + ) + .into_response(); + } + } + + // 2. Check fleet token + if let Some(ref ft) = cfg.basic.fleet_token { + if token == ft { + return ( + axum::http::StatusCode::OK, + axum::Json(serde_json::json!({ "status": "ok", "token": token })), + ) + .into_response(); + } + } + + // 3. Check JWT token + if cfg.basic.authentication { + if let Some(key_der) = load_auth_public_key() { + if verify_jwt_raw(token, &key_der).is_ok() { + return ( + axum::http::StatusCode::OK, + axum::Json(serde_json::json!({ "status": "ok", "token": token })), + ) + .into_response(); + } + } + } + + ( + axum::http::StatusCode::UNAUTHORIZED, + axum::Json(serde_json::json!({ + "error": "invalid_credentials", + "message": "Invalid console password or authentication token" + })), + ) + .into_response() } diff --git a/crates/rekuiper-server/src/secrets.rs b/crates/rekuiper-server/src/secrets.rs new file mode 100644 index 00000000..2c84936c --- /dev/null +++ b/crates/rekuiper-server/src/secrets.rs @@ -0,0 +1,197 @@ +use rekuiper_core::KvStore; +use serde_json::Value; + +/// Checks if a JSON key represents a sensitive credential field. +pub fn is_sensitive_key(key: &str) -> bool { + let lower = key.to_lowercase(); + lower.contains("password") + || lower.contains("token") + || lower.contains("secret") + || lower.contains("private_key") + || lower.contains("privatekey") + || lower.contains("api_key") + || lower.contains("apikey") + || lower.contains("client_secret") + || lower.contains("access_key") +} + +/// Recursively masks sensitive fields with "******" in place. +pub fn mask_secrets(value: &mut Value) { + match value { + Value::Object(map) => { + for (k, v) in map.iter_mut() { + if is_sensitive_key(k) { + if let Value::String(s) = v { + if !s.is_empty() { + *v = Value::String("******".to_string()); + } + } + } else { + mask_secrets(v); + } + } + } + Value::Array(arr) => { + for v in arr.iter_mut() { + mask_secrets(v); + } + } + _ => {} + } +} + +/// When updating an entity, if the incoming payload contains the masked placeholder "******", +/// preserve the existing real secret from the stored entity. +pub fn preserve_masked_secrets(incoming: &mut Value, existing: &Value) { + match (incoming, existing) { + (Value::Object(in_map), Value::Object(ex_map)) => { + for (k, in_val) in in_map.iter_mut() { + if is_sensitive_key(k) { + if let Value::String(s) = in_val { + if s == "******" { + if let Some(real_val) = ex_map.get(k) { + *in_val = real_val.clone(); + } + } + } + } else if let Some(ex_sub) = ex_map.get(k) { + preserve_masked_secrets(in_val, ex_sub); + } + } + } + (Value::Array(in_arr), Value::Array(ex_arr)) => { + for (idx, in_item) in in_arr.iter_mut().enumerate() { + if let Some(ex_item) = ex_arr.get(idx) { + preserve_masked_secrets(in_item, ex_item); + } + } + } + _ => {} + } +} + +/// Resolves `${ENV:VAR}` and `${SECRET:key}` expressions in string values. +pub fn interpolate_string(input: &str, kv: Option<&dyn KvStore>) -> String { + let re_env = regex::Regex::new(r"\$\{ENV:([a-zA-Z0-9_]+)\}").unwrap(); + let re_sec = regex::Regex::new(r"\$\{SECRET:([a-zA-Z0-9_\-.]+)\}").unwrap(); + + let mut result = input.to_string(); + + // 1. ${ENV:VAR} + result = re_env + .replace_all(&result, |caps: ®ex::Captures| { + let var_name = &caps[1]; + std::env::var(var_name).unwrap_or_default() + }) + .to_string(); + + // 2. ${SECRET:key} + if let Some(kv_store) = kv { + result = re_sec + .replace_all(&result, |caps: ®ex::Captures| { + let sec_key = &caps[1]; + futures::executor::block_on(async { + if let Ok(Some(val)) = kv_store.get("secrets", sec_key).await { + val + } else if let Ok(Some(val)) = kv_store.get("connections", sec_key).await { + val + } else { + String::new() + } + }) + }) + .to_string(); + } + + result +} + +/// Recursively traverses a JSON value and interpolates environment variables and secrets. +pub fn interpolate_secrets(value: &mut Value, kv: Option<&dyn KvStore>) { + match value { + Value::String(s) => { + if s.contains("${ENV:") || s.contains("${SECRET:") { + *s = interpolate_string(s, kv); + } + } + Value::Object(map) => { + for v in map.values_mut() { + interpolate_secrets(v, kv); + } + } + Value::Array(arr) => { + for v in arr.iter_mut() { + interpolate_secrets(v, kv); + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_secret_masking_and_preservation() { + let original = json!({ + "name": "mqtt_prod", + "server": "tcp://broker:1883", + "password": "super_secret_password_123", + "token": "bearer-abc-xyz", + "nested": { + "private_key": "MIIEvgIBADANBgkqhkiG9w0BAQEFAASC", + "normal": "value" + } + }); + + // 1. Mask secrets + let mut masked = original.clone(); + mask_secrets(&mut masked); + + assert_eq!(masked["password"], "******"); + assert_eq!(masked["token"], "******"); + assert_eq!(masked["nested"]["private_key"], "******"); + assert_eq!(masked["nested"]["normal"], "value"); + assert_eq!(masked["server"], "tcp://broker:1883"); + + // 2. Incoming update sends masked placeholder back + let mut update_payload = json!({ + "name": "mqtt_prod", + "server": "tcp://broker-new:1883", + "password": "******", // unchanged + "token": "new-token-456", // changed + "nested": { + "private_key": "******", // unchanged + "normal": "updated_value" + } + }); + + preserve_masked_secrets(&mut update_payload, &original); + + assert_eq!(update_payload["password"], "super_secret_password_123"); + assert_eq!(update_payload["token"], "new-token-456"); + assert_eq!( + update_payload["nested"]["private_key"], + "MIIEvgIBADANBgkqhkiG9w0BAQEFAASC" + ); + assert_eq!(update_payload["nested"]["normal"], "updated_value"); + assert_eq!(update_payload["server"], "tcp://broker-new:1883"); + } + + #[test] + fn test_env_interpolation() { + std::env::set_var("TEST_DB_PASS", "my_secret_env_pass_999"); + let mut config = json!({ + "url": "http://127.0.0.1:8080", + "password": "${ENV:TEST_DB_PASS}", + "auth": "Bearer ${ENV:TEST_DB_PASS}" + }); + + interpolate_secrets(&mut config, None); + + assert_eq!(config["password"], "my_secret_env_pass_999"); + assert_eq!(config["auth"], "Bearer my_secret_env_pass_999"); + } +} diff --git a/crates/rekuiper-server/src/sink_cache.rs b/crates/rekuiper-server/src/sink_cache.rs index a98d3daf..5da4135c 100644 --- a/crates/rekuiper-server/src/sink_cache.rs +++ b/crates/rekuiper-server/src/sink_cache.rs @@ -427,10 +427,7 @@ mod tests { fn rec(i: u64) -> StreamRecord { let mut data = HashMap::new(); data.insert("seq".to_string(), json!(i)); - StreamRecord { - timestamp: i as i64, - data: data.into(), - } + StreamRecord::with_timestamp(data, i as i64) } fn cfg(memory: usize, disk: usize, page: usize) -> CacheConfig { diff --git a/crates/rekuiper-server/src/state.rs b/crates/rekuiper-server/src/state.rs index febe6044..1d84a0b1 100644 --- a/crates/rekuiper-server/src/state.rs +++ b/crates/rekuiper-server/src/state.rs @@ -1,3 +1,4 @@ +use crate::comms::CommsHub; use axum::{ extract::State, http::{header, StatusCode}, @@ -386,6 +387,9 @@ pub struct AppState { pub declarative_watch: bool, pub active_rule_hashes: Arc>>, pub declarative_reload_failures: Arc, + pub comms_hub: CommsHub, + pub last_watchdog_rollback: Arc>>, + pub fleet: Arc, } pub fn default_import_status() -> Value { @@ -497,6 +501,9 @@ impl AppState { declarative_watch: false, active_rule_hashes: Arc::new(RwLock::new(HashMap::new())), declarative_reload_failures: Arc::new(std::sync::atomic::AtomicU64::new(0)), + comms_hub: CommsHub::default(), + last_watchdog_rollback: Arc::new(RwLock::new(None)), + fleet: Arc::new(crate::fleet::FleetManager::new()), } } } @@ -649,38 +656,95 @@ pub async fn auth_guard( request: axum::http::Request, next: axum::middleware::Next, ) -> Response { - if !state.config.read().basic.authentication { + let (auth_enabled, fleet_token, ui_password) = { + let cfg = state.config.read(); + ( + cfg.basic.authentication, + cfg.basic.fleet_token.clone(), + cfg.basic.ui_password.clone(), + ) + }; + + if !auth_enabled && fleet_token.is_none() && ui_password.is_none() { return next.run(request).await; } + let path = request.uri().path(); - if path == "/" || path == "/ping" { + if path == "/" + || path == "/ping" + || path == "/api/ui/auth-status" + || path == "/api/ui/login" + || path == "/ui" + || path == "/ui/" + { return next.run(request).await; } - let Some(header_val) = request + + let query_token = request.uri().query().and_then(|q| { + q.split('&').find_map(|pair| { + let mut parts = pair.split('='); + if parts.next()? == "token" { + parts.next() + } else { + None + } + }) + }); + + let header_val = request .headers() .get(header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - else { - return (StatusCode::UNAUTHORIZED, "Missing authorization header\n").into_response(); + .and_then(|v| v.to_str().ok()); + + let token_candidate = if let Some(h) = header_val { + if let Some(bearer) = h + .strip_prefix("Bearer ") + .or_else(|| h.strip_prefix("bearer ")) + { + Some(bearer.trim()) + } else { + Some(h.trim()) + } + } else { + query_token }; - if header_val.starts_with("Bearer ") || header_val.starts_with("bearer ") { - return ( - StatusCode::UNAUTHORIZED, - "Bearer token is not supported, please use raw JWT token\n", - ) - .into_response(); - } - let Some(key_der) = load_auth_public_key() else { + + let Some(token) = token_candidate else { return ( StatusCode::UNAUTHORIZED, - "Authentication misconfigured: public key not found\n", + "Missing authorization header or token\n", ) .into_response(); }; - match verify_jwt_raw(header_val.trim(), &key_der) { - Ok(()) => next.run(request).await, - Err(msg) => (StatusCode::UNAUTHORIZED, msg).into_response(), + + // If a UI custom password is configured, check if it matches + if let Some(ref pw) = ui_password { + if token == pw { + return next.run(request).await; + } } + + // If a fleet bearer/provisioning token is configured, check if it matches + if let Some(ref ft) = fleet_token { + if token == ft { + return next.run(request).await; + } + } + + // If JWT authentication is enabled, check the raw JWT + if auth_enabled { + if let Some(key_der) = load_auth_public_key() { + if verify_jwt_raw(token, &key_der).is_ok() { + return next.run(request).await; + } + } + } + + ( + StatusCode::UNAUTHORIZED, + "Unauthorized: invalid authentication token\n", + ) + .into_response() } /// Verify a raw RS256 JWT against a PKCS#1 DER public key: exactly three diff --git a/crates/rekuiper-server/src/ui.html b/crates/rekuiper-server/src/ui.html index 6540bf8a..358328ab 100644 --- a/crates/rekuiper-server/src/ui.html +++ b/crates/rekuiper-server/src/ui.html @@ -669,996 +669,11505 @@ min-height: 140px; resize: vertical; } - - - - -
-
- -
- rekuiper - Edge SQL & IIoT Telemetry Engine -
-
+ /* ========================================================================== + Native Visual Stream Studio Styles (design.md Dark & Industrial Theme) + ========================================================================== */ + .studio-container { + display: flex; + flex-direction: column; + height: calc(100vh - 160px); + min-height: 720px; + background: #080c16; + border: 1px solid #1e293b; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6); + position: relative; + } -
- - - Connected - + .studio-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 16px; + background: #0f172a; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + color: #f8fafc; + z-index: 20; + } - - Mutable Mode - + .studio-brand { + display: flex; + align-items: center; + gap: 10px; + font-weight: 700; + font-size: 13.5px; + } - + .studio-brand .studio-tag { + font-size: 10.5px; + font-weight: 600; + padding: 2px 7px; + background: rgba(14, 165, 233, 0.15); + color: #38bdf8; + border: 1px solid rgba(56, 189, 248, 0.25); + border-radius: 4px; + } - + .studio-pipeline-select { + background: #1e293b; + color: #f8fafc; + border: 1px solid #334155; + padding: 4px 8px; + border-radius: 6px; + font-family: var(--font-mono); + font-size: 12px; + outline: none; + } + .studio-pipeline-select:focus { + border-color: #f97316; + } - -
-
+ .studio-ai-bar { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + max-width: 580px; + background: #141e2e; + border: 1px solid #23344d; + border-radius: 8px; + padding: 4px 10px; + transition: all 0.2s ease; + } - -
+ .studio-ai-bar:focus-within { + border-color: #a855f7; + box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.2); + } - -
-
-
- ACTIVE RULES - 100% -
-
0 / 0
-
- 0 stopped - -
-
+ .studio-ai-input { + flex: 1; + background: transparent; + border: none; + color: #f8fafc; + font-size: 12px; + outline: none; + } -
-
- INGRESS THROUGHPUT - IN -
-
0 rec/s
-
- Total In: - 0 -
-
+ .studio-ai-input::placeholder { + color: #64748b; + } -
-
- EGRESS THROUGHPUT - OUT -
-
0 rec/s
-
- Total Out: - 0 -
-
+ .studio-btn-ai { + background: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%); + color: #ffffff; + border: none; + font-size: 11px; + font-weight: 600; + padding: 4px 10px; + border-radius: 5px; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; + transition: opacity 0.15s ease; + white-space: nowrap; + } -
-
- TOPOLOGY CATALOG -
-
0
-
- 0 Streams · - 0 Tables -
-
+ .studio-btn-ai:hover { + opacity: 0.9; + } -
-
- SINK RETRY CACHE -
-
0
-
- Filtered: 0 -
-
+ .studio-actions { + display: flex; + align-items: center; + gap: 6px; + } -
-
- DLQ & POISON PILLS - Clean -
-
0
-
- Exceptions: 0 -
-
-
+ .studio-body { + display: flex; + flex: 1; + position: relative; + overflow: hidden; + } - - + .palette-search-wrap { + padding: 10px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + display: flex; + flex-direction: column; + gap: 8px; + } - -
-
-
-
- Rule Execution Pipelines - 0 -
-
- Click any rule to inspect SQL, stage counters & Dead-Letter Queue poison pills -
-
+ .palette-search { + width: 100%; + background: #141e2e; + border: 1px solid #23344d; + border-radius: 6px; + color: #f8fafc; + font-size: 11.5px; + padding: 6px 10px; + outline: none; + transition: border-color 0.15s ease; + } -
- - - - - - - - - - - - - - - - - - -
Rule IDStatusRecords InRecords OutFilteredSink CacheDLQ / ErrorsActions
Loading active rules...
-
-
+ .palette-search:focus { + border-color: #38bdf8; + } - -