* feat(rocketmq): 优化 RocketMQ 消费者组管理和性能监控 - 添加并行订阅组配置收集功能,支持超时控制和并发限制 - 实现消费者滞后监控功能,支持按主题过滤的滞后统计 - 重构消费滞后结果构建逻辑,添加 Broker 客户端映射功能 - 优化 Admin 客户端构建,确保请求超时参数正确传递 - 为 RabbitMQ 面板组件添加 MQ 写入保护确认机制 - 统一 MQ 面板样式表引入,清理重复按钮样式定义 - 增强连接对话框中的生产环境保护逻辑,支持 MQ 类型范围 - 更新测试用例以验证新的消费者滞后计算和客户端映射功能 - 为 RocketMQ 订阅面板添加过滤计数功能和测试覆盖 * feat(rocketmq): 优化 RocketMQ 客户端连接和消费者组管理 - 移除硬编码的 5 秒收集超时,改用连接配置的请求超时 - 添加从第一个可用主节点收集消费者组配置的方法以提高速度 - 重构内部方法访问客户端 API 实现和超时配置 - 在主题过滤模式下保留一个主节点转储以恢复 FIFO 类型准确性 - 改进消费延迟计算,失败时不设置总延迟而是保持未定义 - 为消费者运行信息获取添加 Docker 地址映射支持 - 更新命名服务器探测逻辑以更好地处理高可用配置 - 增加消息队列保活机制的查询超时支持 - 在前端界面中显示部分积压加载失败的错误信息 - 添加卸载主题前的操作确认保护 - 改进空结果集的用户界面提示信息 * fix(rocketmq): 解决消费组延迟统计失败时显示为健康零延迟的问题 - 在 examineConsumeStatsRemapped 中添加成功计数和最后错误记录 - 添加 ensureConsumeStatsProbeSucceeded 方法确保没有主节点响应时抛出异常 - 修改 TopicConsumeDetail 接口使 delay 字段可选以表示探针失败 - 更新 UI 显示逻辑在延迟不可用时显示破折号而不是零 - 添加测试用例验证所有代理失败时的异常处理 - 在 Rust 代码中检查 totalLag 是否存在以避免将缺失值视为零 - 添加消息队列适配器的标识检查防止过期的保活连接被错误清理 * fix(rocketmq): 修复RocketMQ消费组详情加载和统计查询问题 - 为examineConsumeStats调用添加超时参数以支持Docker地址映射 - 更新ensureConsumeStatsProbeSucceeded方法以处理部分失败情况下的空合并结果 - 在消费者组对话框中添加序列号防止陈旧详情/配置响应覆盖新数据 - 修复消费延迟显示逻辑,将null延迟值显示为"-" - 改进错误处理,确保仅在所有代理成功时才接受空偏移表作为有效结果 * fix(rocketmq): 修复消费者组配置收集和消息积压显示问题 - 修改 collectConsumerGroupConfigsForTopicFilter 方法以合并多个主节点的订阅配置 - 优化 Topic-filtered 列表的配置收集逻辑,确保 FIFO 组类型准确性 - 添加 totalLagFailed 标志以正确处理消息积压探测失败情况 - 更新前端组件以在探测失败时显示 "-" 而非 0 - 修复 Docker 环境下多代理地址映射冲突问题 - 添加 RFC1918 私有 IP 验证方法并改进主机可达性判断 - 为积压不可用状态添加数据模型支持 * fix(rocketmq): 修复RocketMQ消费者组配置收集中的FIFO组识别问题 - 重构订阅组配置合并逻辑,确保FIFO组不会被误识别为NORMAL组 - 添加mergeSubscriptionGroupConfigs方法处理重复和冲突的订阅组配置 - 修改collectConsumerGroupConfigsForTopicFilter方法使用新的合并策略 - 添加测试用例验证FIFO组优先级和后续组保持功能 - 修复中间代理返回重复转储时丢失FIFO-only组的问题 * refactor(rocketmq): 优化消费组统计和名称服务器连接逻辑 - 引入 MasterBrokerAddrPlan 类来管理重映射和冲突回退地址 - 将 examineConsumeStatsRemapped 重构为 examineConsumeStatsOnMasters 方法 - 实现对 Docker 环境中重映射地址的更好支持和故障转移处理 - 在连接前预先探测 NameServer 可达性以避免 JVM 启动超时 - 优化消费者组在线成员数显示,未知状态时显示为 '-' - 添加相关单元测试验证地址解析和冲突处理逻辑 * fix(rocketmq): 修复Docker重映射碰撞和消费者组配置问题 - 修复订阅组配置查找逻辑,支持FIFO合并避免顺序消息被隐藏 - 添加超时参数到订阅组查询方法 - 实现Docker重映射碰撞检测和失败关闭机制 - 添加测试验证碰撞空合并失败关闭逻辑 - 修复RocketMQ跟踪详情对话框加载序列控制问题 - 更新消费延迟标签为积压(backlog) - 添加连接缺失和只读写拒绝的国际化消息提示 - 修复CSS样式中按钮主题颜色应用问题 - 优化NameServer探测预算计算逻辑 * fix(rocketmq): 修复消费者组类型分类和成员计数显示问题 - 当订阅配置缺失时返回 UNKNOWN 类型而不是默认 NORMAL - 在删除和更新消费者组时对所有主节点执行操作确保一致性 - 成员计数查询失败时保持字段为空避免显示错误的离线状态 - 添加 UNKNOWN 类型的国际化支持和UI样式适配 - 修改测试用例验证缺失配置时的类型分类逻辑 * style(mq): 移除MQ面板中的主按钮样式覆盖 - 删除了 .mq-btn-primary 和 .btn-primary 的填充颜色样式定义 - 保留了悬停效果样式以维持用户体验一致性 - 更新注释说明MQ Chrome在浅色主题中保持页面表面样式 - 避免与工具栏和表格操作按钮产生视觉冲突 * test(composables): 为 useMqMutationGuard 添加测试并更新实现 - 添加了测试用例验证MQ面板使用的getter连接ID解析 - 将 unref 替换为 toValue 以正确处理getter参数 - 更新类型导入使用 MaybeRefOrGetter 替代 MaybeRef - 添加注释说明使用 toValue 的原因 * fix(rocketmq): 修复消费者连接检查和订阅组变更的容错机制 - 重构 examineConsumerConnectionInfoRemapped 方法以正确处理无主节点响应的情况 - 添加 requireConsumerConnectionProbeResult 方法确保连接探针结果的准确性 - 修改 mutateSubscriptionGroupOnMasters 方法实现全地址覆盖要求防止部分更新 - 新增 ensureSubscriptionGroupMutationSucceeded 和 shouldFailClosedOnCollisionPartialMutation 方法增强订阅组变更的错误处理 - 更新 rocketmq_subscription_from_group 函数默认类型为 UNKNOWN 而非 NORMAL - 添加多个单元测试验证连接探针和订阅组变更的各种异常场景 * test(rocketmq): 为消费者连接探测测试方法添加异常声明 - 在requireConsumerConnectionProbeResultFailsClosedWhenNoMasterAnswers测试方法中添加throws Exception声明 - 确保测试方法能够正确处理可能抛出的异常情况 * fix(i18n): MQ 生产确认 source 本地化并清理 fieldStatus 死键 --------- Co-authored-by: AiLi <aili@aili.com> Co-authored-by: t8y2 <t8y2@users.noreply.github.com> Co-authored-by: skyler <1156263951@qq.com>
1136 lines
37 KiB
Rust
1136 lines
37 KiB
Rust
//! Service-layer functions for message queue admin operations. These `*_core`
|
|
//! functions are shared by both the desktop command layer
|
|
//! (`src-tauri/src/commands/mq_cmd.rs`) and the web route layer
|
|
//! (`crates/dbx-web/src/routes/mq.rs`), keeping the business logic unified.
|
|
//!
|
|
//! 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::*;
|
|
use chrono::{TimeZone, Utc};
|
|
use uuid::Uuid;
|
|
|
|
const MAX_PEEK_MESSAGES: u32 = 100;
|
|
|
|
/// Test connectivity to the message queue admin endpoint. Successful MQ
|
|
/// adapters are cached so agent-backed systems do not cold-start on every
|
|
/// repeated test. Failed builds are not cached by the registry.
|
|
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 agent_launch = resolve_mq_agent_launch_spec(&mqc, state);
|
|
let build = match state.mq_registry.get_or_build_config(conn_id, mqc, agent_launch).await {
|
|
Ok(build) => build,
|
|
Err(err) => {
|
|
state.mq_registry.drop_connection(conn_id).await;
|
|
return Err(err);
|
|
}
|
|
};
|
|
match build.adapter.test_connection().await {
|
|
Ok(info) => Ok(info),
|
|
Err(err) => {
|
|
state.mq_registry.drop_connection(conn_id).await;
|
|
Err(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Tenants ----
|
|
|
|
pub async fn mq_list_tenants_core(state: &AppState, conn_id: &str) -> Result<Vec<TenantInfo>, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.list_tenants().await
|
|
}
|
|
|
|
pub async fn mq_get_tenant_core(state: &AppState, conn_id: &str, name: &str) -> Result<TenantInfo, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.get_tenant(name).await
|
|
}
|
|
|
|
pub async fn mq_create_tenant_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
name: &str,
|
|
cfg: TenantConfig,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Create tenant").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.create_tenant(name, cfg).await
|
|
}
|
|
|
|
pub async fn mq_update_tenant_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
name: &str,
|
|
cfg: TenantConfig,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Update tenant").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.update_tenant(name, cfg).await
|
|
}
|
|
|
|
pub async fn mq_delete_tenant_core(state: &AppState, conn_id: &str, name: &str, force: bool) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Delete tenant").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.delete_tenant(name, force).await
|
|
}
|
|
|
|
// ---- Namespaces ----
|
|
|
|
pub async fn mq_list_namespaces_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
tenant: &str,
|
|
) -> Result<Vec<NamespaceInfo>, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.list_namespaces(tenant).await
|
|
}
|
|
|
|
pub async fn mq_create_namespace_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
ns: NamespaceRef,
|
|
cfg: NamespaceConfig,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Create namespace").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.create_namespace(&ns, cfg).await
|
|
}
|
|
|
|
pub async fn mq_delete_namespace_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
ns: NamespaceRef,
|
|
force: bool,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Delete namespace").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.delete_namespace(&ns, force).await
|
|
}
|
|
|
|
pub async fn mq_get_namespace_policies_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
ns: NamespaceRef,
|
|
) -> Result<serde_json::Value, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.get_namespace_policies(&ns).await
|
|
}
|
|
|
|
// ---- Topics ----
|
|
|
|
pub async fn mq_list_topics_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
ns: NamespaceRef,
|
|
opts: ListTopicsOpts,
|
|
) -> Result<Vec<TopicInfo>, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.list_topics(&ns, opts).await
|
|
}
|
|
|
|
pub async fn mq_create_topic_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
partitions: Option<u32>,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Create topic").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.create_topic(&topic, partitions).await
|
|
}
|
|
|
|
pub async fn mq_delete_topic_core(state: &AppState, conn_id: &str, topic: TopicRef, force: bool) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Delete topic").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.delete_topic(&topic, force).await
|
|
}
|
|
|
|
pub async fn mq_update_partitions_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
partitions: u32,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Update partitions").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.update_partitions(&topic, partitions).await
|
|
}
|
|
|
|
pub async fn mq_get_topic_stats_core(state: &AppState, conn_id: &str, topic: TopicRef) -> Result<TopicStats, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.get_topic_stats(&topic).await
|
|
}
|
|
|
|
pub async fn mq_get_topic_internal_stats_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
) -> Result<serde_json::Value, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.get_topic_internal_stats(&topic).await
|
|
}
|
|
|
|
// ---- Exchanges / bindings (RabbitMQ) ----
|
|
|
|
pub async fn mq_list_exchanges_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
ns: NamespaceRef,
|
|
) -> Result<Vec<MqExchangeInfo>, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.list_exchanges(&ns).await
|
|
}
|
|
|
|
pub async fn mq_create_exchange_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
ns: NamespaceRef,
|
|
name: &str,
|
|
exchange_type: &str,
|
|
durable: bool,
|
|
auto_delete: bool,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Create exchange").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.create_exchange(&ns, name, exchange_type, durable, auto_delete).await
|
|
}
|
|
|
|
pub async fn mq_delete_exchange_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
ns: NamespaceRef,
|
|
name: &str,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Delete exchange").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.delete_exchange(&ns, name).await
|
|
}
|
|
|
|
pub async fn mq_list_bindings_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
ns: NamespaceRef,
|
|
exchange: Option<String>,
|
|
queue: Option<String>,
|
|
) -> Result<Vec<MqBindingInfo>, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.list_bindings(&ns, exchange.as_deref(), queue.as_deref()).await
|
|
}
|
|
|
|
pub async fn mq_bind_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
ns: NamespaceRef,
|
|
binding: MqBindingInfo,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Create binding").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.bind_queue(&ns, &binding).await
|
|
}
|
|
|
|
pub async fn mq_unbind_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
ns: NamespaceRef,
|
|
binding: MqBindingInfo,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Delete binding").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.unbind_queue(&ns, &binding).await
|
|
}
|
|
|
|
// ---- Client connections / channels (RabbitMQ) ----
|
|
|
|
pub async fn mq_list_client_connections_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
ns: NamespaceRef,
|
|
) -> Result<Vec<MqClientConnectionInfo>, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.list_client_connections(&ns).await
|
|
}
|
|
|
|
pub async fn mq_list_client_channels_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
ns: NamespaceRef,
|
|
connection: Option<String>,
|
|
) -> Result<Vec<MqChannelInfo>, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.list_client_channels(&ns, connection).await
|
|
}
|
|
|
|
pub async fn mq_close_client_connection_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
ns: NamespaceRef,
|
|
name: &str,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Close client connection").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.close_client_connection(&ns, name).await
|
|
}
|
|
|
|
// ---- Users & virtual-host permissions (RabbitMQ) ----
|
|
|
|
pub async fn mq_list_users_core(state: &AppState, conn_id: &str) -> Result<Vec<MqUserInfo>, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.list_users().await
|
|
}
|
|
|
|
pub async fn mq_create_user_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
name: &str,
|
|
password: &str,
|
|
tags: Vec<String>,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Create user").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.create_user(name, password, tags).await
|
|
}
|
|
|
|
pub async fn mq_delete_user_core(state: &AppState, conn_id: &str, name: &str) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Delete user").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.delete_user(name).await
|
|
}
|
|
|
|
pub async fn mq_list_user_permissions_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
ns: NamespaceRef,
|
|
) -> Result<Vec<MqVhostPermission>, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.list_user_permissions(&ns).await
|
|
}
|
|
|
|
pub async fn mq_grant_user_permission_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
ns: NamespaceRef,
|
|
user: &str,
|
|
configure: &str,
|
|
write: &str,
|
|
read: &str,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Grant user permission").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.grant_user_permission(&ns, user, configure, write, read).await
|
|
}
|
|
|
|
pub async fn mq_revoke_user_permission_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
ns: NamespaceRef,
|
|
user: &str,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Revoke user permission").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.revoke_user_permission(&ns, user).await
|
|
}
|
|
|
|
// ---- Policies (RabbitMQ) ----
|
|
|
|
pub async fn mq_list_policies_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
ns: NamespaceRef,
|
|
) -> Result<Vec<MqPolicyInfo>, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.list_policies(&ns).await
|
|
}
|
|
|
|
pub async fn mq_set_policy_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
ns: NamespaceRef,
|
|
policy: MqPolicyInfo,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Set policy").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.set_policy(&ns, &policy).await
|
|
}
|
|
|
|
pub async fn mq_delete_policy_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
ns: NamespaceRef,
|
|
name: &str,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Delete policy").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.delete_policy(&ns, name).await
|
|
}
|
|
|
|
// ---- Cluster monitoring (RabbitMQ) ----
|
|
|
|
pub async fn mq_get_overview_core(state: &AppState, conn_id: &str) -> Result<MqOverviewInfo, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.get_overview().await
|
|
}
|
|
|
|
pub async fn mq_list_nodes_core(state: &AppState, conn_id: &str) -> Result<Vec<MqNodeInfo>, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.list_nodes().await
|
|
}
|
|
|
|
// ---- Subscriptions ----
|
|
|
|
pub async fn mq_list_subscriptions_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
) -> Result<Vec<SubscriptionInfo>, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.list_subscriptions(&topic).await
|
|
}
|
|
|
|
pub async fn mq_enrich_subscriptions_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
) -> Result<Vec<SubscriptionInfo>, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.enrich_subscriptions(&topic).await
|
|
}
|
|
|
|
pub async fn mq_create_subscription_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
sub: String,
|
|
pos: ResetPosition,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Create subscription").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.create_subscription(&topic, &sub, pos).await
|
|
}
|
|
|
|
pub async fn mq_delete_subscription_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
sub: String,
|
|
force: bool,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Delete subscription").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.delete_subscription(&topic, &sub, force).await
|
|
}
|
|
|
|
pub async fn mq_skip_messages_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
sub: String,
|
|
count: SkipCount,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Skip messages").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.skip_messages(&topic, &sub, count).await
|
|
}
|
|
|
|
pub async fn mq_reset_cursor_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
sub: String,
|
|
pos: ResetPosition,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Reset cursor").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.reset_cursor(&topic, &sub, pos).await
|
|
}
|
|
|
|
pub async fn mq_clear_backlog_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
sub: String,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Clear backlog").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.clear_backlog(&topic, &sub).await
|
|
}
|
|
|
|
pub async fn mq_get_consumer_group_config_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
group_id: String,
|
|
) -> Result<serde_json::Value, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.get_consumer_group_config(&group_id).await
|
|
}
|
|
|
|
pub async fn mq_alter_consumer_group_config_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
group_id: String,
|
|
config: serde_json::Value,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Alter consumer group config").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.alter_consumer_group_config(&group_id, config).await
|
|
}
|
|
|
|
pub async fn mq_peek_messages_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
sub: String,
|
|
count: u32,
|
|
options: Option<PeekMessagesOptions>,
|
|
) -> Result<PeekMessagesResult, String> {
|
|
if count == 0 {
|
|
return Ok(PeekMessagesResult::default());
|
|
}
|
|
if count > MAX_PEEK_MESSAGES {
|
|
return Err(format!("Peek message count must be between 1 and {MAX_PEEK_MESSAGES}"));
|
|
}
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.peek_messages(&topic, &sub, count, options.unwrap_or_default()).await
|
|
}
|
|
|
|
pub async fn mq_expire_messages_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
sub: String,
|
|
expire_seconds: i64,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Expire messages").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.expire_messages(&topic, &sub, expire_seconds).await
|
|
}
|
|
|
|
// ---- Producers / consumers ----
|
|
|
|
pub async fn mq_list_producers_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
) -> Result<Vec<ProducerInfo>, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.list_producers(&topic).await
|
|
}
|
|
|
|
pub async fn mq_list_consumers_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
sub: String,
|
|
) -> Result<Vec<ConsumerInfo>, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.list_consumers(&topic, &sub).await
|
|
}
|
|
|
|
pub async fn mq_unload_topic_core(state: &AppState, conn_id: &str, topic: TopicRef) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Unload topic").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.unload_topic(&topic).await
|
|
}
|
|
|
|
// ---- Rate limits / quotas / retention ----
|
|
|
|
pub async fn mq_set_publish_rate_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
scope: PolicyScope,
|
|
rate: PublishRate,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Set publish rate").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.set_publish_rate(&scope, rate).await
|
|
}
|
|
|
|
pub async fn mq_set_dispatch_rate_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
scope: PolicyScope,
|
|
rate: DispatchRate,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Set dispatch rate").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.set_dispatch_rate(&scope, rate).await
|
|
}
|
|
|
|
pub async fn mq_set_subscribe_rate_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
scope: PolicyScope,
|
|
rate: SubscribeRate,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Set subscribe rate").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.set_subscribe_rate(&scope, rate).await
|
|
}
|
|
|
|
pub async fn mq_set_backlog_quota_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
scope: PolicyScope,
|
|
quota: BacklogQuota,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Set backlog quota").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.set_backlog_quota(&scope, quota).await
|
|
}
|
|
|
|
pub async fn mq_set_retention_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
scope: PolicyScope,
|
|
retention: RetentionPolicy,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Set retention").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.set_retention(&scope, retention).await
|
|
}
|
|
|
|
pub async fn mq_get_effective_policies_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
scope: PolicyScope,
|
|
) -> Result<serde_json::Value, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.get_effective_policies(&scope).await
|
|
}
|
|
|
|
// ---- Permissions ----
|
|
|
|
pub async fn mq_grant_permission_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
scope: PolicyScope,
|
|
role: String,
|
|
actions: Vec<AuthAction>,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Grant permission").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.grant_permission(&scope, &role, actions).await
|
|
}
|
|
|
|
pub async fn mq_revoke_permission_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
scope: PolicyScope,
|
|
role: String,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Revoke permission").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.revoke_permission(&scope, &role).await
|
|
}
|
|
|
|
pub async fn mq_list_permissions_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
scope: PolicyScope,
|
|
) -> Result<PermissionMap, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.list_permissions(&scope).await
|
|
}
|
|
|
|
// ---- Client tokens ----
|
|
|
|
pub async fn mq_issue_token_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
req: MqTokenIssueRequest,
|
|
) -> Result<MqIssuedToken, String> {
|
|
ensure_connection_writable(state, conn_id, "Issue MQ token").await?;
|
|
let cfg = state.configs.read().await.get(conn_id).cloned().ok_or("Connection not found")?;
|
|
let mqc = MqAdminConfig::from_connection(&cfg)?;
|
|
let signing_config = mqc
|
|
.token_signing
|
|
.as_ref()
|
|
.filter(|config| config.is_configured())
|
|
.ok_or("Token signing is not configured for this MQ connection. Edit the connection and configure Broker Token signing with an HS256 secret or RS256 private key.")?;
|
|
|
|
let now = Utc::now();
|
|
let signed = sign_pulsar_token(signing_config, &req, now.timestamp())?;
|
|
let expires_at =
|
|
signed.expires_at_unix.and_then(|value| Utc.timestamp_opt(value, 0).single()).map(|value| value.to_rfc3339());
|
|
let record = MqTokenRecord {
|
|
id: Uuid::new_v4().to_string(),
|
|
connection_id: conn_id.to_string(),
|
|
subject: req.subject.trim().to_string(),
|
|
algorithm: signing_config.algorithm,
|
|
token_fingerprint: signed.fingerprint,
|
|
scope: req.scope.clone(),
|
|
actions: req.actions.clone(),
|
|
expires_at,
|
|
created_at: now.to_rfc3339(),
|
|
note: req.note.as_deref().unwrap_or_default().trim().to_string(),
|
|
};
|
|
|
|
state.storage.save_mq_token_record(&record).await?;
|
|
Ok(MqIssuedToken { token: signed.token, record })
|
|
}
|
|
|
|
pub async fn mq_list_token_records_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
subject: Option<String>,
|
|
) -> Result<Vec<MqTokenRecord>, String> {
|
|
if !state.configs.read().await.contains_key(conn_id) {
|
|
return Err("Connection not found".to_string());
|
|
}
|
|
let subject = subject.as_deref().map(str::trim).filter(|value| !value.is_empty());
|
|
state.storage.load_mq_token_records(conn_id, subject).await
|
|
}
|
|
|
|
// ---- Monitoring ----
|
|
|
|
pub async fn mq_get_backlog_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
sub: Option<String>,
|
|
) -> Result<BacklogStats, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
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
|
|
}
|
|
|
|
pub async fn mq_get_topic_route_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
) -> Result<serde_json::Value, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.get_topic_route(&topic).await
|
|
}
|
|
|
|
pub async fn mq_alter_topic_config_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
configs: serde_json::Value,
|
|
) -> Result<(), String> {
|
|
ensure_connection_writable(state, conn_id, "Alter topic config").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.alter_topic_config(&topic, configs).await
|
|
}
|
|
|
|
pub async fn mq_skip_topic_accumulation_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
) -> Result<serde_json::Value, String> {
|
|
ensure_connection_writable(state, conn_id, "Skip topic accumulation").await?;
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.skip_topic_accumulation(&topic).await
|
|
}
|
|
|
|
pub async fn mq_view_message_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
msg_id: String,
|
|
) -> Result<serde_json::Value, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.view_message(&topic, &msg_id).await
|
|
}
|
|
|
|
pub async fn mq_query_messages_by_key_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
key: String,
|
|
begin: i64,
|
|
end: i64,
|
|
max_num: u32,
|
|
) -> Result<serde_json::Value, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.query_messages_by_key(&topic, &key, begin, end, max_num).await
|
|
}
|
|
|
|
pub async fn mq_query_messages_by_topic_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
topic: TopicRef,
|
|
begin: i64,
|
|
end: i64,
|
|
max_num: u32,
|
|
) -> Result<serde_json::Value, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.query_messages_by_topic(&topic, begin, end, max_num).await
|
|
}
|
|
|
|
pub async fn mq_query_message_trace_core(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
msg_id: String,
|
|
trace_topic: Option<String>,
|
|
) -> Result<serde_json::Value, String> {
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
adapter.query_message_trace(&msg_id, trace_topic.as_deref()).await
|
|
}
|
|
|
|
// ---- Raw request (escape hatch) ----
|
|
|
|
pub async fn mq_raw_request_core(state: &AppState, conn_id: &str, req: MqRawRequest) -> Result<MqRawResponse, String> {
|
|
if req.is_mutating() {
|
|
ensure_connection_writable(state, conn_id, "MQ admin write").await?;
|
|
}
|
|
let adapter = get_adapter(state, conn_id).await?;
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async fn get_adapter(
|
|
state: &AppState,
|
|
conn_id: &str,
|
|
) -> 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?;
|
|
let agent_launch = resolve_mq_agent_launch_spec(&mqc, state);
|
|
state.mq_registry.get_or_build_config(conn_id, mqc, agent_launch).await.map(|build| build.adapter)
|
|
}
|
|
|
|
/// Resolve the MQ agent launch spec for agent-backed systems (Kafka, RocketMQ, RabbitMQ).
|
|
/// Returns `None` for native REST systems so the registry skips agent resolution.
|
|
pub fn resolve_mq_agent_launch_spec(mqc: &MqAdminConfig, state: &AppState) -> Option<AgentLaunchSpec> {
|
|
let agent_key = match mqc.system_kind {
|
|
MqSystemKind::Kafka => "kafka",
|
|
MqSystemKind::RocketMq => "rocketmq",
|
|
MqSystemKind::RabbitMq => "rabbitmq",
|
|
_ => return None,
|
|
};
|
|
let agent_state = state.agent_manager.load_state();
|
|
let jre_key = agent_state
|
|
.installed_drivers
|
|
.get(agent_key)
|
|
.map(|driver| driver.jre.as_str())
|
|
.unwrap_or(crate::agent_manager::DEFAULT_JRE_KEY);
|
|
match state.agent_manager.resolve_agent_launch_spec(&agent_state, agent_key, jre_key) {
|
|
Ok(launch) => Some(launch),
|
|
Err(err) => {
|
|
log::warn!("Failed to resolve {agent_key} agent launch spec: {err}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
#[deprecated(note = "use resolve_mq_agent_launch_spec")]
|
|
pub fn resolve_kafka_launch_spec(mqc: &MqAdminConfig, state: &AppState) -> Option<AgentLaunchSpec> {
|
|
resolve_mq_agent_launch_spec(mqc, state)
|
|
}
|
|
|
|
async fn ensure_connection_writable(state: &AppState, conn_id: &str, operation: &str) -> Result<(), String> {
|
|
let configs = state.configs.read().await;
|
|
if let Some(config) = configs.get(conn_id) {
|
|
if config.read_only {
|
|
return Err(format!(
|
|
"Read-only mode: connection '{}' has read-only protection enabled. {} blocked.",
|
|
config.name, operation
|
|
));
|
|
}
|
|
// Production protection for desktop MQ uses UI confirmation; MCP enforces
|
|
// is_production separately. Do not hard-block confirmed desktop writes here.
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::models::connection::{ConnectionConfig, DatabaseType};
|
|
use crate::storage::Storage;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
fn mq_connection(read_only: bool) -> ConnectionConfig {
|
|
ConnectionConfig {
|
|
id: "readonly-mq".to_string(),
|
|
name: "Read only MQ".to_string(),
|
|
note: String::new(),
|
|
db_type: DatabaseType::MessageQueue,
|
|
driver_profile: Some("pulsar".to_string()),
|
|
driver_label: Some("Apache Pulsar".to_string()),
|
|
url_params: None,
|
|
agent_java_options: Vec::new(),
|
|
host: "127.0.0.1".to_string(),
|
|
port: 8080,
|
|
username: String::new(),
|
|
password: String::new(),
|
|
database: None,
|
|
visible_databases: None,
|
|
visible_schemas: None,
|
|
show_system_schemas: false,
|
|
attached_databases: Vec::new(),
|
|
init_script: None,
|
|
color: None,
|
|
transport_layers: Vec::new(),
|
|
connect_timeout_secs: 5,
|
|
query_timeout_secs: 30,
|
|
idle_timeout_secs: 60,
|
|
keepalive_interval_secs: crate::models::connection::default_keepalive_interval_secs(),
|
|
ssl: false,
|
|
ca_cert_path: String::new(),
|
|
client_cert_path: String::new(),
|
|
client_key_path: String::new(),
|
|
sysdba: false,
|
|
oracle_connection_type: None,
|
|
connection_string: None,
|
|
redis_connection_mode: None,
|
|
redis_sentinel_master: String::new(),
|
|
redis_sentinel_nodes: String::new(),
|
|
redis_sentinel_username: String::new(),
|
|
redis_sentinel_password: String::new(),
|
|
redis_sentinel_tls: false,
|
|
redis_cluster_nodes: String::new(),
|
|
redis_key_separator: ":".to_string(),
|
|
redis_scan_page_size: None,
|
|
redis_database_aliases: Default::default(),
|
|
etcd_endpoints: String::new(),
|
|
gbase_server: String::new(),
|
|
informix_server: String::new(),
|
|
external_config: Some(serde_json::json!({
|
|
"systemKind": "pulsar",
|
|
"adminUrl": "http://127.0.0.1:8080",
|
|
"auth": { "kind": "none" }
|
|
})),
|
|
jdbc_driver_class: None,
|
|
jdbc_driver_paths: Vec::new(),
|
|
one_time: false,
|
|
read_only,
|
|
is_production: false,
|
|
production_databases: Vec::new(),
|
|
database_info: None,
|
|
}
|
|
}
|
|
|
|
fn mq_connection_with_token_signing() -> ConnectionConfig {
|
|
let mut config = mq_connection(false);
|
|
config.external_config = Some(serde_json::json!({
|
|
"systemKind": "pulsar",
|
|
"adminUrl": "http://127.0.0.1:8080",
|
|
"auth": { "kind": "none" },
|
|
"tokenSigning": {
|
|
"algorithm": "hs256",
|
|
"key": "broker-signing-secret"
|
|
}
|
|
}));
|
|
config
|
|
}
|
|
|
|
async fn test_state_with(config: ConnectionConfig) -> (AppState, std::path::PathBuf) {
|
|
let stamp =
|
|
SystemTime::now().duration_since(UNIX_EPOCH).expect("system time should be after UNIX epoch").as_nanos();
|
|
let dir = std::env::temp_dir().join(format!("dbx-mq-service-test-{stamp}"));
|
|
std::fs::create_dir_all(&dir).expect("failed to create test directory");
|
|
let storage = Storage::open(&dir.join("storage.db")).await.expect("failed to open test storage");
|
|
let state = AppState::new_with_plugin_dir(storage, dir.join("plugins"));
|
|
state.configs.write().await.insert(config.id.clone(), config);
|
|
(state, dir)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mutating_rocketmq_send_blocks_read_only_connections() {
|
|
let (state, _dir) = test_state_with(mq_connection(true)).await;
|
|
let err = mq_send_message_core(
|
|
&state,
|
|
"readonly-mq",
|
|
SendMessageRequest {
|
|
topic: "t".into(),
|
|
key: None,
|
|
payload_base64: "eA==".into(),
|
|
payload_text: Some("x".into()),
|
|
headers: Default::default(),
|
|
partition: None,
|
|
exchange: None,
|
|
routing_key: None,
|
|
namespace: None,
|
|
},
|
|
)
|
|
.await
|
|
.expect_err("read-only must block send");
|
|
assert!(err.contains("Read-only mode"), "{err}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mutating_service_calls_block_read_only_connections_before_adapter_build() {
|
|
let (state, dir) = test_state_with(mq_connection(true)).await;
|
|
let err = mq_create_topic_core(
|
|
&state,
|
|
"readonly-mq",
|
|
TopicRef {
|
|
tenant: "public".to_string(),
|
|
namespace: "default".to_string(),
|
|
topic: "orders".to_string(),
|
|
persistent: true,
|
|
partitioned: None,
|
|
message_type: None,
|
|
..TopicRef::default()
|
|
},
|
|
None,
|
|
)
|
|
.await
|
|
.expect_err("read-only write should fail");
|
|
|
|
assert!(err.contains("Read-only mode"));
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mutating_exchange_calls_block_read_only_connections_before_adapter_build() {
|
|
let (state, dir) = test_state_with(mq_connection(true)).await;
|
|
let ns = NamespaceRef { tenant: "_rabbitmq".to_string(), namespace: "_rabbitmq".to_string() };
|
|
|
|
let err = mq_create_exchange_core(&state, "readonly-mq", ns, "dbx-events", "topic", true, false)
|
|
.await
|
|
.expect_err("read-only exchange create should fail");
|
|
|
|
assert!(err.contains("Read-only mode"));
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mutating_raw_requests_block_read_only_connections_before_adapter_build() {
|
|
let (state, dir) = test_state_with(mq_connection(true)).await;
|
|
let err = mq_raw_request_core(
|
|
&state,
|
|
"readonly-mq",
|
|
MqRawRequest {
|
|
method: "POST".to_string(),
|
|
path: "/admin/v2/tenants".to_string(),
|
|
query: None,
|
|
body: Some(serde_json::json!({ "adminRoles": [] })),
|
|
},
|
|
)
|
|
.await
|
|
.expect_err("read-only raw write should fail");
|
|
|
|
assert!(err.contains("Read-only mode"));
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mutating_user_permission_calls_block_read_only_connections_before_adapter_build() {
|
|
let (state, dir) = test_state_with(mq_connection(true)).await;
|
|
let ns = NamespaceRef { tenant: "_rabbitmq".to_string(), namespace: "orders".to_string() };
|
|
|
|
let err = mq_create_user_core(&state, "readonly-mq", "dbx-app", "secret", vec![])
|
|
.await
|
|
.expect_err("read-only user create should fail");
|
|
assert!(err.contains("Read-only mode"));
|
|
|
|
let err =
|
|
mq_delete_user_core(&state, "readonly-mq", "dbx-app").await.expect_err("read-only user delete should fail");
|
|
assert!(err.contains("Read-only mode"));
|
|
|
|
let err = mq_grant_user_permission_core(&state, "readonly-mq", ns.clone(), "dbx-app", ".*", ".*", ".*")
|
|
.await
|
|
.expect_err("read-only permission grant should fail");
|
|
assert!(err.contains("Read-only mode"));
|
|
|
|
let err = mq_revoke_user_permission_core(&state, "readonly-mq", ns, "dbx-app")
|
|
.await
|
|
.expect_err("read-only permission revoke should fail");
|
|
assert!(err.contains("Read-only mode"));
|
|
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mutating_policy_calls_block_read_only_connections_before_adapter_build() {
|
|
let (state, dir) = test_state_with(mq_connection(true)).await;
|
|
let ns = NamespaceRef { tenant: "_rabbitmq".to_string(), namespace: "orders".to_string() };
|
|
let policy =
|
|
MqPolicyInfo { name: "dbx-ttl".to_string(), pattern: "^dbx-".to_string(), ..MqPolicyInfo::default() };
|
|
|
|
let err = mq_set_policy_core(&state, "readonly-mq", ns.clone(), policy)
|
|
.await
|
|
.expect_err("read-only policy set should fail");
|
|
assert!(err.contains("Read-only mode"));
|
|
|
|
let err = mq_delete_policy_core(&state, "readonly-mq", ns, "dbx-ttl")
|
|
.await
|
|
.expect_err("read-only policy delete should fail");
|
|
assert!(err.contains("Read-only mode"));
|
|
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn peek_messages_rejects_counts_above_service_limit_before_adapter_call() {
|
|
let (state, dir) = test_state_with(mq_connection(false)).await;
|
|
let err = mq_peek_messages_core(
|
|
&state,
|
|
"readonly-mq",
|
|
TopicRef {
|
|
tenant: "public".to_string(),
|
|
namespace: "default".to_string(),
|
|
topic: "orders".to_string(),
|
|
persistent: true,
|
|
partitioned: None,
|
|
message_type: None,
|
|
..TopicRef::default()
|
|
},
|
|
"sub-a".to_string(),
|
|
101,
|
|
None,
|
|
)
|
|
.await
|
|
.expect_err("peek count above the service limit should fail");
|
|
|
|
assert!(err.contains("between 1 and 100"));
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn issue_token_saves_record_without_plaintext() {
|
|
let (state, dir) = test_state_with(mq_connection_with_token_signing()).await;
|
|
|
|
let issued = mq_issue_token_core(
|
|
&state,
|
|
"readonly-mq",
|
|
MqTokenIssueRequest {
|
|
subject: "rt-erp-server".to_string(),
|
|
expires_in_seconds: Some(3600),
|
|
scope: None,
|
|
actions: vec![AuthAction::Consume],
|
|
note: Some("integration test".to_string()),
|
|
},
|
|
)
|
|
.await
|
|
.expect("token issuance should succeed");
|
|
|
|
assert!(!issued.token.is_empty());
|
|
assert_eq!(issued.record.subject, "rt-erp-server");
|
|
let records = mq_list_token_records_core(&state, "readonly-mq", Some("rt-erp-server".to_string()))
|
|
.await
|
|
.expect("listing token records should succeed");
|
|
assert_eq!(records.len(), 1);
|
|
assert_eq!(records[0], issued.record);
|
|
let record_json = serde_json::to_value(&records[0]).expect("record should serialize to JSON");
|
|
assert!(record_json.get("token").is_none());
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
}
|