feat(kafka): support Kafka MQ admin client
This commit is contained in:
1 parent
3e7cf30ce5
commit
7cec4892bf
43 files changed
+4281
-186
No files matched your search
@@ -36,6 +36,8 @@ const MONGODB_PROFILES: &[AgentDriverProfile] = &[AgentDriverProfile {
|
||||
store_visible: false,
|
||||
}];
|
||||
|
||||
const EXTRA_DRIVER_STORE_ENTRIES: &[(&str, &str)] = &[("kafka", "Apache Kafka")];
|
||||
|
||||
const AGENT_CATALOG: &[AgentCatalogEntry] = &[
|
||||
AgentCatalogEntry {
|
||||
db_type: DatabaseType::Dameng,
|
||||
@@ -278,6 +280,9 @@ pub fn entries() -> &'static [AgentCatalogEntry] {
|
||||
}
|
||||
|
||||
pub fn agent_key(db_type: &DatabaseType, driver_profile: Option<&str>) -> Option<&'static str> {
|
||||
if *db_type == DatabaseType::MessageQueue {
|
||||
return (driver_profile == Some("kafka")).then_some("kafka");
|
||||
}
|
||||
let entry = entry_for_db_type(db_type)?;
|
||||
if let Some(driver_profile) = driver_profile {
|
||||
if let Some(profile) = entry.profiles.iter().find(|profile| profile.profile == driver_profile) {
|
||||
@@ -304,10 +309,14 @@ pub fn driver_store_entries() -> impl Iterator<Item = (&'static str, &'static st
|
||||
.map(|profile| (profile.key, profile.label));
|
||||
base.into_iter().chain(profiles)
|
||||
})
|
||||
.chain(EXTRA_DRIVER_STORE_ENTRIES.iter().copied())
|
||||
.filter(move |(key, _)| seen.insert(*key))
|
||||
}
|
||||
|
||||
pub fn label_for_key(agent_key: &str) -> Option<&'static str> {
|
||||
if let Some((_, label)) = EXTRA_DRIVER_STORE_ENTRIES.iter().find(|(key, _)| *key == agent_key) {
|
||||
return Some(label);
|
||||
}
|
||||
for entry in entries() {
|
||||
if entry.key == agent_key {
|
||||
return Some(entry.label);
|
||||
|
||||
@@ -255,17 +255,32 @@ pub fn jre_needs_install(am: &AgentManager, registry: &AgentRegistry, jre_key: &
|
||||
|
||||
pub fn local_agent_jar_candidates(db_type: &str) -> Vec<PathBuf> {
|
||||
let jar_name = format!("dbx-agent-{db_type}.jar");
|
||||
let monorepo_driver =
|
||||
PathBuf::from("agents").join("drivers").join(db_type).join("build").join("libs").join(&jar_name);
|
||||
let monorepo_legacy = PathBuf::from("agents").join(db_type).join("build").join("libs").join(&jar_name);
|
||||
let relative_driver =
|
||||
PathBuf::from("..").join("dbx-agents").join("drivers").join(db_type).join("build").join("libs").join(&jar_name);
|
||||
let nested_driver =
|
||||
PathBuf::from("dbx-agents").join("drivers").join(db_type).join("build").join("libs").join(&jar_name);
|
||||
let relative_legacy =
|
||||
PathBuf::from("..").join("dbx-agents").join(db_type).join("build").join("libs").join(&jar_name);
|
||||
let nested_legacy = PathBuf::from("dbx-agents").join(db_type).join("build").join("libs").join(&jar_name);
|
||||
vec![monorepo_driver, monorepo_legacy, relative_driver, nested_driver, relative_legacy, nested_legacy]
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
for agents_dir in local_agents_dir_candidates() {
|
||||
candidates.push(agent_driver_jar_path(&agents_dir, db_type, &jar_name));
|
||||
candidates.push(agent_legacy_jar_path(&agents_dir, db_type, &jar_name));
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn local_agents_dir_candidates() -> Vec<PathBuf> {
|
||||
let mut candidates = vec![PathBuf::from("agents"), PathBuf::from("..").join("agents")];
|
||||
if let Some(workspace_root) = PathBuf::from(env!("CARGO_MANIFEST_DIR")).parent().and_then(|path| path.parent()) {
|
||||
candidates.push(workspace_root.join("agents"));
|
||||
}
|
||||
candidates.push(PathBuf::from("..").join("dbx-agents"));
|
||||
candidates.push(PathBuf::from("dbx-agents"));
|
||||
candidates
|
||||
}
|
||||
|
||||
fn agent_driver_jar_path(agents_dir: &Path, db_type: &str, jar_name: &str) -> PathBuf {
|
||||
agents_dir.join("drivers").join(db_type).join("build").join("libs").join(jar_name)
|
||||
}
|
||||
|
||||
fn agent_legacy_jar_path(agents_dir: &Path, db_type: &str, jar_name: &str) -> PathBuf {
|
||||
agents_dir.join(db_type).join("build").join("libs").join(jar_name)
|
||||
}
|
||||
|
||||
pub fn find_local_agent_jar(db_type: &str) -> Option<PathBuf> {
|
||||
|
||||
@@ -1018,7 +1018,8 @@ impl AppState {
|
||||
// connectivity via the mq_registry and insert a marker so this
|
||||
// connection_id is recognized as valid.
|
||||
let mqc = self.mq_admin_config_for_connection(connection_id, &config).await?;
|
||||
let adapter = self.mq_registry.build_transient_config(mqc).await?;
|
||||
let kafka_launch = crate::mq::service::resolve_kafka_launch_spec(&mqc, self);
|
||||
let adapter = self.mq_registry.build_transient_config(mqc, kafka_launch).await?;
|
||||
adapter.test_connection().await?;
|
||||
PoolKind::MessageQueue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,865 @@
|
||||
//! Apache Kafka admin adapter. Communicates with a Java agent process
|
||||
//! (`KafkaAgent.java`) via JSON-RPC over stdin/stdout. The Java agent uses
|
||||
//! `kafka-clients` AdminClient for admin operations and KafkaProducer for
|
||||
//! message production.
|
||||
//!
|
||||
//! This adapter follows the same pattern as the ZooKeeper/Etcd agents:
|
||||
//! 1. Spawn a Java agent process via `AgentDriverClient`
|
||||
//! 2. Perform JSON-RPC handshake + connect
|
||||
//! 3. Delegate all `MessageQueueAdmin` trait methods to JSON-RPC calls
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::db::agent_driver::{AgentDriverClient, AgentLaunchSpec};
|
||||
use crate::mq::auth::MqAuth;
|
||||
use crate::mq::config::MqAdminConfig;
|
||||
use crate::mq::port::MessageQueueAdmin;
|
||||
use crate::mq::types::*;
|
||||
|
||||
/// Kafka capabilities — no tenants/namespaces, supports topics, consumer groups,
|
||||
/// ACLs, retention, and message production.
|
||||
const KAFKA_CAPABILITIES: MqCapabilities = MqCapabilities {
|
||||
supports_tenants: false,
|
||||
supports_namespaces: false,
|
||||
supports_partitioned_topics: true,
|
||||
supports_subscriptions: true,
|
||||
supports_create_subscription: false,
|
||||
supports_reset_cursor: true,
|
||||
supports_skip_messages: false,
|
||||
supports_clear_backlog: true,
|
||||
supports_peek_messages: true,
|
||||
supports_expire_messages: false,
|
||||
supports_rate_limits: false,
|
||||
supports_backlog_quota: false,
|
||||
supports_retention: true,
|
||||
supports_permissions: true,
|
||||
supports_geo_replication: false,
|
||||
supports_token_management: false,
|
||||
supports_raw_admin_api: false,
|
||||
supports_send_message: true,
|
||||
};
|
||||
|
||||
pub struct KafkaAdmin {
|
||||
client: Arc<Mutex<AgentDriverClient>>,
|
||||
config: MqAdminConfig,
|
||||
}
|
||||
|
||||
impl KafkaAdmin {
|
||||
/// Spawn the Kafka Java agent, perform handshake, and connect.
|
||||
pub async fn new(cfg: MqAdminConfig, launch: AgentLaunchSpec) -> Result<Self, String> {
|
||||
let mut client = AgentDriverClient::spawn(launch).await?;
|
||||
|
||||
// Handshake
|
||||
let _: serde_json::Value = client.call("handshake", serde_json::json!({})).await?;
|
||||
|
||||
// Build the connection params from MqAdminConfig
|
||||
let conn_params = build_connection_params(&cfg);
|
||||
let connect_params = serde_json::json!({ "connection": conn_params });
|
||||
let _: serde_json::Value = client.call("connect", connect_params).await?;
|
||||
|
||||
log::info!("Kafka admin connected via agent (bootstrap servers: {})", bootstrap_servers(&cfg));
|
||||
|
||||
Ok(Self { client: Arc::new(Mutex::new(client)), config: cfg })
|
||||
}
|
||||
|
||||
/// Send a JSON-RPC call to the Kafka agent and deserialize the result.
|
||||
async fn call<T: DeserializeOwned + Send + 'static>(
|
||||
&self,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
) -> Result<T, String> {
|
||||
let mut client = self.client.lock().await;
|
||||
client.call(method, params).await
|
||||
}
|
||||
|
||||
/// Send a JSON-RPC call that returns `{ok: true}` on success.
|
||||
async fn call_ok(&self, method: &str, params: serde_json::Value) -> Result<(), String> {
|
||||
let _: serde_json::Value = self.call(method, params).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MessageQueueAdmin for KafkaAdmin {
|
||||
fn capabilities(&self) -> MqCapabilities {
|
||||
KAFKA_CAPABILITIES
|
||||
}
|
||||
|
||||
fn system_kind(&self) -> MqSystemKind {
|
||||
MqSystemKind::Kafka
|
||||
}
|
||||
|
||||
async fn test_connection(&self) -> Result<MqClusterInfo, String> {
|
||||
let conn_params = build_connection_params(&self.config);
|
||||
let result: serde_json::Value =
|
||||
self.call("test_connection", serde_json::json!({ "connection": conn_params })).await?;
|
||||
|
||||
let cluster_id = result.get("clusterId").and_then(|v| v.as_str()).map(String::from);
|
||||
let brokers = result.get("brokers").cloned().unwrap_or(serde_json::json!([]));
|
||||
|
||||
// When the broker has no authorizer configured, disable permissions in the UI
|
||||
// so the frontend hides the tab instead of showing raw errors.
|
||||
let acl_enabled = result.get("aclEnabled").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
let mut caps = KAFKA_CAPABILITIES;
|
||||
if !acl_enabled {
|
||||
caps.supports_permissions = false;
|
||||
}
|
||||
|
||||
Ok(MqClusterInfo {
|
||||
system_kind: MqSystemKind::Kafka,
|
||||
server_version: None,
|
||||
resolved_profile: "kafka-agent".to_string(),
|
||||
version_detection: "agent".to_string(),
|
||||
capabilities: caps,
|
||||
extra: serde_json::json!({
|
||||
"clusterId": cluster_id,
|
||||
"brokers": brokers,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Tenants (not supported by Kafka) ----
|
||||
|
||||
async fn list_tenants(&self) -> Result<Vec<TenantInfo>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get_tenant(&self, _name: &str) -> Result<TenantInfo, String> {
|
||||
Err("Kafka does not support tenants".to_string())
|
||||
}
|
||||
|
||||
async fn create_tenant(&self, _name: &str, _cfg: TenantConfig) -> Result<(), String> {
|
||||
Err("Kafka does not support tenants".to_string())
|
||||
}
|
||||
|
||||
async fn update_tenant(&self, _name: &str, _cfg: TenantConfig) -> Result<(), String> {
|
||||
Err("Kafka does not support tenants".to_string())
|
||||
}
|
||||
|
||||
async fn delete_tenant(&self, _name: &str, _force: bool) -> Result<(), String> {
|
||||
Err("Kafka does not support tenants".to_string())
|
||||
}
|
||||
|
||||
// ---- Namespaces (not supported by Kafka) ----
|
||||
|
||||
async fn list_namespaces(&self, _tenant: &str) -> Result<Vec<NamespaceInfo>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn create_namespace(&self, _ns: &NamespaceRef, _cfg: NamespaceConfig) -> Result<(), String> {
|
||||
Err("Kafka does not support namespaces".to_string())
|
||||
}
|
||||
|
||||
async fn delete_namespace(&self, _ns: &NamespaceRef, _force: bool) -> Result<(), String> {
|
||||
Err("Kafka does not support namespaces".to_string())
|
||||
}
|
||||
|
||||
async fn get_namespace_policies(&self, _ns: &NamespaceRef) -> Result<serde_json::Value, String> {
|
||||
Err("Kafka does not support namespaces".to_string())
|
||||
}
|
||||
|
||||
// ---- Topics ----
|
||||
|
||||
async fn list_topics(&self, _ns: &NamespaceRef, _opts: ListTopicsOpts) -> Result<Vec<TopicInfo>, String> {
|
||||
let result: serde_json::Value = self.call("mq_list_topics", serde_json::json!({})).await?;
|
||||
let topics = result.get("topics").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
|
||||
Ok(topics
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
let name = t.get("name").and_then(|v| v.as_str()).unwrap_or_default().to_string();
|
||||
let partitions = t.get("partitions").and_then(|v| v.as_u64()).map(|v| v as u32);
|
||||
TopicInfo {
|
||||
name: name.clone(),
|
||||
short_name: name,
|
||||
partitioned: partitions.map(|p| p > 1).unwrap_or(false),
|
||||
partitions,
|
||||
persistent: true,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn create_topic(&self, topic: &TopicRef, partitions: Option<u32>) -> Result<(), String> {
|
||||
let params = serde_json::json!({
|
||||
"name": topic.topic,
|
||||
"partitions": partitions.unwrap_or(1),
|
||||
"replicationFactor": 1,
|
||||
});
|
||||
self.call_ok("mq_create_topic", params).await
|
||||
}
|
||||
|
||||
async fn delete_topic(&self, topic: &TopicRef, _force: bool) -> Result<(), String> {
|
||||
self.call_ok("mq_delete_topic", serde_json::json!({ "name": topic.topic })).await
|
||||
}
|
||||
|
||||
async fn update_partitions(&self, topic: &TopicRef, partitions: u32) -> Result<(), String> {
|
||||
self.call_ok(
|
||||
"mq_update_partitions",
|
||||
serde_json::json!({
|
||||
"name": topic.topic,
|
||||
"totalPartitions": partitions,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_topic_stats(&self, topic: &TopicRef) -> Result<TopicStats, String> {
|
||||
let result: serde_json::Value =
|
||||
self.call("mq_get_topic_stats", serde_json::json!({ "name": topic.topic })).await?;
|
||||
|
||||
let total_messages = result.get("totalMessages").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let _partitions = result.get("partitions").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||
|
||||
Ok(TopicStats {
|
||||
msg_rate_in: 0.0,
|
||||
msg_rate_out: 0.0,
|
||||
msg_throughput_in: 0.0,
|
||||
msg_throughput_out: 0.0,
|
||||
storage_size: 0,
|
||||
backlog_size: 0,
|
||||
msg_in_counter: total_messages,
|
||||
msg_out_counter: 0,
|
||||
subscription_count: 0,
|
||||
producer_count: 0,
|
||||
raw: result,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_topic_internal_stats(&self, topic: &TopicRef) -> Result<serde_json::Value, String> {
|
||||
self.call("mq_get_topic_config", serde_json::json!({ "name": topic.topic })).await
|
||||
}
|
||||
|
||||
// ---- Subscriptions (mapped to consumer groups) ----
|
||||
|
||||
async fn list_subscriptions(&self, topic: &TopicRef) -> Result<Vec<SubscriptionInfo>, String> {
|
||||
// List consumer groups and filter those subscribed to this topic
|
||||
let result: serde_json::Value = self.call("mq_list_consumer_groups", serde_json::json!({})).await?;
|
||||
let groups = result.get("groups").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
|
||||
// For each group, check both active assignments and committed offsets.
|
||||
let mut subs = Vec::new();
|
||||
for group in groups {
|
||||
let group_id = group.get("groupId").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let desc = match self
|
||||
.call::<serde_json::Value>("mq_describe_consumer_group", serde_json::json!({ "groupId": group_id }))
|
||||
.await
|
||||
{
|
||||
Ok(desc) => desc,
|
||||
Err(_) => continue, // Skip groups we can't describe
|
||||
};
|
||||
let lag = self
|
||||
.call::<serde_json::Value>(
|
||||
"mq_get_consumer_lag",
|
||||
serde_json::json!({
|
||||
"groupId": group_id,
|
||||
"topic": topic.topic,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
if let Some(sub) = kafka_subscription_for_topic(group_id, &topic.topic, &desc, lag.as_ref()) {
|
||||
subs.push(sub);
|
||||
}
|
||||
}
|
||||
Ok(subs)
|
||||
}
|
||||
|
||||
async fn create_subscription(&self, _topic: &TopicRef, _sub: &str, _pos: ResetPosition) -> Result<(), String> {
|
||||
Err("Kafka consumer groups are created automatically when consumers join".to_string())
|
||||
}
|
||||
|
||||
async fn delete_subscription(&self, _topic: &TopicRef, sub: &str, _force: bool) -> Result<(), String> {
|
||||
self.call_ok("mq_delete_consumer_group", serde_json::json!({ "groupId": sub })).await
|
||||
}
|
||||
|
||||
async fn skip_messages(&self, _topic: &TopicRef, _sub: &str, _count: SkipCount) -> Result<(), String> {
|
||||
Err("Kafka does not support skipping messages directly".to_string())
|
||||
}
|
||||
|
||||
async fn reset_cursor(&self, topic: &TopicRef, sub: &str, pos: ResetPosition) -> Result<(), String> {
|
||||
let params = reset_cursor_params(topic, sub, pos)?;
|
||||
self.call_ok("mq_reset_consumer_group_offsets", params).await
|
||||
}
|
||||
|
||||
async fn clear_backlog(&self, topic: &TopicRef, sub: &str) -> Result<(), String> {
|
||||
// Clearing backlog = resetting offsets to latest
|
||||
self.call_ok(
|
||||
"mq_reset_consumer_group_offsets",
|
||||
serde_json::json!({
|
||||
"groupId": sub,
|
||||
"topic": topic.topic,
|
||||
"position": "latest",
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn peek_messages(&self, topic: &TopicRef, _sub: &str, count: u32) -> Result<Vec<PeekedMessage>, String> {
|
||||
let conn_params = build_connection_params(&self.config);
|
||||
let result: serde_json::Value = self
|
||||
.call(
|
||||
"mq_peek_messages",
|
||||
serde_json::json!({
|
||||
"topic": topic.topic,
|
||||
"partition": 0,
|
||||
"offset": 0,
|
||||
"count": count,
|
||||
"connection": conn_params,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let messages = result.get("messages").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
Ok(messages
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, m)| PeekedMessage {
|
||||
position: (idx + 1) as u32,
|
||||
message_id: m.get("offset").and_then(|v| v.as_i64()).map(|v| v.to_string()),
|
||||
key: m.get("key").and_then(|v| v.as_str()).map(String::from),
|
||||
publish_time: m.get("timestamp").and_then(|v| v.as_i64()).map(|v| v.to_string()),
|
||||
event_time: None,
|
||||
properties: HashMap::new(),
|
||||
headers: m
|
||||
.get("headers")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|obj| {
|
||||
obj.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or_default().to_string())).collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
payload_base64: m.get("payloadBase64").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
|
||||
payload_text: m.get("payloadText").and_then(|v| v.as_str()).map(String::from),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn expire_messages(&self, _topic: &TopicRef, _sub: &str, _expire_seconds: i64) -> Result<(), String> {
|
||||
Err("Kafka does not support expiring messages on a subscription".to_string())
|
||||
}
|
||||
|
||||
// ---- Producers / consumers ----
|
||||
|
||||
async fn list_producers(&self, topic: &TopicRef) -> Result<Vec<ProducerInfo>, String> {
|
||||
let result: serde_json::Value =
|
||||
self.call("mq_list_producers", serde_json::json!({ "topic": topic.topic })).await?;
|
||||
let producers = result.get("producers").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
Ok(producers
|
||||
.into_iter()
|
||||
.map(|p| ProducerInfo {
|
||||
producer_id: p.get("producerId").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
producer_name: p.get("producerName").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
|
||||
msg_rate_in: p.get("msgRateIn").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
msg_throughput_in: p.get("msgThroughputIn").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
address: p.get("address").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
|
||||
client_version: p.get("clientVersion").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_consumers(&self, _topic: &TopicRef, sub: &str) -> Result<Vec<ConsumerInfo>, String> {
|
||||
let result: serde_json::Value =
|
||||
self.call("mq_describe_consumer_group", serde_json::json!({ "groupId": sub })).await?;
|
||||
|
||||
let members = result.get("members").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
Ok(members
|
||||
.into_iter()
|
||||
.map(|m| ConsumerInfo {
|
||||
consumer_name: m.get("memberId").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
|
||||
msg_rate_out: 0.0,
|
||||
msg_throughput_out: 0.0,
|
||||
available_permits: 0,
|
||||
address: m.get("host").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
|
||||
client_version: String::new(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn unload_topic(&self, _topic: &TopicRef) -> Result<(), String> {
|
||||
Err("Kafka does not support unloading topics".to_string())
|
||||
}
|
||||
|
||||
// ---- Rate limits / quotas / retention ----
|
||||
|
||||
async fn set_publish_rate(&self, _scope: &PolicyScope, _rate: PublishRate) -> Result<(), String> {
|
||||
Err("Kafka does not support publish rate limits via AdminClient".to_string())
|
||||
}
|
||||
|
||||
async fn set_dispatch_rate(&self, _scope: &PolicyScope, _rate: DispatchRate) -> Result<(), String> {
|
||||
Err("Kafka does not support dispatch rate limits via AdminClient".to_string())
|
||||
}
|
||||
|
||||
async fn set_subscribe_rate(&self, _scope: &PolicyScope, _rate: SubscribeRate) -> Result<(), String> {
|
||||
Err("Kafka does not support subscribe rate limits via AdminClient".to_string())
|
||||
}
|
||||
|
||||
async fn set_backlog_quota(&self, _scope: &PolicyScope, _quota: BacklogQuota) -> Result<(), String> {
|
||||
Err("Kafka does not support backlog quotas via AdminClient".to_string())
|
||||
}
|
||||
|
||||
async fn set_retention(&self, scope: &PolicyScope, retention: RetentionPolicy) -> Result<(), String> {
|
||||
let topic_name = match scope {
|
||||
PolicyScope::Topic { topic, .. } => topic.clone(),
|
||||
PolicyScope::Namespace { .. } => return Err("Kafka retention can only be set on topics".to_string()),
|
||||
};
|
||||
|
||||
let retention_ms = if retention.retention_time_in_minutes < 0 {
|
||||
"-1".to_string()
|
||||
} else {
|
||||
(retention.retention_time_in_minutes as i64 * 60 * 1000).to_string()
|
||||
};
|
||||
|
||||
let mut configs = vec![serde_json::json!({ "key": "retention.ms", "value": retention_ms })];
|
||||
if retention.retention_size_in_mb >= 0 {
|
||||
let retention_bytes = (retention.retention_size_in_mb as i64 * 1024 * 1024).to_string();
|
||||
configs.push(serde_json::json!({ "key": "retention.bytes", "value": retention_bytes }));
|
||||
}
|
||||
|
||||
self.call_ok(
|
||||
"mq_alter_topic_config",
|
||||
serde_json::json!({
|
||||
"name": topic_name,
|
||||
"configs": configs,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_effective_policies(&self, scope: &PolicyScope) -> Result<serde_json::Value, String> {
|
||||
let topic_name = match scope {
|
||||
PolicyScope::Topic { topic, .. } => topic.clone(),
|
||||
PolicyScope::Namespace { .. } => return Err("Kafka does not support namespace policies".to_string()),
|
||||
};
|
||||
self.call("mq_get_topic_config", serde_json::json!({ "name": topic_name })).await
|
||||
}
|
||||
|
||||
// ---- Permissions (mapped to Kafka ACLs) ----
|
||||
|
||||
async fn grant_permission(&self, scope: &PolicyScope, role: &str, actions: Vec<AuthAction>) -> Result<(), String> {
|
||||
let (resource_type, resource_name) = match scope {
|
||||
PolicyScope::Topic { topic, .. } => ("TOPIC", topic.clone()),
|
||||
PolicyScope::Namespace { .. } => ("TOPIC", "*".to_string()),
|
||||
};
|
||||
|
||||
let acls: Vec<serde_json::Value> = actions
|
||||
.into_iter()
|
||||
.map(|action| {
|
||||
let operation = match action {
|
||||
AuthAction::Produce => "WRITE",
|
||||
AuthAction::Consume => "READ",
|
||||
_ => "ALL",
|
||||
};
|
||||
serde_json::json!({
|
||||
"resourceType": resource_type,
|
||||
"resourceName": resource_name,
|
||||
"patternType": "LITERAL",
|
||||
"principal": format!("User:{}", role),
|
||||
"host": "*",
|
||||
"operation": operation,
|
||||
"permissionType": "ALLOW",
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.call_ok("mq_create_acls", serde_json::json!({ "acls": acls })).await
|
||||
}
|
||||
|
||||
async fn revoke_permission(&self, scope: &PolicyScope, role: &str) -> Result<(), String> {
|
||||
let (resource_type, resource_name) = match scope {
|
||||
PolicyScope::Topic { topic, .. } => ("TOPIC", topic.clone()),
|
||||
PolicyScope::Namespace { .. } => ("TOPIC", "*".to_string()),
|
||||
};
|
||||
|
||||
self.call_ok(
|
||||
"mq_delete_acls",
|
||||
serde_json::json!({
|
||||
"filters": [{
|
||||
"resourceType": resource_type,
|
||||
"resourceName": resource_name,
|
||||
"principal": format!("User:{}", role),
|
||||
}]
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_permissions(&self, scope: &PolicyScope) -> Result<PermissionMap, String> {
|
||||
let (resource_type, resource_name) = match scope {
|
||||
PolicyScope::Topic { topic, .. } => ("TOPIC", topic.clone()),
|
||||
PolicyScope::Namespace { .. } => ("TOPIC", "*".to_string()),
|
||||
};
|
||||
|
||||
let result: serde_json::Value = self
|
||||
.call(
|
||||
"mq_list_acls",
|
||||
serde_json::json!({
|
||||
"resourceType": resource_type,
|
||||
"resourceName": resource_name,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let acls = result.get("acls").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
let mut permissions: PermissionMap = HashMap::new();
|
||||
|
||||
for acl in acls {
|
||||
let principal = acl.get("principal").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let role = principal.strip_prefix("User:").unwrap_or(principal).to_string();
|
||||
let operation = acl.get("operation").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let action = match operation {
|
||||
"WRITE" => AuthAction::Produce,
|
||||
"READ" => AuthAction::Consume,
|
||||
_ => continue,
|
||||
};
|
||||
permissions.entry(role).or_default().push(action);
|
||||
}
|
||||
Ok(permissions)
|
||||
}
|
||||
|
||||
// ---- Monitoring ----
|
||||
|
||||
async fn get_backlog(&self, topic: &TopicRef, sub: Option<&str>) -> Result<BacklogStats, String> {
|
||||
let group_id = sub.ok_or("Consumer group name (subscription) is required for Kafka backlog")?;
|
||||
let result: serde_json::Value = self
|
||||
.call(
|
||||
"mq_get_consumer_lag",
|
||||
serde_json::json!({
|
||||
"groupId": group_id,
|
||||
"topic": topic.topic,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let total_lag = result.get("totalLag").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
Ok(BacklogStats { msg_backlog: total_lag, backlog_size: 0 })
|
||||
}
|
||||
|
||||
async fn get_cluster_info(&self) -> Result<ClusterInfo, String> {
|
||||
let result: serde_json::Value = self.call("mq_describe_cluster", serde_json::json!({})).await?;
|
||||
|
||||
let cluster_id = result.get("clusterId").and_then(|v| v.as_str()).map(String::from);
|
||||
let broker_count = result.get("nodeCount").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||
|
||||
let controller = result.get("controller").filter(|v| !v.is_null());
|
||||
let controller_id = controller.and_then(|v| v.get("id")).and_then(|v| v.as_i64()).map(|v| v as i32);
|
||||
let controller_host = controller.and_then(|v| v.get("host")).and_then(|v| v.as_str()).map(|host| {
|
||||
let port = controller.and_then(|v| v.get("port")).and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
format!("{}:{}", host, port)
|
||||
});
|
||||
|
||||
let brokers = result
|
||||
.get("brokers")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|node| {
|
||||
Some(BrokerNode {
|
||||
id: node.get("id")?.as_i64()? as i32,
|
||||
host: node.get("host")?.as_str()?.to_string(),
|
||||
port: node.get("port")?.as_i64()? as i32,
|
||||
rack: node.get("rack").and_then(|v| v.as_str()).map(String::from),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(ClusterInfo { cluster_id, broker_count, controller_id, controller_host, brokers, raw: result })
|
||||
}
|
||||
|
||||
// ---- Raw request (not supported for Kafka) ----
|
||||
|
||||
async fn raw_request(&self, _req: MqRawRequest) -> Result<MqRawResponse, String> {
|
||||
Err("Kafka does not have a REST admin API; raw requests are not supported".to_string())
|
||||
}
|
||||
|
||||
// ---- Message production ----
|
||||
|
||||
async fn send_message(&self, req: SendMessageRequest) -> Result<SendMessageResponse, String> {
|
||||
let params = serde_json::json!({
|
||||
"topic": req.topic,
|
||||
"key": req.key,
|
||||
"payloadBase64": req.payload_base64,
|
||||
"headers": req.headers,
|
||||
"partition": req.partition,
|
||||
});
|
||||
let result: serde_json::Value = self.call("mq_send_message", params).await?;
|
||||
|
||||
Ok(SendMessageResponse {
|
||||
topic: result.get("topic").and_then(|v| v.as_str()).unwrap_or(&req.topic).to_string(),
|
||||
partition: result.get("partition").and_then(|v| v.as_i64()).unwrap_or(0) as i32,
|
||||
offset: result.get("offset").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
timestamp: result.get("timestamp").and_then(|v| v.as_i64()).map(|v| v.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extract Kafka bootstrap servers from MqAdminConfig.extra.
|
||||
fn bootstrap_servers(cfg: &MqAdminConfig) -> String {
|
||||
cfg.extra.get("bootstrapServers").and_then(|v| v.as_str()).unwrap_or("").to_string()
|
||||
}
|
||||
|
||||
fn extra_str<'a>(extra: &'a serde_json::Value, key: &str) -> Option<&'a str> {
|
||||
extra.get(key).and_then(|v| v.as_str()).filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Build the connection params JSON from MqAdminConfig for the Java agent.
|
||||
fn build_connection_params(cfg: &MqAdminConfig) -> serde_json::Value {
|
||||
let extra = &cfg.extra;
|
||||
let basic_auth = match &cfg.auth {
|
||||
MqAuth::Basic { username, password } => Some((username.as_str(), password.as_str())),
|
||||
_ => None,
|
||||
};
|
||||
let sasl_username =
|
||||
extra_str(extra, "saslUsername").or_else(|| basic_auth.map(|(username, _)| username)).unwrap_or("");
|
||||
let sasl_password =
|
||||
extra_str(extra, "saslPassword").or_else(|| basic_auth.map(|(_, password)| password)).unwrap_or("");
|
||||
let sasl_mechanism = extra_str(extra, "saslMechanism").unwrap_or(if basic_auth.is_some() { "PLAIN" } else { "" });
|
||||
let security_protocol =
|
||||
extra_str(extra, "securityProtocol").unwrap_or(if !sasl_mechanism.is_empty() { "SASL_PLAINTEXT" } else { "" });
|
||||
let properties =
|
||||
extra.get("properties").filter(|value| value.is_object()).cloned().unwrap_or_else(|| serde_json::json!({}));
|
||||
|
||||
serde_json::json!({
|
||||
"bootstrap_servers": bootstrap_servers(cfg),
|
||||
"security_protocol": security_protocol,
|
||||
"sasl_mechanism": sasl_mechanism,
|
||||
"sasl_username": sasl_username,
|
||||
"sasl_password": sasl_password,
|
||||
"tls_skip_verify": cfg.tls_skip_verify,
|
||||
"tls": {
|
||||
"skip_verify": cfg.tls_skip_verify,
|
||||
},
|
||||
"properties": properties,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset_cursor_params(topic: &TopicRef, sub: &str, pos: ResetPosition) -> Result<serde_json::Value, String> {
|
||||
match pos {
|
||||
ResetPosition::Earliest => Ok(serde_json::json!({
|
||||
"groupId": sub,
|
||||
"topic": topic.topic,
|
||||
"position": "earliest",
|
||||
})),
|
||||
ResetPosition::Latest => Ok(serde_json::json!({
|
||||
"groupId": sub,
|
||||
"topic": topic.topic,
|
||||
"position": "latest",
|
||||
})),
|
||||
ResetPosition::Timestamp { timestamp_ms } => Ok(serde_json::json!({
|
||||
"groupId": sub,
|
||||
"topic": topic.topic,
|
||||
"position": "timestamp",
|
||||
"timestampMs": timestamp_ms,
|
||||
})),
|
||||
ResetPosition::MessageId { .. } => Err("Kafka does not support cursor reset by Pulsar message id".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn kafka_subscription_for_topic(
|
||||
group_id: &str,
|
||||
topic: &str,
|
||||
desc: &serde_json::Value,
|
||||
lag: Option<&serde_json::Value>,
|
||||
) -> Option<SubscriptionInfo> {
|
||||
let has_active_assignment = desc
|
||||
.get("members")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|members| {
|
||||
members.iter().any(|member| {
|
||||
member
|
||||
.get("assignments")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|assignments| {
|
||||
assignments.iter().any(|a| a.get("topic").and_then(|v| v.as_str()) == Some(topic))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let has_committed_offsets = lag
|
||||
.and_then(|v| v.get("partitions"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|partitions| !partitions.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_active_assignment && !has_committed_offsets {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(SubscriptionInfo {
|
||||
name: group_id.to_string(),
|
||||
sub_type: "consumer-group".to_string(),
|
||||
msg_backlog: lag.and_then(|v| v.get("totalLag")).and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
msg_rate_out: 0.0,
|
||||
msg_throughput_out: 0.0,
|
||||
consumers: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mq::auth::MqAuth;
|
||||
use crate::mq::types::MqSystemKind;
|
||||
|
||||
fn kafka_config(extra: serde_json::Value, auth: MqAuth, tls_skip_verify: bool) -> MqAdminConfig {
|
||||
MqAdminConfig {
|
||||
system_kind: MqSystemKind::Kafka,
|
||||
admin_url: String::new(),
|
||||
auth,
|
||||
tls_skip_verify,
|
||||
pinned_version: None,
|
||||
token_signing: None,
|
||||
connect_override: None,
|
||||
extra,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_params_map_basic_auth_to_kafka_sasl_without_plaintext_password_extra() {
|
||||
let cfg = kafka_config(
|
||||
serde_json::json!({
|
||||
"bootstrapServers": "localhost:9092"
|
||||
}),
|
||||
MqAuth::Basic { username: "alice".to_string(), password: "secret".to_string() },
|
||||
false,
|
||||
);
|
||||
|
||||
let params = build_connection_params(&cfg);
|
||||
|
||||
assert_eq!(params.get("bootstrap_servers").and_then(|v| v.as_str()), Some("localhost:9092"));
|
||||
assert_eq!(params.get("security_protocol").and_then(|v| v.as_str()), Some("SASL_PLAINTEXT"));
|
||||
assert_eq!(params.get("sasl_mechanism").and_then(|v| v.as_str()), Some("PLAIN"));
|
||||
assert_eq!(params.get("sasl_username").and_then(|v| v.as_str()), Some("alice"));
|
||||
assert_eq!(params.get("sasl_password").and_then(|v| v.as_str()), Some("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_params_preserve_kafka_security_extra_and_nested_tls() {
|
||||
let cfg = kafka_config(
|
||||
serde_json::json!({
|
||||
"bootstrapServers": "broker:9093",
|
||||
"securityProtocol": "SASL_SSL",
|
||||
"saslMechanism": "SCRAM-SHA-512",
|
||||
"properties": {
|
||||
"client.id": "dbx"
|
||||
}
|
||||
}),
|
||||
MqAuth::Basic { username: "bob".to_string(), password: "pw".to_string() },
|
||||
true,
|
||||
);
|
||||
|
||||
let params = build_connection_params(&cfg);
|
||||
|
||||
assert_eq!(params.get("security_protocol").and_then(|v| v.as_str()), Some("SASL_SSL"));
|
||||
assert_eq!(params.get("sasl_mechanism").and_then(|v| v.as_str()), Some("SCRAM-SHA-512"));
|
||||
assert_eq!(params.pointer("/tls/skip_verify").and_then(|v| v.as_bool()), Some(true));
|
||||
assert_eq!(params.pointer("/properties/client.id").and_then(|v| v.as_str()), Some("dbx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_cursor_params_preserve_timestamp_position() {
|
||||
let topic = TopicRef {
|
||||
tenant: "_kafka".to_string(),
|
||||
namespace: "_kafka".to_string(),
|
||||
topic: "events".to_string(),
|
||||
persistent: true,
|
||||
partitioned: None,
|
||||
};
|
||||
|
||||
let params = reset_cursor_params(&topic, "group-a", ResetPosition::Timestamp { timestamp_ms: 1710000000000 })
|
||||
.expect("timestamp reset should be supported");
|
||||
|
||||
assert_eq!(params.get("groupId").and_then(|v| v.as_str()), Some("group-a"));
|
||||
assert_eq!(params.get("topic").and_then(|v| v.as_str()), Some("events"));
|
||||
assert_eq!(params.get("position").and_then(|v| v.as_str()), Some("timestamp"));
|
||||
assert_eq!(params.get("timestampMs").and_then(|v| v.as_i64()), Some(1710000000000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_cursor_params_reject_message_id_position() {
|
||||
let topic = TopicRef {
|
||||
tenant: "_kafka".to_string(),
|
||||
namespace: "_kafka".to_string(),
|
||||
topic: "events".to_string(),
|
||||
persistent: true,
|
||||
partitioned: None,
|
||||
};
|
||||
|
||||
let err = reset_cursor_params(&topic, "group-a", ResetPosition::MessageId { ledger_id: 1, entry_id: 2 })
|
||||
.expect_err("Kafka should not accept Pulsar message ids");
|
||||
|
||||
assert!(err.contains("message id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kafka_subscription_for_topic_includes_offline_group_with_committed_offsets() {
|
||||
let desc = serde_json::json!({
|
||||
"groupId": "orders-service",
|
||||
"members": []
|
||||
});
|
||||
let lag = serde_json::json!({
|
||||
"totalLag": 7,
|
||||
"partitions": [
|
||||
{ "partition": 0, "currentOffset": 3, "endOffset": 10, "lag": 7 }
|
||||
]
|
||||
});
|
||||
|
||||
let sub = kafka_subscription_for_topic("orders-service", "orders", &desc, Some(&lag))
|
||||
.expect("committed offsets should make an inactive group visible");
|
||||
|
||||
assert_eq!(sub.name, "orders-service");
|
||||
assert_eq!(sub.sub_type, "consumer-group");
|
||||
assert_eq!(sub.msg_backlog, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kafka_subscription_for_topic_includes_active_assignment_without_committed_offsets() {
|
||||
let desc = serde_json::json!({
|
||||
"groupId": "live-service",
|
||||
"members": [{
|
||||
"assignments": [
|
||||
{ "topic": "events", "partition": 0 }
|
||||
]
|
||||
}]
|
||||
});
|
||||
let lag = serde_json::json!({
|
||||
"totalLag": 0,
|
||||
"partitions": []
|
||||
});
|
||||
|
||||
let sub = kafka_subscription_for_topic("live-service", "events", &desc, Some(&lag))
|
||||
.expect("active assignments should make the group visible");
|
||||
|
||||
assert_eq!(sub.name, "live-service");
|
||||
assert_eq!(sub.msg_backlog, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kafka_subscription_for_topic_ignores_unrelated_group() {
|
||||
let desc = serde_json::json!({
|
||||
"groupId": "billing-service",
|
||||
"members": [{
|
||||
"assignments": [
|
||||
{ "topic": "billing", "partition": 0 }
|
||||
]
|
||||
}]
|
||||
});
|
||||
let lag = serde_json::json!({
|
||||
"totalLag": 0,
|
||||
"partitions": []
|
||||
});
|
||||
|
||||
assert!(kafka_subscription_for_topic("billing-service", "orders", &desc, Some(&lag)).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Concrete message queue admin adapters.
|
||||
|
||||
pub mod kafka;
|
||||
pub mod pulsar;
|
||||
pub mod pulsar_version;
|
||||
@@ -139,6 +139,7 @@ impl PulsarApiProfile {
|
||||
// Token signing is NOT a broker REST capability — see chapter 11.
|
||||
supports_token_management: false,
|
||||
supports_raw_admin_api: true,
|
||||
supports_send_message: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,9 @@ impl MqAdminConfig {
|
||||
let mut parsed: MqAdminConfig = serde_json::from_value(raw.clone())
|
||||
.map_err(|e| format!("Failed to parse message queue admin config: {e}"))?;
|
||||
parsed.admin_url = parsed.admin_url.trim().to_string();
|
||||
if parsed.admin_url.is_empty() {
|
||||
// Kafka uses bootstrap servers from `extra` instead of an admin URL,
|
||||
// so allow an empty admin_url for Kafka connections.
|
||||
if parsed.admin_url.is_empty() && parsed.system_kind != MqSystemKind::Kafka {
|
||||
return Err("Message queue admin URL is empty".to_string());
|
||||
}
|
||||
Ok(parsed)
|
||||
@@ -175,6 +177,22 @@ mod tests {
|
||||
assert!(MqAdminConfig::from_connection(&cfg).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_kafka_config_with_empty_admin_url() {
|
||||
let cfg = connection_with_external(serde_json::json!({
|
||||
"systemKind": "kafka",
|
||||
"adminUrl": "",
|
||||
"auth": { "kind": "none" },
|
||||
"extra": {
|
||||
"bootstrapServers": "broker1:9092,broker2:9092"
|
||||
}
|
||||
}));
|
||||
let mqc = MqAdminConfig::from_connection(&cfg).expect("should parse valid Kafka config");
|
||||
assert_eq!(mqc.system_kind, MqSystemKind::Kafka);
|
||||
assert_eq!(mqc.admin_url, "");
|
||||
assert_eq!(mqc.extra.get("bootstrapServers").and_then(|v| v.as_str()), Some("broker1:9092,broker2:9092"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_url_with_endpoint_preserves_scheme_path_and_query() {
|
||||
let rewritten =
|
||||
|
||||
@@ -24,7 +24,9 @@ use std::sync::Arc;
|
||||
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
use crate::db::agent_driver::AgentLaunchSpec;
|
||||
use crate::models::connection::ConnectionConfig;
|
||||
use crate::mq::adapters::kafka::KafkaAdmin;
|
||||
use crate::mq::adapters::pulsar::PulsarAdmin;
|
||||
use crate::mq::config::MqAdminConfig;
|
||||
use crate::mq::port::MessageQueueAdmin;
|
||||
@@ -55,13 +57,14 @@ impl MqAdminRegistry {
|
||||
/// connection's `external_config` if not already present.
|
||||
pub async fn get_or_build(&self, cfg: &ConnectionConfig) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
let mqc = MqAdminConfig::from_connection(cfg)?;
|
||||
self.get_or_build_config(&cfg.id, mqc).await
|
||||
self.get_or_build_config(&cfg.id, mqc, None).await
|
||||
}
|
||||
|
||||
pub async fn get_or_build_config(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
mqc: MqAdminConfig,
|
||||
kafka_launch: Option<AgentLaunchSpec>,
|
||||
) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
// Fast path: return the cached adapter.
|
||||
if let Some(adapter) = self.instances.read().await.get(connection_id) {
|
||||
@@ -81,7 +84,7 @@ impl MqAdminRegistry {
|
||||
return Ok(adapter.clone());
|
||||
}
|
||||
|
||||
let adapter = build_adapter(mqc).await?;
|
||||
let adapter = build_adapter(mqc, kafka_launch).await?;
|
||||
self.instances.write().await.insert(connection_id.to_string(), adapter.clone());
|
||||
Ok(adapter)
|
||||
}
|
||||
@@ -96,21 +99,33 @@ impl MqAdminRegistry {
|
||||
/// where we don't want to retain state.
|
||||
pub async fn build_transient(&self, cfg: &ConnectionConfig) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
let mqc = MqAdminConfig::from_connection(cfg)?;
|
||||
self.build_transient_config(mqc).await
|
||||
self.build_transient_config(mqc, None).await
|
||||
}
|
||||
|
||||
pub async fn build_transient_config(&self, mqc: MqAdminConfig) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
build_adapter(mqc).await
|
||||
pub async fn build_transient_config(
|
||||
&self,
|
||||
mqc: MqAdminConfig,
|
||||
kafka_launch: Option<AgentLaunchSpec>,
|
||||
) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
build_adapter(mqc, kafka_launch).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_adapter(mqc: MqAdminConfig) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
async fn build_adapter(
|
||||
mqc: MqAdminConfig,
|
||||
kafka_launch: Option<AgentLaunchSpec>,
|
||||
) -> Result<Arc<dyn MessageQueueAdmin>, String> {
|
||||
match mqc.system_kind {
|
||||
MqSystemKindInternal::Pulsar => {
|
||||
let adapter = PulsarAdmin::new(mqc).await?;
|
||||
Ok(Arc::new(adapter))
|
||||
}
|
||||
MqSystemKindInternal::Kafka => Err("Kafka admin is not yet implemented".to_string()),
|
||||
MqSystemKindInternal::Kafka => {
|
||||
let launch = kafka_launch
|
||||
.ok_or("Kafka adapter requires an agent launch spec. The Kafka agent driver is not installed or not configured.")?;
|
||||
let adapter = KafkaAdmin::new(mqc, launch).await?;
|
||||
Ok(Arc::new(adapter))
|
||||
}
|
||||
MqSystemKindInternal::RocketMq => Err("RocketMQ admin is not yet implemented".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,20 @@ pub trait MessageQueueAdmin: Send + Sync {
|
||||
// ---- Monitoring ----
|
||||
async fn get_backlog(&self, topic: &TopicRef, sub: Option<&str>) -> Result<BacklogStats, String>;
|
||||
|
||||
/// Cluster-level info for the Broker monitoring panel.
|
||||
async fn get_cluster_info(&self) -> Result<ClusterInfo, String> {
|
||||
Err("Cluster info is not supported by this MQ system".to_string())
|
||||
}
|
||||
|
||||
/// Escape hatch: proxy an arbitrary admin REST call. Covers any endpoint the
|
||||
/// typed methods do not.
|
||||
async fn raw_request(&self, req: MqRawRequest) -> Result<MqRawResponse, String>;
|
||||
|
||||
// ---- Message production ----
|
||||
|
||||
/// Produce a message to a topic. Adapters that do not support message
|
||||
/// production (e.g. admin-only systems) return an error by default.
|
||||
async fn send_message(&self, _req: SendMessageRequest) -> Result<SendMessageResponse, String> {
|
||||
Err("Message production is not supported by this MQ system".to_string())
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//! Mirrors the pattern used by `agent_kv::*_core`.
|
||||
|
||||
use crate::connection::AppState;
|
||||
use crate::db::agent_driver::AgentLaunchSpec;
|
||||
use crate::mq::config::MqAdminConfig;
|
||||
use crate::mq::token::sign_pulsar_token;
|
||||
use crate::mq::types::*;
|
||||
@@ -19,7 +20,8 @@ const MAX_PEEK_MESSAGES: u32 = 100;
|
||||
pub async fn mq_test_connection_core(state: &AppState, conn_id: &str) -> Result<MqClusterInfo, String> {
|
||||
let cfg = state.configs.read().await.get(conn_id).cloned().ok_or("Connection not found")?;
|
||||
let mqc = state.mq_admin_config_for_connection(conn_id, &cfg).await?;
|
||||
let adapter = state.mq_registry.build_transient_config(mqc).await?;
|
||||
let kafka_launch = resolve_kafka_launch_spec(&mqc, state);
|
||||
let adapter = state.mq_registry.build_transient_config(mqc, kafka_launch).await?;
|
||||
adapter.test_connection().await
|
||||
}
|
||||
|
||||
@@ -446,6 +448,11 @@ pub async fn mq_get_backlog_core(
|
||||
adapter.get_backlog(&topic, sub.as_deref()).await
|
||||
}
|
||||
|
||||
pub async fn mq_get_cluster_info_core(state: &AppState, conn_id: &str) -> Result<ClusterInfo, String> {
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.get_cluster_info().await
|
||||
}
|
||||
|
||||
// ---- Raw request (escape hatch) ----
|
||||
|
||||
pub async fn mq_raw_request_core(state: &AppState, conn_id: &str, req: MqRawRequest) -> Result<MqRawResponse, String> {
|
||||
@@ -456,6 +463,19 @@ pub async fn mq_raw_request_core(state: &AppState, conn_id: &str, req: MqRawRequ
|
||||
adapter.raw_request(req).await
|
||||
}
|
||||
|
||||
// ---- Message production ----
|
||||
|
||||
/// Produce a message to a topic through the MQ adapter.
|
||||
pub async fn mq_send_message_core(
|
||||
state: &AppState,
|
||||
conn_id: &str,
|
||||
req: SendMessageRequest,
|
||||
) -> Result<SendMessageResponse, String> {
|
||||
ensure_connection_writable(state, conn_id, "Send message").await?;
|
||||
let adapter = get_adapter(state, conn_id).await?;
|
||||
adapter.send_message(req).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -466,7 +486,29 @@ async fn get_adapter(
|
||||
) -> Result<std::sync::Arc<dyn crate::mq::port::MessageQueueAdmin>, String> {
|
||||
let cfg = state.configs.read().await.get(conn_id).cloned().ok_or("Connection not found")?;
|
||||
let mqc = state.mq_admin_config_for_connection(conn_id, &cfg).await?;
|
||||
state.mq_registry.get_or_build_config(conn_id, mqc).await
|
||||
let kafka_launch = resolve_kafka_launch_spec(&mqc, state);
|
||||
state.mq_registry.get_or_build_config(conn_id, mqc, kafka_launch).await
|
||||
}
|
||||
|
||||
/// Resolve the Kafka agent launch spec if the config targets Kafka.
|
||||
/// Returns `None` for non-Kafka systems so the registry skips agent resolution.
|
||||
pub fn resolve_kafka_launch_spec(mqc: &MqAdminConfig, state: &AppState) -> Option<AgentLaunchSpec> {
|
||||
if mqc.system_kind != MqSystemKind::Kafka {
|
||||
return None;
|
||||
}
|
||||
let agent_state = state.agent_manager.load_state();
|
||||
let jre_key = agent_state
|
||||
.installed_drivers
|
||||
.get("kafka")
|
||||
.map(|driver| driver.jre.as_str())
|
||||
.unwrap_or(crate::agent_manager::DEFAULT_JRE_KEY);
|
||||
match state.agent_manager.resolve_agent_launch_spec(&agent_state, "kafka", jre_key) {
|
||||
Ok(launch) => Some(launch),
|
||||
Err(err) => {
|
||||
log::warn!("Failed to resolve Kafka agent launch spec: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_connection_writable(state: &AppState, conn_id: &str, operation: &str) -> Result<(), String> {
|
||||
|
||||
@@ -49,6 +49,9 @@ pub struct MqCapabilities {
|
||||
pub supports_geo_replication: bool,
|
||||
pub supports_token_management: bool,
|
||||
pub supports_raw_admin_api: bool,
|
||||
/// Whether the adapter supports producing messages to topics.
|
||||
#[serde(default)]
|
||||
pub supports_send_message: bool,
|
||||
}
|
||||
|
||||
impl Default for MqCapabilities {
|
||||
@@ -71,6 +74,7 @@ impl Default for MqCapabilities {
|
||||
supports_geo_replication: false,
|
||||
supports_token_management: false,
|
||||
supports_raw_admin_api: false,
|
||||
supports_send_message: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,6 +324,29 @@ pub struct TopicStats {
|
||||
// Subscription / consumers / producers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Cluster-level information for the Broker monitoring panel.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClusterInfo {
|
||||
pub cluster_id: Option<String>,
|
||||
pub broker_count: u32,
|
||||
pub controller_id: Option<i32>,
|
||||
pub controller_host: Option<String>,
|
||||
pub brokers: Vec<BrokerNode>,
|
||||
/// Free-form extra fields from the adapter.
|
||||
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
|
||||
pub raw: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BrokerNode {
|
||||
pub id: i32,
|
||||
pub host: String,
|
||||
pub port: i32,
|
||||
pub rack: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscriptionInfo {
|
||||
@@ -562,3 +589,48 @@ pub struct MqRawResponse {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Send message (produce)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Request to produce a message to a topic.
|
||||
///
|
||||
/// The payload is always base64-encoded so binary messages are preserved.
|
||||
/// An optional `payload_text` field provides a UTF-8 preview for the UI.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SendMessageRequest {
|
||||
/// Target topic name.
|
||||
pub topic: String,
|
||||
/// Optional message key (used for partitioning in Kafka).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<String>,
|
||||
/// Message payload encoded as base64.
|
||||
pub payload_base64: String,
|
||||
/// Optional UTF-8 text preview of the payload.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub payload_text: Option<String>,
|
||||
/// Optional message headers.
|
||||
#[serde(default)]
|
||||
pub headers: HashMap<String, String>,
|
||||
/// Optional target partition (Kafka). When `None`, the key-based partitioner
|
||||
/// is used.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub partition: Option<i32>,
|
||||
}
|
||||
|
||||
/// Result of a successful message production.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SendMessageResponse {
|
||||
/// Topic the message was written to.
|
||||
pub topic: String,
|
||||
/// Partition the message was written to.
|
||||
pub partition: i32,
|
||||
/// Offset of the produced message.
|
||||
pub offset: i64,
|
||||
/// Broker-assigned timestamp, if available.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timestamp: Option<String>,
|
||||
}
|
||||
Reference in new issue
Block a user