diff --git a/agents/README.md b/agents/README.md index b3a4d2f08..88c71db4b 100644 --- a/agents/README.md +++ b/agents/README.md @@ -43,6 +43,7 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo | iotdb | Apache IoTDB | IoTDB JDBC | | etcd | etcd | jetcd | | zookeeper | Apache ZooKeeper | Apache Curator | +| rabbitmq | RabbitMQ | RabbitMQ AMQP Java client | ## Multi-JRE Support diff --git a/agents/README.zh-CN.md b/agents/README.zh-CN.md index 5ab1e94e7..9c669c7c3 100644 --- a/agents/README.zh-CN.md +++ b/agents/README.zh-CN.md @@ -43,6 +43,7 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数 | iotdb | Apache IoTDB | IoTDB JDBC | | etcd | etcd | jetcd | | zookeeper | Apache ZooKeeper | Apache Curator | +| rabbitmq | RabbitMQ | RabbitMQ AMQP Java client | ## 多 JRE 支持 diff --git a/agents/build.gradle b/agents/build.gradle index ea12b6b7c..39ef46f48 100644 --- a/agents/build.gradle +++ b/agents/build.gradle @@ -4,7 +4,7 @@ plugins { def java8Projects = ['common', 'test-support'] as Set def infrastructureProjects = ['common', 'test-support'] as Set -def legacyStandaloneProjects = ['mongodb', 'kafka', 'rocketmq'] as Set +def legacyStandaloneProjects = ['mongodb', 'kafka', 'rocketmq', 'rabbitmq'] as Set def agentProjects = subprojects.findAll { !infrastructureProjects.contains(it.name) } def jdbcAgentProjects = agentProjects.findAll { !legacyStandaloneProjects.contains(it.name) } diff --git a/agents/drivers/rabbitmq/build.gradle b/agents/drivers/rabbitmq/build.gradle new file mode 100644 index 000000000..b1a36133a --- /dev/null +++ b/agents/drivers/rabbitmq/build.gradle @@ -0,0 +1,12 @@ +dependencies { + implementation 'com.google.code.gson:gson:2.12.1' + implementation 'com.rabbitmq:amqp-client:5.21.0' + runtimeOnly 'org.slf4j:slf4j-simple:1.7.36' +} + +tasks.named('shadowJar') { + mergeServiceFiles() + manifest { + attributes('Agent-Label': 'RabbitMQ', 'Main-Class': 'com.dbx.agent.rabbitmq.RabbitMqAgent') + } +} diff --git a/agents/drivers/rabbitmq/src/main/java/com/dbx/agent/rabbitmq/RabbitMqAgent.java b/agents/drivers/rabbitmq/src/main/java/com/dbx/agent/rabbitmq/RabbitMqAgent.java new file mode 100644 index 000000000..8f71465e5 --- /dev/null +++ b/agents/drivers/rabbitmq/src/main/java/com/dbx/agent/rabbitmq/RabbitMqAgent.java @@ -0,0 +1,2236 @@ +package com.dbx.agent.rabbitmq; + +import com.google.gson.*; +import com.rabbitmq.client.AMQP; +import com.rabbitmq.client.Address; +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.Connection; +import com.rabbitmq.client.ConnectionFactory; +import com.rabbitmq.client.GetResponse; +import com.rabbitmq.client.ShutdownSignalException; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.PrintStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * RabbitMQ admin agent for DBX. Communicates with the Rust bridge via JSON-RPC + * over stdin/stdout. Uses the RabbitMQ AMQP Java client for queue operations + * and the HTTP management API (when available) for queue listing. + */ +public final class RabbitMqAgent { + + private static final Gson GSON = new GsonBuilder().serializeNulls().create(); + private static final int DEFAULT_PORT = 5672; + private static final int DEFAULT_REQUEST_TIMEOUT_MS = 30_000; + private static final int DEFAULT_MANAGEMENT_PORT = 15672; + private static final int DEFAULT_MANAGEMENT_TLS_PORT = 15671; + private static final int MAX_PEEK_MESSAGES = 10_000; + + private static final List CAPABILITIES = Collections.unmodifiableList(Arrays.asList( + "mq_connect", "mq_test_connection", "mq_topics", + "mq_messages", "mq_config", "mq_monitoring", "mq_exchanges", + "mq_client_connections", "mq_user_permissions", "mq_policies" + )); + + private static Connection connection; + private static Channel channel; + private static JsonObject cachedConnection; + // Lazily created AMQP clients for virtual hosts other than the connection's + // default vhost; AMQP connections are scoped to a single vhost, so each + // extra vhost needs its own connection/channel pair. + private static final Map vhostClients = new HashMap<>(); + private static volatile boolean shutdownRequested; + + private RabbitMqAgent() {} + + // ----------------------------------------------------------------------- + // Entry point + // ----------------------------------------------------------------------- + + public static void main(String[] args) throws Exception { + System.setProperty("org.slf4j.simpleLogger.logFile", "System.err"); + // The JSON-RPC pipe with the Rust bridge is UTF-8; relying on the + // platform default charset mangles non-ASCII payloads on Windows. + PrintStream out = new PrintStream(System.out, true, StandardCharsets.UTF_8); + out.println("{\"ready\":true}"); + out.flush(); + + BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)); + while (true) { + String line = reader.readLine(); + if (line == null) break; + String response = handleRequest(line); + out.println(response); + out.flush(); + if (shutdownRequested) { + System.exit(0); + } + } + } + + // ----------------------------------------------------------------------- + // JSON-RPC dispatch + // ----------------------------------------------------------------------- + + static String handleRequest(String line) { + JsonObject response = new JsonObject(); + response.addProperty("jsonrpc", "2.0"); + try { + // Parse and extract inside the try: a malformed request must yield + // a JSON-RPC error (with a null id), never kill the agent process. + JsonObject req = JsonParser.parseString(line).getAsJsonObject(); + JsonElement id = req.get("id"); + response.add("id", id != null ? id : JsonNull.INSTANCE); + String method = req.get("method").getAsString(); + JsonObject params = req.has("params") && req.get("params").isJsonObject() + ? req.getAsJsonObject("params") : new JsonObject(); + + Object result = dispatch(method, params); + response.add("result", GSON.toJsonTree(result)); + } catch (Exception e) { + if (!response.has("id")) { + response.add("id", JsonNull.INSTANCE); + } + JsonObject error = new JsonObject(); + error.addProperty("code", -1); + error.addProperty("message", normalizeErrorMessage(e)); + response.add("error", error); + } + return GSON.toJson(response); + } + + /** + * Operations that act on a single vhost-scoped resource. The {@code all_vhosts} + * sentinel only makes sense for cluster-wide listings; for these methods it + * must fail fast instead of silently falling back to the default vhost. + */ + private static final Set ALL_VHOSTS_UNSUPPORTED_METHODS = Set.of( + "mq_create_topic", "mq_delete_topic", "mq_purge_queue", "mq_send_message", + "mq_bind", "mq_unbind", "mq_create_exchange", "mq_delete_exchange", + "mq_peek_messages", "mq_get_topic_stats", "mq_list_consumers", "mq_close_connection", + "mq_grant_permission", "mq_revoke_permission", "mq_set_policy", "mq_delete_policy"); + + private static Object dispatch(String method, JsonObject params) throws Exception { + if (ALL_VHOSTS_UNSUPPORTED_METHODS.contains(method) && allVhostsRequested(params)) { + throw new IllegalArgumentException("all_vhosts is only supported for list operations"); + } + return switch (method) { + case "handshake" -> handshakeResult(); + case "connect" -> connect(params); + case "test_connection" -> testConnection(params); + case "disconnect" -> { closeClients(); yield Collections.singletonMap("ok", true); } + case "shutdown" -> { closeClients(); shutdownRequested = true; yield Collections.singletonMap("ok", true); } + // Topic (queue) management + case "mq_list_topics" -> listTopics(params); + case "mq_create_topic" -> createTopic(params); + case "mq_delete_topic" -> deleteTopic(params); + case "mq_get_topic_stats" -> getTopicStats(params); + case "mq_get_topic_config" -> getTopicConfig(params); + case "mq_alter_topic_config" -> alterTopicConfig(params); + case "mq_purge_queue" -> purgeQueue(params); + case "mq_list_consumers" -> listConsumers(params); + // Namespaces (virtual hosts) + case "mq_list_namespaces" -> listNamespaces(params); + case "mq_create_namespace" -> createNamespace(params); + case "mq_delete_namespace" -> deleteNamespace(params); + // Exchanges & bindings + case "mq_list_exchanges" -> listExchanges(params); + case "mq_create_exchange" -> createExchange(params); + case "mq_delete_exchange" -> deleteExchange(params); + case "mq_list_bindings" -> listBindings(params); + case "mq_bind" -> bind(params); + case "mq_unbind" -> unbind(params); + // Client connections & channels + case "mq_list_connections" -> listClientConnections(params); + case "mq_list_channels" -> listClientChannels(params); + case "mq_close_connection" -> closeClientConnection(params); + // Users & permissions + case "mq_list_users" -> listUsers(params); + case "mq_create_user" -> createUser(params); + case "mq_delete_user" -> deleteUser(params); + case "mq_list_permissions" -> listPermissions(params); + case "mq_grant_permission" -> grantPermission(params); + case "mq_revoke_permission" -> revokePermission(params); + // Policies + case "mq_list_policies" -> listPolicies(params); + case "mq_set_policy" -> setPolicy(params); + case "mq_delete_policy" -> deletePolicy(params); + // Messages + case "mq_peek_messages" -> peekMessages(params); + case "mq_send_message" -> sendMessage(params); + // Cluster / monitoring + case "mq_describe_cluster" -> describeCluster(params); + case "mq_overview" -> getOverview(params); + case "mq_list_nodes" -> listNodes(params); + default -> throw new IllegalArgumentException("Unknown method: " + method); + }; + } + + // ----------------------------------------------------------------------- + // Lifecycle + // ----------------------------------------------------------------------- + + private static Object handshakeResult() { + return new HandshakeResult(1, 1, CAPABILITIES); + } + + private static Object connect(JsonObject params) throws Exception { + JsonObject conn = connectionObject(params); + Connection nextConnection = null; + Channel nextChannel = null; + try { + nextConnection = openConnection(conn); + nextChannel = nextConnection.createChannel(); + closeClients(); + connection = nextConnection; + channel = nextChannel; + cachedConnection = conn.deepCopy(); + return Collections.singletonMap("ok", true); + } catch (Exception e) { + closeQuietly(nextChannel); + closeQuietly(nextConnection); + throw e; + } + } + + private static Object testConnection(JsonObject params) throws Exception { + JsonObject conn = connectionObject(params); + Connection probe = null; + try { + probe = openConnection(conn); + Map serverProps = probe.getServerProperties(); + + Map result = new LinkedHashMap<>(); + result.put("ok", true); + result.put("product", serverString(serverProps, "product")); + result.put("version", serverString(serverProps, "version")); + result.put("serverVersion", serverString(serverProps, "version")); + result.put("clusterName", serverString(serverProps, "cluster_name")); + result.put("platform", serverString(serverProps, "platform")); + return result; + } finally { + closeQuietly(probe); + } + } + + private static void closeClients() { + for (VhostClient client : vhostClients.values()) { + client.closeQuietly(); + } + vhostClients.clear(); + closeQuietly(channel); + channel = null; + closeQuietly(connection); + connection = null; + cachedConnection = null; + } + + private static void closeQuietly(Channel ch) { + if (ch != null) { + try { + ch.close(); + } catch (Exception ignored) {} + } + } + + private static void closeQuietly(Connection conn) { + if (conn != null) { + try { + conn.close(); + } catch (Exception ignored) {} + } + } + + // ----------------------------------------------------------------------- + // Client builders + // ----------------------------------------------------------------------- + + private static Connection openConnection(JsonObject conn) throws Exception { + ConnectionFactory factory = buildConnectionFactory(conn); + List
addresses = resolveAddresses(conn); + return factory.newConnection(addresses); + } + + static ConnectionFactory buildConnectionFactory(JsonObject conn) throws Exception { + ConnectionFactory factory = new ConnectionFactory(); + factory.setUsername(credentialOrGuest(conn, "username")); + factory.setPassword(credentialOrGuest(conn, "password")); + factory.setVirtualHost(stringOrDefault(conn, "virtual_host", "/")); + factory.setConnectionTimeout(intOrDefault(conn, "request_timeout_ms", DEFAULT_REQUEST_TIMEOUT_MS)); + applyTlsSettings(conn, factory); + applyExtraProperties(conn, factory); + return factory; + } + + static void applyTlsSettings(JsonObject conn, ConnectionFactory factory) throws Exception { + JsonObject tls = conn.has("tls") && conn.get("tls").isJsonObject() + ? conn.getAsJsonObject("tls") : null; + boolean tlsEnabled = tls != null + || boolOrDefault(conn, "tls_skip_verify", false) + || boolProperty(conn, "ssl") + || boolProperty(conn, "tls"); + if (!tlsEnabled) { + return; + } + boolean skipVerify = tlsSkipVerify(conn); + if (skipVerify) { + factory.useSslProtocol(trustAllSslContext()); + } else { + factory.useSslProtocol(); + factory.enableHostnameVerification(); + } + } + + static void applyExtraProperties(JsonObject conn, ConnectionFactory factory) { + JsonObject properties = conn.has("properties") && conn.get("properties").isJsonObject() + ? conn.getAsJsonObject("properties") : null; + if (properties == null) { + return; + } + Integer heartbeat = integerProperty(properties, "requested_heartbeat"); + if (heartbeat != null) { + factory.setRequestedHeartbeat(heartbeat); + } + Integer connectionTimeout = integerProperty(properties, "connection_timeout_ms"); + if (connectionTimeout != null) { + factory.setConnectionTimeout(connectionTimeout); + } + Integer handshakeTimeout = integerProperty(properties, "handshake_timeout_ms"); + if (handshakeTimeout != null) { + factory.setHandshakeTimeout(handshakeTimeout); + } + Boolean automaticRecovery = booleanProperty(properties, "automatic_recovery"); + if (automaticRecovery != null) { + factory.setAutomaticRecoveryEnabled(automaticRecovery); + } + Boolean topologyRecovery = booleanProperty(properties, "topology_recovery"); + if (topologyRecovery != null) { + factory.setTopologyRecoveryEnabled(topologyRecovery); + } + } + + /** + * Parse the {@code addresses} connection parameter: a comma-separated list of + * {@code host[:port]} entries. Bare hosts fall back to {@code defaultPort} + * (the {@code port} connection parameter, defaulting to 5672). + */ + static List
resolveAddresses(JsonObject conn) { + String addresses = stringOrEmpty(conn, "addresses"); + if (addresses.isBlank()) { + addresses = stringOrEmpty(conn, "host"); + } + if (addresses.isBlank()) { + throw new IllegalArgumentException("addresses is required"); + } + return parseAddresses(addresses, intOrDefault(conn, "port", DEFAULT_PORT)); + } + + static List
parseAddresses(String addresses, int defaultPort) { + List
result = new ArrayList<>(); + for (String part : addresses.split(",")) { + String trimmed = part.trim(); + if (trimmed.isEmpty()) { + continue; + } + int colon = trimmed.lastIndexOf(':'); + if (colon > 0 && colon < trimmed.length() - 1) { + result.add(new Address(trimmed.substring(0, colon), Integer.parseInt(trimmed.substring(colon + 1)))); + } else { + result.add(new Address(trimmed, defaultPort)); + } + } + if (result.isEmpty()) { + throw new IllegalArgumentException("addresses is required"); + } + return result; + } + + /** Whether the connection config asks to skip TLS certificate verification. */ + static boolean tlsSkipVerify(JsonObject conn) { + JsonObject tls = conn.has("tls") && conn.get("tls").isJsonObject() + ? conn.getAsJsonObject("tls") : null; + return boolOrDefault(conn, "tls_skip_verify", false) + || (tls != null && boolOrDefault(tls, "skip_verify", false)); + } + + private static SSLContext trustAllSslContext() throws Exception { + TrustManager[] trustAll = new TrustManager[] { + new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) {} + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) {} + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } + }; + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, trustAll, new SecureRandom()); + return context; + } + + // ----------------------------------------------------------------------- + // Topic (queue) management + // ----------------------------------------------------------------------- + + private static Object listTopics(JsonObject params) throws Exception { + JsonObject conn = requireConnectionConfig(params); + boolean allVhosts = allVhostsRequested(params); + JsonArray queues = managementGetAll(conn, managementListPath(params, conn, "queues")); + + List> topics = new ArrayList<>(); + for (JsonElement element : queues) { + JsonObject queue = element.getAsJsonObject(); + Map topic = new LinkedHashMap<>(); + topic.put("name", stringOrEmpty(queue, "name")); + topic.put("durable", boolOrDefault(queue, "durable", false)); + topic.put("autoDelete", boolOrDefault(queue, "auto_delete", false)); + topic.put("state", stringOrEmpty(queue, "state")); + topic.put("messages", longOrDefault(queue, "messages", 0)); + topic.put("consumers", longOrDefault(queue, "consumers", 0)); + if (allVhosts) { + attachVhost(topic, queue); + } + topics.add(topic); + } + topics.sort(Comparator.comparing(m -> (String) m.get("name"))); + return Collections.singletonMap("topics", topics); + } + + private static Object createTopic(JsonObject params) throws Exception { + Channel ch = channelFor(params); + String name = queueName(params); + boolean durable = boolOrDefault(params, "durable", true); + + Map arguments = new HashMap<>(); + JsonObject configs = params.has("configs") && params.get("configs").isJsonObject() + ? params.getAsJsonObject("configs") : null; + if (configs != null) { + for (Map.Entry entry : configs.entrySet()) { + Object value = argumentValue(entry.getValue()); + if (value != null) { + arguments.put(entry.getKey(), value); + } + } + } + + ch.queueDeclare(name, durable, false, false, arguments); + return Collections.singletonMap("ok", true); + } + + private static Object deleteTopic(JsonObject params) throws Exception { + Channel ch = channelFor(params); + ch.queueDelete(queueName(params)); + return Collections.singletonMap("ok", true); + } + + private static Object getTopicStats(JsonObject params) throws Exception { + String name = queueName(params); + + // Prefer the management API: it is read-only and works for exclusive + // queues, whereas a passive declare on an exclusive queue owned by + // another connection fails with 405 RESOURCE_LOCKED and the broker + // force-closes the channel. + JsonObject conn = currentConnectionConfig(params); + if (conn != null) { + String vhost = effectiveVhost(params, conn); + try { + JsonElement queue = managementGet(conn, + "/api/queues/" + urlEncodeVhost(vhost) + "/" + urlEncodePathSegment(name)); + if (queue.isJsonObject()) { + JsonObject info = queue.getAsJsonObject(); + long messages = longOrDefault(info, "messages", 0); + Map result = new LinkedHashMap<>(); + result.put("name", name); + result.put("messageCount", messages); + result.put("consumerCount", longOrDefault(info, "consumers", 0)); + result.put("totalMessages", messages); + return result; + } + } catch (Exception managementError) { + System.err.println("Management API unavailable for queue stats, " + + "falling back to passive declare: " + managementError.getMessage()); + } + } + + Channel ch = channelFor(params); + AMQP.Queue.DeclareOk declared = ch.queueDeclarePassive(name); + + Map result = new LinkedHashMap<>(); + result.put("name", name); + result.put("messageCount", declared.getMessageCount()); + result.put("consumerCount", declared.getConsumerCount()); + result.put("totalMessages", declared.getMessageCount()); + return result; + } + + private static Object getTopicConfig(JsonObject params) throws Exception { + Channel ch = channelFor(params); + String name = queueName(params); + + Map configs = new LinkedHashMap<>(); + JsonObject conn = currentConnectionConfig(params); + if (conn != null) { + String vhost = effectiveVhost(params, conn); + try { + JsonElement queue = managementGet(conn, + "/api/queues/" + urlEncodeVhost(vhost) + "/" + urlEncodePathSegment(name)); + if (queue.isJsonObject()) { + JsonObject info = queue.getAsJsonObject(); + configs.put("durable", boolOrDefault(info, "durable", false)); + configs.put("auto_delete", boolOrDefault(info, "auto_delete", false)); + configs.put("exclusive", boolOrDefault(info, "exclusive", false)); + if (info.has("arguments") && info.get("arguments").isJsonObject()) { + for (Map.Entry entry : info.getAsJsonObject("arguments").entrySet()) { + configs.put(entry.getKey(), entry.getValue().isJsonNull() + ? null : entry.getValue().getAsString()); + } + } + } + } catch (Exception managementError) { + System.err.println("Management API unavailable for queue config: " + managementError.getMessage()); + } + } + + // Fall back to a passive declare so the call still verifies the queue exists. + if (configs.isEmpty()) { + ch.queueDeclarePassive(name); + } + return Collections.singletonMap("configs", configs); + } + + private static Object alterTopicConfig(JsonObject params) { + throw new UnsupportedOperationException( + "RabbitMQ queue arguments are immutable after declaration; delete and re-declare the queue to change them"); + } + + private static Object purgeQueue(JsonObject params) throws Exception { + String name = queueName(params); + Channel ch = channelFor(params); + AMQP.Queue.PurgeOk purged = ch.queuePurge(name); + + Map result = new LinkedHashMap<>(); + result.put("ok", true); + result.put("purged", purged.getMessageCount()); + return result; + } + + private static Object listConsumers(JsonObject params) throws Exception { + JsonObject conn = requireConnectionConfig(params); + String name = queueName(params); + String vhost = effectiveVhost(params, conn); + JsonElement queue = managementGet(conn, + "/api/queues/" + urlEncodeVhost(vhost) + "/" + urlEncodePathSegment(name)); + if (!queue.isJsonObject()) { + throw new IllegalStateException("Unexpected management API response for queue details"); + } + return Collections.singletonMap("consumers", consumersFromQueueInfo(queue.getAsJsonObject())); + } + + /** + * Map the management API queue detail's {@code consumer_details} array to the + * bridge's consumer shape. A queue without consumers may omit the array + * entirely, which maps to an empty list. + */ + static List> consumersFromQueueInfo(JsonObject info) { + List> consumers = new ArrayList<>(); + JsonElement details = info.get("consumer_details"); + if (details == null || !details.isJsonArray()) { + return consumers; + } + for (JsonElement element : details.getAsJsonArray()) { + if (!element.isJsonObject()) { + continue; + } + JsonObject consumer = element.getAsJsonObject(); + Map entry = new LinkedHashMap<>(); + String channelName = ""; + JsonElement channelDetails = consumer.get("channel_details"); + if (channelDetails != null && channelDetails.isJsonObject()) { + channelName = stringOrEmpty(channelDetails.getAsJsonObject(), "name"); + } + entry.put("name", channelName); + entry.put("tag", stringOrEmpty(consumer, "consumer_tag")); + entry.put("active", boolOrDefault(consumer, "active", false)); + entry.put("ackRequired", boolOrDefault(consumer, "ack_required", false)); + Integer prefetch = integerOrNull(consumer, "prefetch_count"); + if (prefetch != null) { + entry.put("prefetch", prefetch); + } + consumers.add(entry); + } + return consumers; + } + + // ----------------------------------------------------------------------- + // Namespaces (virtual hosts) + // ----------------------------------------------------------------------- + + private static Object listNamespaces(JsonObject params) throws Exception { + JsonObject conn = requireConnectionConfig(params); + JsonElement vhosts = managementGet(conn, "/api/vhosts"); + if (!vhosts.isJsonArray()) { + throw new IllegalStateException("Unexpected management API response for vhost listing"); + } + + List> namespaces = new ArrayList<>(); + for (JsonElement element : vhosts.getAsJsonArray()) { + if (!element.isJsonObject()) { + continue; + } + namespaces.add(Collections.singletonMap("name", stringOrEmpty(element.getAsJsonObject(), "name"))); + } + return Collections.singletonMap("namespaces", namespaces); + } + + private static Object createNamespace(JsonObject params) throws Exception { + String namespace = namespaceName(params); + JsonObject conn = requireConnectionConfig(params); + managementSend(conn, "PUT", "/api/vhosts/" + urlEncodeVhost(namespace)); + return Collections.singletonMap("ok", true); + } + + private static Object deleteNamespace(JsonObject params) throws Exception { + String namespace = namespaceName(params); + // The default vhost is protected even before checking connectivity, so + // the guard error is semantic rather than a connection failure. + assertNamespaceDeletable(namespace, null); + JsonObject conn = requireConnectionConfig(params); + assertNamespaceDeletable(namespace, stringOrDefault(conn, "virtual_host", "/")); + managementSend(conn, "DELETE", "/api/vhosts/" + urlEncodeVhost(namespace)); + return Collections.singletonMap("ok", true); + } + + /** Guard rails for vhost deletion: never "/", never the vhost in use. */ + static void assertNamespaceDeletable(String namespace, String connectedVhost) { + if ("/".equals(namespace)) { + throw new IllegalArgumentException("The default virtual host '/' cannot be deleted"); + } + if (connectedVhost != null && namespace.equals(connectedVhost)) { + throw new IllegalArgumentException( + "Cannot delete the virtual host '" + namespace + "' while connected to it"); + } + } + + private static String namespaceName(JsonObject params) { + String name = stringOrEmpty(params, "namespace"); + if (name.isBlank()) { + throw new IllegalArgumentException("namespace is required"); + } + // '*' is the all-vhosts marker used by listings, never a real vhost name; + // without this guard a create/delete would address /api/vhosts/%2A. + if ("*".equals(name.trim())) { + throw new IllegalArgumentException("namespace create/delete requires a specific virtual host (all-vhosts context)"); + } + return name; + } + + // ----------------------------------------------------------------------- + // Exchanges & bindings + // ----------------------------------------------------------------------- + + private static final Set EXCHANGE_TYPES = Set.of("direct", "fanout", "topic", "headers"); + + private static Object listExchanges(JsonObject params) throws Exception { + JsonObject conn = requireConnectionConfig(params); + boolean allVhosts = allVhostsRequested(params); + JsonArray exchanges = managementGetAll(conn, managementListPath(params, conn, "exchanges")); + + List> result = new ArrayList<>(); + for (JsonElement element : exchanges) { + if (!element.isJsonObject()) { + continue; + } + JsonObject exchange = element.getAsJsonObject(); + Map info = exchangeInfoFromJson(exchange); + if (allVhosts) { + attachVhost(info, exchange); + } + result.add(info); + } + result.sort(Comparator.comparing(m -> (String) m.get("name"))); + return Collections.singletonMap("exchanges", result); + } + + /** + * Map one management API exchange entry to the bridge shape. The default + * exchange ("") reports an empty type in the API; surface it as "default". + */ + static Map exchangeInfoFromJson(JsonObject exchange) { + Map info = new LinkedHashMap<>(); + info.put("name", stringOrEmpty(exchange, "name")); + String type = stringOrEmpty(exchange, "type"); + info.put("type", type.isEmpty() ? "default" : type); + info.put("durable", boolOrDefault(exchange, "durable", false)); + info.put("autoDelete", boolOrDefault(exchange, "auto_delete", false)); + info.put("internal", boolOrDefault(exchange, "internal", false)); + return info; + } + + private static Object createExchange(JsonObject params) throws Exception { + // Validate before touching connectivity so type errors are semantic. + String name = exchangeName(params); + String type = validateExchangeType(stringOrEmpty(params, "type")); + JsonObject conn = requireConnectionConfig(params); + String vhost = effectiveVhost(params, conn); + + JsonObject body = new JsonObject(); + body.addProperty("type", type); + body.addProperty("durable", boolOrDefault(params, "durable", true)); + body.addProperty("auto_delete", boolOrDefault(params, "autoDelete", false)); + managementSend(conn, "PUT", + "/api/exchanges/" + urlEncodeVhost(vhost) + "/" + urlEncodePathSegment(name), + body); + return Collections.singletonMap("ok", true); + } + + private static Object deleteExchange(JsonObject params) throws Exception { + // Guard before connectivity: the error is semantic, not a connection failure. + String name = stringOrEmpty(params, "name"); + assertExchangeDeletable(name); + if (name.isBlank()) { + throw new IllegalArgumentException("name is required"); + } + JsonObject conn = requireConnectionConfig(params); + String vhost = effectiveVhost(params, conn); + managementSend(conn, "DELETE", + "/api/exchanges/" + urlEncodeVhost(vhost) + "/" + urlEncodePathSegment(name)); + return Collections.singletonMap("ok", true); + } + + /** Exchange type whitelist; anything else is rejected before hitting the broker. */ + static String validateExchangeType(String type) { + if (!EXCHANGE_TYPES.contains(type)) { + throw new IllegalArgumentException( + "Invalid exchange type '" + type + "'. Supported types: direct, fanout, topic, headers"); + } + return type; + } + + /** Guard rails for exchange deletion: never the default exchange, never amq.* built-ins. */ + static void assertExchangeDeletable(String name) { + if (name.isEmpty()) { + throw new IllegalArgumentException("The default exchange cannot be deleted"); + } + if (name.startsWith("amq.")) { + throw new IllegalArgumentException("The built-in exchange '" + name + "' cannot be deleted"); + } + } + + private static String exchangeName(JsonObject params) { + String name = stringOrEmpty(params, "name"); + if (name.isBlank()) { + throw new IllegalArgumentException("name is required"); + } + return name; + } + + private static Object listBindings(JsonObject params) throws Exception { + JsonObject conn = requireConnectionConfig(params); + boolean allVhosts = allVhostsRequested(params); + JsonArray bindings = managementGetAll(conn, managementListPath(params, conn, "bindings")); + String exchange = stringOrEmpty(params, "exchange"); + String queue = stringOrEmpty(params, "queue"); + + List> result = new ArrayList<>(); + for (JsonElement element : bindings) { + if (!element.isJsonObject()) { + continue; + } + Map binding = bindingInfoFromJson(element.getAsJsonObject()); + if (!exchange.isEmpty() && !exchange.equals(binding.get("source"))) { + continue; + } + // A queue filter means "bindings feeding this queue": the + // destination must be the queue itself, not an exchange. + if (!queue.isEmpty() && !(queue.equals(binding.get("destination")) + && "queue".equals(binding.get("destinationType")))) { + continue; + } + if (allVhosts) { + attachVhost(binding, element.getAsJsonObject()); + } + result.add(binding); + } + return Collections.singletonMap("bindings", result); + } + + /** Map one management API binding entry (snake_case) to the bridge shape (camelCase). */ + static Map bindingInfoFromJson(JsonObject binding) { + Map info = new LinkedHashMap<>(); + info.put("source", stringOrEmpty(binding, "source")); + info.put("destination", stringOrEmpty(binding, "destination")); + info.put("destinationType", stringOrEmpty(binding, "destination_type")); + info.put("routingKey", stringOrEmpty(binding, "routing_key")); + JsonElement arguments = binding.get("arguments"); + if (arguments != null && arguments.isJsonObject() && !arguments.getAsJsonObject().isEmpty()) { + Map args = new LinkedHashMap<>(); + for (Map.Entry entry : arguments.getAsJsonObject().entrySet()) { + if (entry.getValue().isJsonNull()) { + continue; + } + Object value = argumentValue(entry.getValue()); + args.put(entry.getKey(), value != null ? value : entry.getValue().toString()); + } + info.put("arguments", args); + } + return info; + } + + private static Object bind(JsonObject params) throws Exception { + applyBinding(params, true); + return Collections.singletonMap("ok", true); + } + + private static Object unbind(JsonObject params) throws Exception { + applyBinding(params, false); + return Collections.singletonMap("ok", true); + } + + /** + * Bind or unbind via AMQP. Queue destinations use queueBind/queueUnbind; + * exchange destinations (exchange-to-exchange) use exchangeBind/exchangeUnbind. + */ + private static void applyBinding(JsonObject params, boolean bind) throws Exception { + String source = requireBindingName(params, "source"); + String destination = requireBindingName(params, "destination"); + String destinationType = stringOrDefault(params, "destinationType", + stringOrEmpty(params, "destination_type")); + // Validate the destination type before touching connectivity so a bad + // value fails fast instead of surfacing as a connection error. + if (!"queue".equals(destinationType) && !"exchange".equals(destinationType)) { + throw new IllegalArgumentException( + "destinationType must be 'queue' or 'exchange', got '" + destinationType + "'"); + } + String routingKey = stringOrDefault(params, "routingKey", stringOrEmpty(params, "routing_key")); + Map arguments = bindingArguments(params); + Channel ch = channelFor(params); + + switch (destinationType) { + case "queue" -> { + if (bind) { + ch.queueBind(destination, source, routingKey, arguments); + } else { + ch.queueUnbind(destination, source, routingKey, arguments); + } + } + case "exchange" -> { + if (bind) { + ch.exchangeBind(destination, source, routingKey, arguments); + } else { + ch.exchangeUnbind(destination, source, routingKey, arguments); + } + } + default -> throw new IllegalStateException("unreachable"); + } + } + + private static String requireBindingName(JsonObject params, String key) { + String name = stringOrEmpty(params, key); + if (name.isBlank()) { + throw new IllegalArgumentException(key + " is required"); + } + return name; + } + + private static Map bindingArguments(JsonObject params) { + Map arguments = new HashMap<>(); + JsonObject args = params.has("arguments") && params.get("arguments").isJsonObject() + ? params.getAsJsonObject("arguments") : null; + if (args != null) { + for (Map.Entry entry : args.entrySet()) { + Object value = argumentValue(entry.getValue()); + if (value != null) { + arguments.put(entry.getKey(), value); + } + } + } + return arguments; + } + + // ----------------------------------------------------------------------- + // Client connections & channels + // ----------------------------------------------------------------------- + + private static Object listClientConnections(JsonObject params) throws Exception { + JsonObject conn = requireConnectionConfig(params); + JsonArray connections = managementGetAll(conn, "/api/connections"); + boolean allVhosts = allVhostsRequested(params); + String vhostFilter = vhostFilter(params, conn); + + List> result = new ArrayList<>(); + for (JsonElement element : connections) { + if (!element.isJsonObject()) { + continue; + } + JsonObject connection = element.getAsJsonObject(); + if (!vhostFilter.isEmpty() && !vhostFilter.equals(stringOrEmpty(connection, "vhost"))) { + continue; + } + Map info = clientConnectionInfoFromJson(connection); + if (allVhosts) { + attachVhost(info, connection); + } + result.add(info); + } + result.sort(Comparator.comparing(m -> (String) m.get("name"))); + return Collections.singletonMap("connections", result); + } + + /** + * Map one management API connection entry (snake_case) to the bridge shape + * (camelCase). Rates come from the *_oct_details blocks; connected_at is a + * millisecond timestamp. Both are omitted when the broker does not report them. + */ + static Map clientConnectionInfoFromJson(JsonObject connection) { + Map info = new LinkedHashMap<>(); + info.put("name", stringOrEmpty(connection, "name")); + info.put("user", stringOrEmpty(connection, "user")); + info.put("peerHost", stringOrEmpty(connection, "peer_host")); + info.put("peerPort", longOrDefault(connection, "peer_port", 0)); + info.put("state", stringOrEmpty(connection, "state")); + info.put("channels", longOrDefault(connection, "channels", 0)); + Double recvRate = rateFromDetails(connection, "recv_oct_details"); + if (recvRate != null) { + info.put("recvRate", recvRate); + } + Double sendRate = rateFromDetails(connection, "send_oct_details"); + if (sendRate != null) { + info.put("sendRate", sendRate); + } + Long connectedAt = longOrNull(connection, "connected_at"); + if (connectedAt != null) { + info.put("connectedAt", connectedAt); + } + return info; + } + + /** Per-second byte rate from a {@code recv_oct_details}/{@code send_oct_details} block. */ + static Double rateFromDetails(JsonObject object, String key) { + JsonElement details = object.get(key); + if (details == null || !details.isJsonObject()) { + return null; + } + JsonElement rate = details.getAsJsonObject().get("rate"); + return rate == null || rate.isJsonNull() ? null : rate.getAsDouble(); + } + + private static Object listClientChannels(JsonObject params) throws Exception { + JsonObject conn = requireConnectionConfig(params); + JsonArray channels = managementGetAll(conn, "/api/channels"); + String connectionFilter = stringOrEmpty(params, "connection"); + boolean allVhosts = allVhostsRequested(params); + String vhostFilter = vhostFilter(params, conn); + + List> result = new ArrayList<>(); + for (JsonElement element : channels) { + if (!element.isJsonObject()) { + continue; + } + JsonObject channel = element.getAsJsonObject(); + if (!vhostFilter.isEmpty() && !vhostFilter.equals(stringOrEmpty(channel, "vhost"))) { + continue; + } + Map info = channelInfoFromJson(channel); + if (allVhosts) { + attachVhost(info, channel); + } + if (!connectionFilter.isEmpty() && !channelMatchesConnection(info, connectionFilter)) { + continue; + } + result.add(info); + } + result.sort(Comparator.comparing(m -> (String) m.get("name"))); + return Collections.singletonMap("channels", result); + } + + /** Map one management API channel entry (snake_case) to the bridge shape (camelCase). */ + static Map channelInfoFromJson(JsonObject channel) { + Map info = new LinkedHashMap<>(); + info.put("name", stringOrEmpty(channel, "name")); + JsonElement connectionDetails = channel.get("connection_details"); + if (connectionDetails != null && connectionDetails.isJsonObject()) { + String connectionName = stringOrEmpty(connectionDetails.getAsJsonObject(), "name"); + if (!connectionName.isEmpty()) { + info.put("connectionName", connectionName); + } + } + info.put("state", stringOrEmpty(channel, "state")); + Integer prefetch = integerOrNull(channel, "prefetch_count"); + if (prefetch != null) { + info.put("prefetch", prefetch); + } + Long unacked = longOrNull(channel, "messages_unacknowledged"); + if (unacked != null) { + info.put("messagesUnacked", unacked); + } + Long consumers = longOrNull(channel, "consumer_count"); + if (consumers != null) { + info.put("consumerCount", consumers); + } + return info; + } + + /** + * A channel belongs to a connection when its {@code connection_details.name} + * matches, or when its own name starts with the connection name (channel + * names are "{connectionName} ({channelNumber})"). + */ + static boolean channelMatchesConnection(Map channelInfo, String connectionName) { + if (connectionName.equals(channelInfo.get("connectionName"))) { + return true; + } + Object name = channelInfo.get("name"); + return name instanceof String && ((String) name).startsWith(connectionName); + } + + private static Object closeClientConnection(JsonObject params) throws Exception { + // Validate before touching connectivity so the error is semantic. + String name = stringOrEmpty(params, "name"); + if (name.isBlank()) { + throw new IllegalArgumentException("name is required"); + } + JsonObject conn = requireConnectionConfig(params); + managementSend(conn, "DELETE", "/api/connections/" + urlEncodeName(name)); + return Collections.singletonMap("ok", true); + } + + /** + * URL-encode a connection name for the management API path. Connection names + * contain " -> " and spaces, so URLEncoder's '+' for spaces must become %20. + */ + static String urlEncodeName(String name) { + return urlEncodePathSegment(name); + } + + // ----------------------------------------------------------------------- + // Users & permissions + // ----------------------------------------------------------------------- + + private static Object listUsers(JsonObject params) throws Exception { + JsonObject conn = requireConnectionConfig(params); + JsonArray users = managementGetAll(conn, "/api/users"); + + List> result = new ArrayList<>(); + for (JsonElement element : users) { + if (!element.isJsonObject()) { + continue; + } + result.add(userInfoFromJson(element.getAsJsonObject())); + } + result.sort(Comparator.comparing(m -> (String) m.get("name"))); + return Collections.singletonMap("users", result); + } + + /** + * Map one management API user entry to the bridge shape. The API reports tags + * as a single comma-separated string; the bridge shape carries them as an array. + */ + static Map userInfoFromJson(JsonObject user) { + Map info = new LinkedHashMap<>(); + info.put("name", stringOrEmpty(user, "name")); + info.put("tags", parseUserTags(stringOrEmpty(user, "tags"))); + return info; + } + + /** Split the management API's comma-separated tag string; blank entries are dropped. */ + static List parseUserTags(String tags) { + List result = new ArrayList<>(); + for (String tag : tags.split(",")) { + String trimmed = tag.trim(); + if (!trimmed.isEmpty()) { + result.add(trimmed); + } + } + return result; + } + + private static Object createUser(JsonObject params) throws Exception { + // Validate before touching connectivity so the errors are semantic. + String name = userName(params); + String password = stringOrEmpty(params, "password"); + if (password.isEmpty()) { + throw new IllegalArgumentException("password is required"); + } + JsonObject conn = requireConnectionConfig(params); + // PUT /api/users upserts, so "creating" the connected user would actually + // change its credentials; reject it just like deletion. + assertNotConnectedUser("create or modify", name, stringOrDefault(conn, "username", "guest")); + + JsonObject body = new JsonObject(); + body.addProperty("password", password); + body.addProperty("tags", userTagsParam(params)); + managementSend(conn, "PUT", "/api/users/" + urlEncodePathSegment(name), body); + return Collections.singletonMap("ok", true); + } + + /** Tags for user creation: accepts a JSON array or a comma-separated string. */ + static String userTagsParam(JsonObject params) { + JsonElement tags = params.get("tags"); + if (tags == null || tags.isJsonNull()) { + return ""; + } + if (tags.isJsonArray()) { + List parts = new ArrayList<>(); + for (JsonElement element : tags.getAsJsonArray()) { + String tag = element.getAsString().trim(); + if (!tag.isEmpty()) { + parts.add(tag); + } + } + return String.join(",", parts); + } + return tags.getAsString(); + } + + private static Object deleteUser(JsonObject params) throws Exception { + String name = userName(params); + JsonObject conn = requireConnectionConfig(params); + assertNotConnectedUser("delete", name, stringOrDefault(conn, "username", "guest")); + managementSend(conn, "DELETE", "/api/users/" + urlEncodePathSegment(name)); + return Collections.singletonMap("ok", true); + } + + /** Guard rail for user changes: never touch the user the agent itself connects as. */ + static void assertNotConnectedUser(String action, String name, String connectedUser) { + if (name.equals(connectedUser)) { + throw new IllegalArgumentException( + "Cannot " + action + " user '" + name + "' while connected as that user"); + } + } + + private static Object listPermissions(JsonObject params) throws Exception { + JsonObject conn = requireConnectionConfig(params); + JsonElement permissions = managementGet(conn, "/api/permissions"); + if (!permissions.isJsonArray()) { + throw new IllegalStateException("Unexpected management API response for permission listing"); + } + // The management API only lists permissions cluster-wide; virtual_host and + // user are client-side filters. all_vhosts simply disables the vhost filter. + String vhostFilter = allVhostsRequested(params) ? "" : stringOrEmpty(params, "virtual_host"); + String userFilter = stringOrEmpty(params, "user"); + + List> result = new ArrayList<>(); + for (JsonElement element : permissions.getAsJsonArray()) { + if (!element.isJsonObject()) { + continue; + } + Map permission = permissionInfoFromJson(element.getAsJsonObject()); + if (!vhostFilter.isEmpty() && !vhostFilter.equals(permission.get("vhost"))) { + continue; + } + if (!userFilter.isEmpty() && !userFilter.equals(permission.get("user"))) { + continue; + } + result.add(permission); + } + result.sort(Comparator.comparing((Map m) -> (String) m.get("user")) + .thenComparing(m -> (String) m.get("vhost"))); + return Collections.singletonMap("permissions", result); + } + + /** Map one management API permission entry (user x vhost regex triple) to the bridge shape. */ + static Map permissionInfoFromJson(JsonObject permission) { + Map info = new LinkedHashMap<>(); + info.put("user", stringOrEmpty(permission, "user")); + info.put("vhost", stringOrEmpty(permission, "vhost")); + info.put("configure", stringOrEmpty(permission, "configure")); + info.put("write", stringOrEmpty(permission, "write")); + info.put("read", stringOrEmpty(permission, "read")); + return info; + } + + private static final String DEFAULT_PERMISSION_PATTERN = ".*"; + + private static Object grantPermission(JsonObject params) throws Exception { + // Validate before touching connectivity so the errors are semantic. + String user = userName(params); + String vhost = permissionVhost(params); + JsonObject conn = requireConnectionConfig(params); + + JsonObject body = new JsonObject(); + body.addProperty("configure", permissionPattern(params, "configure")); + body.addProperty("write", permissionPattern(params, "write")); + body.addProperty("read", permissionPattern(params, "read")); + managementSend(conn, "PUT", + "/api/permissions/" + urlEncodeVhost(vhost) + "/" + urlEncodePathSegment(user), body); + return Collections.singletonMap("ok", true); + } + + private static Object revokePermission(JsonObject params) throws Exception { + String user = userName(params); + String vhost = permissionVhost(params); + JsonObject conn = requireConnectionConfig(params); + managementSend(conn, "DELETE", + "/api/permissions/" + urlEncodeVhost(vhost) + "/" + urlEncodePathSegment(user)); + return Collections.singletonMap("ok", true); + } + + /** Permission pattern defaulting to ".*" (full access) when omitted or blank. */ + static String permissionPattern(JsonObject params, String key) { + String pattern = stringOrEmpty(params, key); + return pattern.isBlank() ? DEFAULT_PERMISSION_PATTERN : pattern; + } + + /** + * Vhost for permission and policy writes: the {@code *} all-vhosts sentinel + * only makes sense for listings; a write always targets one concrete vhost. + */ + static String permissionVhost(JsonObject params) { + String vhost = stringOrEmpty(params, "virtual_host"); + if (vhost.isBlank()) { + throw new IllegalArgumentException("virtual_host is required"); + } + if ("*".equals(vhost)) { + throw new IllegalArgumentException("all_vhosts is only supported for list operations"); + } + return vhost; + } + + /** User name: create/delete send {@code name}, grant/revoke send {@code user}. */ + private static String userName(JsonObject params) { + String name = stringOrEmpty(params, "name"); + if (name.isBlank()) { + name = stringOrEmpty(params, "user"); + } + if (name.isBlank()) { + throw new IllegalArgumentException("user name is required"); + } + return name; + } + + /** + * URL-encode one path segment (queue/exchange name) for the management API. + * URLEncoder is form-oriented and encodes spaces as '+', which the management + * API does not decode back in path segments (causing 404s), so '+' becomes %20. + */ + static String urlEncodePathSegment(String name) { + return URLEncoder.encode(name, StandardCharsets.UTF_8).replace("+", "%20"); + } + + // ----------------------------------------------------------------------- + // Policies + // ----------------------------------------------------------------------- + + private static Object listPolicies(JsonObject params) throws Exception { + JsonObject conn = requireConnectionConfig(params); + // Accept the '*' all-vhosts sentinel as a synonym for all_vhosts=true; + // both select the vhost-less management API variant. + boolean allVhosts = allVhostsRequested(params) || "*".equals(stringOrEmpty(params, "virtual_host")); + JsonArray policies = managementGetAll(conn, allVhosts ? "/api/policies" + : "/api/policies/" + urlEncodeVhost(effectiveVhost(params, conn))); + + List> result = new ArrayList<>(); + for (JsonElement element : policies) { + if (!element.isJsonObject()) { + continue; + } + result.add(policyInfoFromJson(element.getAsJsonObject())); + } + result.sort(Comparator.comparing((Map m) -> (String) m.get("vhost")) + .thenComparing(m -> (String) m.get("name"))); + return Collections.singletonMap("policies", result); + } + + /** + * Map one management API policy entry to the bridge shape: kebab-case + * {@code apply-to} becomes camelCase {@code applyTo}, and the definition map + * is passed through with plain values. Each policy always carries its own + * {@code vhost}, so flat and cross-vhost listings share one shape. + */ + static Map policyInfoFromJson(JsonObject policy) { + Map info = new LinkedHashMap<>(); + info.put("name", stringOrEmpty(policy, "name")); + info.put("vhost", stringOrEmpty(policy, "vhost")); + info.put("pattern", stringOrEmpty(policy, "pattern")); + info.put("applyTo", stringOrEmpty(policy, "apply-to")); + info.put("priority", longOrDefault(policy, "priority", 0)); + Map definition = new LinkedHashMap<>(); + JsonElement rawDefinition = policy.get("definition"); + if (rawDefinition != null && rawDefinition.isJsonObject()) { + for (Map.Entry entry : rawDefinition.getAsJsonObject().entrySet()) { + if (entry.getValue().isJsonNull()) { + continue; + } + Object value = argumentValue(entry.getValue()); + definition.put(entry.getKey(), value != null ? value : entry.getValue().toString()); + } + } + info.put("definition", definition); + return info; + } + + private static Object setPolicy(JsonObject params) throws Exception { + // Validate before touching connectivity so the errors are semantic. + String vhost = permissionVhost(params); + String name = policyName(params); + String pattern = stringOrEmpty(params, "pattern"); + if (pattern.isBlank()) { + throw new IllegalArgumentException("pattern is required"); + } + JsonElement definition = params.get("definition"); + if (definition == null || !definition.isJsonObject()) { + throw new IllegalArgumentException("definition is required"); + } + JsonObject conn = requireConnectionConfig(params); + + JsonObject body = new JsonObject(); + body.addProperty("pattern", pattern); + // The bridge sends camelCase applyTo; the management API wants apply-to. + // applyTo defaults to queues and priority to 0, matching broker defaults. + body.addProperty("apply-to", stringOrDefault(params, "applyTo", "queues")); + body.addProperty("priority", intOrDefault(params, "priority", 0)); + body.add("definition", definition); + managementSend(conn, "PUT", + "/api/policies/" + urlEncodeVhost(vhost) + "/" + urlEncodePathSegment(name), body); + return Collections.singletonMap("ok", true); + } + + private static Object deletePolicy(JsonObject params) throws Exception { + // Validate before touching connectivity so the errors are semantic. + String vhost = permissionVhost(params); + String name = policyName(params); + JsonObject conn = requireConnectionConfig(params); + managementSend(conn, "DELETE", + "/api/policies/" + urlEncodeVhost(vhost) + "/" + urlEncodePathSegment(name)); + return Collections.singletonMap("ok", true); + } + + /** Policy name for set/delete. */ + private static String policyName(JsonObject params) { + String name = stringOrEmpty(params, "name"); + if (name.isBlank()) { + throw new IllegalArgumentException("name is required"); + } + return name; + } + + // ----------------------------------------------------------------------- + // Messages + // ----------------------------------------------------------------------- + + private static Object peekMessages(JsonObject params) throws Exception { + String queue = queueName(params); + long offset = normalizePeekOffset(longOrDefault(params, "offset", 0)); + int count = normalizePeekCount(intOrDefault(params, "count", 10)); + long totalToFetch = Math.min(offset + count, MAX_PEEK_MESSAGES); + if (offset >= MAX_PEEK_MESSAGES) { + return Collections.singletonMap("messages", Collections.emptyList()); + } + + Channel ch; + Connection ownedConnection = null; + if (cachedConnection != null) { + ch = channelFor(params); + } else { + // Not connected: open a short-lived connection from inline params, + // mirroring how the Kafka agent accepts a `connection` object for peek. + JsonObject conn = connectionObject(params); + String vhost = stringOrNull(params, "virtual_host"); + if (vhost != null && !vhost.isBlank()) { + conn = conn.deepCopy(); + conn.addProperty("virtual_host", vhost); + } + ownedConnection = openConnection(conn); + ch = ownedConnection.createChannel(); + } + try { + List fetched = new ArrayList<>(); + long lastDeliveryTag = -1; + for (long i = 0; i < totalToFetch; i++) { + GetResponse response = ch.basicGet(queue, false); + if (response == null) { + break; + } + fetched.add(response); + lastDeliveryTag = response.getEnvelope().getDeliveryTag(); + } + // Requeue everything so peeking never consumes messages. + if (lastDeliveryTag >= 0) { + ch.basicNack(lastDeliveryTag, true, true); + } + + List> messages = new ArrayList<>(); + for (long i = offset; i < fetched.size() && messages.size() < count; i++) { + messages.add(peekedMessageFromGetResponse(queue, i, fetched.get((int) i))); + } + return Collections.singletonMap("messages", messages); + } finally { + if (ownedConnection != null) { + closeQuietly(ch); + closeQuietly(ownedConnection); + } + } + } + + static long normalizePeekOffset(long requestedOffset) { + return Math.max(0, requestedOffset); + } + + /** + * Routing key for publishes: {@code routing_key}/{@code routingKey} win; + * the Rust bridge sends the message key as {@code key}; the default is the + * queue name so publishes through the default exchange reach the queue. + */ + static String resolveRoutingKey(JsonObject params, String queue) { + String routingKey = stringOrDefault(params, "routing_key", ""); + if (routingKey.isEmpty()) { + routingKey = stringOrDefault(params, "routingKey", ""); + } + if (routingKey.isEmpty()) { + routingKey = stringOrDefault(params, "key", ""); + } + // A blank key must not win over the queue fallback: publishing through + // the default exchange with an empty routing key silently drops the message. + if (routingKey.isBlank()) { + routingKey = queue; + } + return routingKey; + } + + static int normalizePeekCount(int requestedCount) { + return Math.max(1, requestedCount); + } + + private static Map peekedMessageFromGetResponse(String queue, long index, GetResponse response) { + Map msg = new LinkedHashMap<>(); + msg.put("topic", queue); + msg.put("offset", index); + msg.put("exchange", response.getEnvelope().getExchange()); + msg.put("routingKey", response.getEnvelope().getRoutingKey()); + msg.put("redelivered", response.getEnvelope().isRedeliver()); + msg.put("deliveryTag", response.getEnvelope().getDeliveryTag()); + + AMQP.BasicProperties props = response.getProps(); + if (props != null && props.getMessageId() != null) { + msg.put("messageId", props.getMessageId()); + } + Date timestamp = props != null ? props.getTimestamp() : null; + msg.put("timestamp", timestamp != null ? timestamp.getTime() : 0L); + + Map headers = new LinkedHashMap<>(); + if (props != null && props.getHeaders() != null) { + for (Map.Entry entry : props.getHeaders().entrySet()) { + headers.put(entry.getKey(), String.valueOf(entry.getValue())); + } + } + msg.put("headers", headers); + + byte[] body = response.getBody(); + if (body != null) { + msg.put("payloadBase64", Base64.getEncoder().encodeToString(body)); + String text = tryDecodeUtf8(body); + if (text != null) { + msg.put("payloadText", text); + } + } else { + msg.put("payloadBase64", ""); + } + return msg; + } + + private static Object sendMessage(JsonObject params) throws Exception { + Channel ch = channelFor(params); + String queue = queueName(params); + String exchange = stringOrDefault(params, "exchange", ""); + String routingKey = resolveRoutingKey(params, queue); + + String payloadBase64 = stringOrEmpty(params, "payloadBase64"); + byte[] body = payloadBase64.isEmpty() ? new byte[0] : Base64.getDecoder().decode(payloadBase64); + + AMQP.BasicProperties properties = null; + JsonObject headers = params.has("headers") && params.get("headers").isJsonObject() + ? params.getAsJsonObject("headers") : null; + if (headers != null) { + Map headerMap = new HashMap<>(); + for (Map.Entry entry : headers.entrySet()) { + Object value = argumentValue(entry.getValue()); + if (value != null) { + headerMap.put(entry.getKey(), value); + } + } + properties = new AMQP.BasicProperties.Builder().headers(headerMap).build(); + } + + ch.basicPublish(exchange, routingKey, properties, body); + + Map result = new LinkedHashMap<>(); + result.put("ok", true); + result.put("exchange", exchange); + result.put("routingKey", routingKey); + return result; + } + + // ----------------------------------------------------------------------- + // Cluster / monitoring + // ----------------------------------------------------------------------- + + private static Object describeCluster(JsonObject params) throws Exception { + Connection conn = requireConnection(); + JsonObject connConfig = currentConnectionConfig(params); + Map serverProps = conn.getServerProperties(); + + List> nodes = new ArrayList<>(); + if (connConfig != null) { + for (Address address : resolveAddresses(connConfig)) { + Map node = new LinkedHashMap<>(); + node.put("name", address.getHost()); + node.put("port", address.getPort()); + nodes.add(node); + } + } + + Map result = new LinkedHashMap<>(); + result.put("clusterName", serverString(serverProps, "cluster_name")); + result.put("product", serverString(serverProps, "product")); + result.put("version", serverString(serverProps, "version")); + result.put("platform", serverString(serverProps, "platform")); + result.put("nodes", nodes); + result.put("nodeCount", nodes.size()); + return result; + } + + private static Object getOverview(JsonObject params) throws Exception { + JsonObject conn = requireConnectionConfig(params); + JsonElement overview = managementGet(conn, "/api/overview"); + if (!overview.isJsonObject()) { + throw new IllegalStateException("Unexpected management API response for cluster overview"); + } + return overviewInfoFromJson(overview.getAsJsonObject()); + } + + /** + * Map the management API overview (snake_case totals and message stats) to + * the bridge shape (camelCase). Rates come from each stat's + * {@code *_details.rate} block; anything the broker does not report is + * omitted rather than zeroed. + */ + static Map overviewInfoFromJson(JsonObject overview) { + Map info = new LinkedHashMap<>(); + putIfPresent(info, "messagesReady", nestedLongOrNull(overview, "queue_totals", "messages_ready")); + putIfPresent(info, "messagesUnacked", nestedLongOrNull(overview, "queue_totals", "messages_unacknowledged")); + + JsonElement stats = overview.get("message_stats"); + if (stats != null && stats.isJsonObject()) { + JsonObject messageStats = stats.getAsJsonObject(); + putIfPresent(info, "publishRate", rateFromDetails(messageStats, "publish_details")); + putIfPresent(info, "deliverRate", rateFromDetails(messageStats, "deliver_get_details")); + putIfPresent(info, "ackRate", rateFromDetails(messageStats, "ack_details")); + } + + putIfPresent(info, "totalQueues", nestedLongOrNull(overview, "object_totals", "queues")); + putIfPresent(info, "totalExchanges", nestedLongOrNull(overview, "object_totals", "exchanges")); + putIfPresent(info, "totalConnections", nestedLongOrNull(overview, "object_totals", "connections")); + putIfPresent(info, "totalChannels", nestedLongOrNull(overview, "object_totals", "channels")); + putIfPresent(info, "totalConsumers", nestedLongOrNull(overview, "object_totals", "consumers")); + return info; + } + + private static Object listNodes(JsonObject params) throws Exception { + JsonObject conn = requireConnectionConfig(params); + JsonElement nodes = managementGet(conn, "/api/nodes"); + if (!nodes.isJsonArray()) { + throw new IllegalStateException("Unexpected management API response for node listing"); + } + + List> result = new ArrayList<>(); + for (JsonElement element : nodes.getAsJsonArray()) { + if (!element.isJsonObject()) { + continue; + } + result.add(nodeInfoFromJson(element.getAsJsonObject())); + } + result.sort(Comparator.comparing(m -> (String) m.get("name"))); + return Collections.singletonMap("nodes", result); + } + + /** + * Map one management API node entry (snake_case) to the bridge shape + * (camelCase). The API reports uptime in milliseconds; resource counters + * the broker does not report are omitted rather than zeroed. + */ + static Map nodeInfoFromJson(JsonObject node) { + Map info = new LinkedHashMap<>(); + info.put("name", stringOrEmpty(node, "name")); + info.put("running", boolOrDefault(node, "running", false)); + putIfPresent(info, "memUsed", longOrNull(node, "mem_used")); + putIfPresent(info, "memLimit", longOrNull(node, "mem_limit")); + putIfPresent(info, "diskFree", longOrNull(node, "disk_free")); + putIfPresent(info, "fdUsed", longOrNull(node, "fd_used")); + putIfPresent(info, "fdTotal", longOrNull(node, "fd_total")); + putIfPresent(info, "socketsUsed", longOrNull(node, "sockets_used")); + putIfPresent(info, "socketsTotal", longOrNull(node, "sockets_total")); + putIfPresent(info, "uptimeMs", longOrNull(node, "uptime")); + return info; + } + + /** Long value one level down (e.g. {@code object_totals.queues}); null when absent. */ + static Long nestedLongOrNull(JsonObject object, String block, String key) { + JsonElement element = object.get(block); + if (element == null || !element.isJsonObject()) { + return null; + } + return longOrNull(element.getAsJsonObject(), key); + } + + /** Adds the value only when the broker reported it (missing stats stay absent). */ + private static void putIfPresent(Map info, String key, Object value) { + if (value != null) { + info.put(key, value); + } + } + + // ----------------------------------------------------------------------- + // HTTP management API helpers + // ----------------------------------------------------------------------- + + static JsonElement managementGet(JsonObject conn, String path) throws Exception { + return managementRequest(conn, "GET", path); + } + + /** Management API call without a JSON body (PUT/DELETE); accepts 2xx. */ + static JsonElement managementSend(JsonObject conn, String method, String path) throws Exception { + return managementRequest(conn, method, path); + } + + /** Management API call with a JSON body (PUT/POST); accepts 2xx. */ + static JsonElement managementSend(JsonObject conn, String method, String path, JsonObject body) throws Exception { + return managementRequest(conn, method, path, body); + } + + static JsonElement managementRequest(JsonObject conn, String method, String path) throws Exception { + return managementRequest(conn, method, path, null); + } + + static JsonElement managementRequest(JsonObject conn, String method, String path, JsonObject body) throws Exception { + // Candidates are tried in order; only connection-level failures + // (refused/timeout/DNS) move to the next candidate. A non-2xx HTTP + // status means the endpoint answered, so the answer is final. + IOException lastConnectionError = null; + for (String baseUrl : managementBaseUrls(conn)) { + try { + return managementRequestOnce(baseUrl, conn, method, path, body); + } catch (IOException e) { + lastConnectionError = e; + } + } + throw lastConnectionError != null ? lastConnectionError + : new IllegalStateException("No management API endpoint candidates"); + } + + private static JsonElement managementRequestOnce(String baseUrl, JsonObject conn, + String method, String path, JsonObject body) throws Exception { + URL url = URI.create(baseUrl + path).toURL(); + HttpURLConnection http = (HttpURLConnection) url.openConnection(); + try { + // tls_skip_verify previously only applied to AMQP; honor it for the + // management API too, or self-signed brokers fail every HTTP call. + if (tlsSkipVerify(conn) && http instanceof HttpsURLConnection https) { + https.setSSLSocketFactory(trustAllSslContext().getSocketFactory()); + https.setHostnameVerifier((hostname, session) -> true); + } + http.setRequestMethod(method); + http.setConnectTimeout(10_000); + http.setReadTimeout(10_000); + http.setRequestProperty("Authorization", + basicAuthHeader(credentialOrGuest(conn, "username"), + credentialOrGuest(conn, "password"))); + if (body != null) { + http.setDoOutput(true); + http.setRequestProperty("Content-Type", "application/json"); + try (OutputStream out = http.getOutputStream()) { + out.write(GSON.toJson(body).getBytes(StandardCharsets.UTF_8)); + } + } + int status = http.getResponseCode(); + if (status < 200 || status >= 300) { + throw new IllegalStateException(managementErrorMessage(status, method, path)); + } + if (status == 204) { + return JsonNull.INSTANCE; + } + try (InputStream in = http.getInputStream()) { + String responseBody = new String(in.readAllBytes(), StandardCharsets.UTF_8); + if (responseBody.isBlank()) { + return JsonNull.INSTANCE; + } + return JsonParser.parseString(responseBody); + } + } finally { + http.disconnect(); + } + } + + static String managementBaseUrl(String host, int port, boolean tls) { + return (tls ? "https" : "http") + "://" + host + ":" + port; + } + + /** + * Candidate management API base URLs. An explicit {@code management_url} + * wins and is used verbatim (scheme/host/port/path prefix, e.g. a reverse + * proxy mount like {@code https://proxy:8443/rmq}); otherwise one candidate + * per AMQP address is derived with the management port, and + * {@link #managementRequest} fails over across them. + */ + static List managementBaseUrls(JsonObject conn) { + String explicit = stringOrNull(conn, "management_url"); + if (explicit != null && !explicit.isBlank()) { + return List.of(normalizeManagementUrl(explicit)); + } + boolean tls = managementTls(conn); + int port = managementPort(conn, tls); + List baseUrls = new ArrayList<>(); + for (Address address : resolveAddresses(conn)) { + baseUrls.add(managementBaseUrl(address.getHost(), port, tls)); + } + return baseUrls; + } + + /** + * Trailing slashes are trimmed so base + "/api/..." joins cleanly; the path + * prefix itself is kept verbatim (no re-encoding). + */ + static String normalizeManagementUrl(String url) { + String trimmed = url.trim(); + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed; + } + + /** + * Whether the derived management endpoint uses TLS. Only explicit tls/ssl + * parameters count: tls_skip_verify is a verification flag, not a scheme + * indicator, and must not flip the management API to https. + */ + static boolean managementTls(JsonObject conn) { + return (conn.has("tls") && conn.get("tls").isJsonObject()) + || boolProperty(conn, "ssl") + || boolProperty(conn, "tls"); + } + + /** + * Username/password with blank normalization: a missing, null, or + * whitespace-only credential falls back to "guest". Without this an empty + * string from the bridge authenticates as ":" and fails confusingly. + */ + static String credentialOrGuest(JsonObject conn, String key) { + String value = stringOrNull(conn, key); + return value == null || value.isBlank() ? "guest" : value; + } + + private static final int MANAGEMENT_PAGE_SIZE = 100; + + /** + * Fetch every item of a management API list endpoint. RabbitMQ answers a + * paginated request ({@code page}/{@code page_size}) with + * {@code {items, page, page_count, total_count}}, so the loop walks to the + * last page; brokers that ignore the parameters answer with a plain array, + * which is returned as-is. + */ + static JsonArray managementGetAll(JsonObject conn, String path) throws Exception { + JsonArray all = new JsonArray(); + for (int page = 1;; page++) { + String separator = path.contains("?") ? "&" : "?"; + JsonElement response = managementGet(conn, + path + separator + "page=" + page + "&page_size=" + MANAGEMENT_PAGE_SIZE); + if (response.isJsonArray()) { + response.getAsJsonArray().forEach(all::add); + return all; + } + if (!response.isJsonObject() || !response.getAsJsonObject().has("items")) { + throw new IllegalStateException( + "Unexpected management API response for list endpoint " + path); + } + JsonObject paged = response.getAsJsonObject(); + JsonElement items = paged.get("items"); + if (items.isJsonArray()) { + items.getAsJsonArray().forEach(all::add); + } + Integer pageCount = integerOrNull(paged, "page_count"); + if (pageCount == null || page >= pageCount) { + return all; + } + } + } + + /** + * Error text for a non-2xx management API response. 401/403 mean the plugin + * answered but rejected the credentials or the user's management tag, so + * blaming the plugin would mislead debugging; other statuses keep the + * plugin hint (connection refused/timeouts never reach this method). + */ + static String managementErrorMessage(int status, String method, String path) { + String base = "RabbitMQ management API returned HTTP " + status + " for " + method + " " + path + "."; + if (status == 401 || status == 403) { + return base + " Hint: check the username/password and that the user has a management" + + " permission tag (management, policymaker, monitoring, or administrator)."; + } + return base + " The rabbitmq_management plugin must be enabled for this operation."; + } + + static int managementPort(JsonObject conn, boolean tls) { + Integer configured = null; + JsonObject properties = conn.has("properties") && conn.get("properties").isJsonObject() + ? conn.getAsJsonObject("properties") : null; + if (properties != null) { + configured = integerProperty(properties, "management_port"); + } + if (configured != null) { + return configured; + } + return tls ? DEFAULT_MANAGEMENT_TLS_PORT : DEFAULT_MANAGEMENT_PORT; + } + + static String basicAuthHeader(String username, String password) { + String credentials = username + ":" + password; + return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + } + + static String urlEncodeVhost(String vhost) { + return URLEncoder.encode(vhost, StandardCharsets.UTF_8).replace("+", "%20"); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private static final Pattern QUOTED_NAME = Pattern.compile("'([^']+)'"); + private static final Pattern DECLARED_RESOURCE_NAME = + Pattern.compile("for (queue|exchange) '([^']+)'"); + + static String normalizeErrorMessage(Exception e) { + // AMQP channel/connection shutdowns carry a broker reply code; map the + // known ones to a readable message instead of leaking raw AMQP text. + String friendly = amqpFriendlyMessage(e); + if (friendly != null) { + return friendly; + } + String message = e.getMessage() == null || e.getMessage().isBlank() + ? e.getClass().getName() + : e.getMessage(); + Throwable root = rootCause(e); + if (root != e && root.getMessage() != null && !root.getMessage().isBlank() + && !message.contains(root.getMessage())) { + message = message + ": " + root.getMessage(); + } + if (isAuthenticationError(e)) { + message = message + ". Hint: authentication failed. Check the RabbitMQ username, " + + "password, and virtual host permissions."; + } + return message; + } + + /** + * Walk the cause chain for an AMQP shutdown signal and map its broker reply + * code to a friendly message. Returns null when there is no AMQP shutdown + * or the reply code has no mapping (caller keeps the raw message). + */ + static String amqpFriendlyMessage(Throwable error) { + for (Throwable current = error; current != null; current = current.getCause()) { + if (!(current instanceof ShutdownSignalException shutdown)) { + continue; + } + Object reason = shutdown.getReason(); + Integer replyCode = null; + String replyText = null; + if (reason instanceof AMQP.Channel.Close channelClose) { + replyCode = channelClose.getReplyCode(); + replyText = channelClose.getReplyText(); + } else if (reason instanceof AMQP.Connection.Close connectionClose) { + replyCode = connectionClose.getReplyCode(); + replyText = connectionClose.getReplyText(); + } + if (replyCode != null) { + String friendly = mapAmqpError(replyCode, replyText); + if (friendly != null) { + return friendly; + } + } + } + return null; + } + + /** Friendly message for a broker reply code, or null to keep the raw message. */ + static String mapAmqpError(int replyCode, String replyText) { + String text = replyText == null ? "" : replyText; + switch (replyCode) { + case 405: { + String name = extractQuotedName(text); + String subject = name != null ? "Queue '" + name + "'" : "The queue"; + return subject + " is exclusive and owned by another connection." + + " Hint: exclusive queues can only be accessed by their owning connection;" + + " stats via the management API are still available."; + } + case 404: { + String name = extractQuotedName(text); + boolean exchange = text.contains("no exchange"); + String kind = exchange ? "Exchange" : "Queue"; + String subject = name != null ? kind + " '" + name + "'" : "The " + kind.toLowerCase(); + return subject + " was not found." + + " Hint: it may have been deleted, or it never existed on this virtual host."; + } + case 406: { + // "PRECONDITION_FAILED - inequivalent arg 'durable' for queue 'q1' + // in vhost '/': ..." — the first quoted token is the argument + // name, so the resource name needs its own extraction. + String name = extractDeclaredResourceName(text); + boolean exchange = text.contains("for exchange"); + String kind = exchange ? "Exchange" : "Queue"; + String subject = name != null ? kind + " '" + name + "'" : "The " + kind.toLowerCase(); + return subject + " already exists with different parameters." + + " Hint: " + kind.toLowerCase() + " parameters are immutable after declaration;" + + " delete and re-declare the " + kind.toLowerCase() + " to change them."; + } + case 403: { + // "ACCESS_REFUSED - access to queue 'q1' in vhost '/' refused for user 'dbx'" + String name = extractQuotedName(text); + String subject = name != null ? "'" + name + "'" : "the requested resource"; + return "Access to " + subject + " was refused." + + " Hint: check the user's configure/write/read permissions on the virtual host."; + } + default: + return null; + } + } + + /** First single-quoted token in a broker reply text (usually the queue/exchange name). */ + static String extractQuotedName(String replyText) { + if (replyText == null) { + return null; + } + Matcher matcher = QUOTED_NAME.matcher(replyText); + return matcher.find() ? matcher.group(1) : null; + } + + /** + * Queue/exchange name in a 406 PRECONDITION_FAILED reply ("... for queue 'q1' + * in vhost ..."); the first quoted token there is the mismatched argument name. + */ + static String extractDeclaredResourceName(String replyText) { + if (replyText == null) { + return null; + } + Matcher matcher = DECLARED_RESOURCE_NAME.matcher(replyText); + return matcher.find() ? matcher.group(2) : null; + } + + private static boolean isAuthenticationError(Throwable error) { + for (Throwable current = error; current != null; current = current.getCause()) { + String className = current.getClass().getName(); + if (className.contains("AuthenticationFailureException") + || className.contains("PossibleAuthenticationFailureException")) { + return true; + } + } + return false; + } + + private static Throwable rootCause(Throwable error) { + Throwable current = error; + for (int depth = 0; current.getCause() != null && current.getCause() != current && depth < 32; depth++) { + current = current.getCause(); + } + return current; + } + + /** + * Channel for the request's effective virtual host. The default vhost reuses + * the primary channel; any other vhost lazily opens (and caches) its own + * connection/channel pair, since AMQP connections are bound to one vhost. + * Channels closed by the broker (e.g. after a 405/404 channel error) are + * detected via {@link #needsNewChannel(Channel)} and rebuilt transparently, + * so one failed call never poisons later ones. + */ + private static Channel channelFor(JsonObject params) throws Exception { + String defaultVhost = cachedConnection != null + ? stringOrDefault(cachedConnection, "virtual_host", "/") : "/"; + String vhost = effectiveVhost(params, cachedConnection); + if (vhost.equals(defaultVhost)) { + return primaryChannel(); + } + + VhostClient client = vhostClients.get(vhost); + if (client != null && client.isOpen()) { + return client.channel; + } + if (client != null) { + client.closeQuietly(); + vhostClients.remove(vhost); + } + JsonObject config = cachedConnection.deepCopy(); + config.addProperty("virtual_host", vhost); + Connection vhostConnection = openConnection(config); + Channel vhostChannel; + try { + vhostChannel = vhostConnection.createChannel(); + } catch (Exception e) { + closeQuietly(vhostConnection); + throw e; + } + vhostClients.put(vhost, new VhostClient(vhostConnection, vhostChannel)); + return vhostChannel; + } + + /** + * Primary channel for the connection's default vhost, recreating the channel + * (or the whole connection) when the broker has closed it. + */ + private static Channel primaryChannel() throws Exception { + if (!needsNewChannel(channel)) { + return channel; + } + if (connection == null || !connection.isOpen()) { + if (cachedConnection == null) { + throw new IllegalStateException("Not connected. Call connect first."); + } + closeQuietly(connection); + connection = openConnection(cachedConnection); + } + closeQuietly(channel); + channel = connection.createChannel(); + return channel; + } + + /** A channel must be rebuilt when it is missing or the broker closed it. */ + static boolean needsNewChannel(Channel ch) { + return ch == null || !ch.isOpen(); + } + + /** + * Effective virtual host: an explicit {@code virtual_host} request parameter + * wins (null/blank means "use the connection's vhost", which is what the + * Rust bridge sends for flat/no-namespace contexts). + */ + static String effectiveVhost(JsonObject params, JsonObject conn) { + String vhost = stringOrNull(params, "virtual_host"); + if (vhost == null || vhost.isBlank()) { + return conn != null ? stringOrDefault(conn, "virtual_host", "/") : "/"; + } + return vhost; + } + + /** + * Whether the request asks for a cross-vhost listing ("all vhosts"). Wins + * over {@code virtual_host}: the vhost-less management API variant is used + * and each returned item carries its own {@code vhost} field. + */ + static boolean allVhostsRequested(JsonObject params) { + return boolOrDefault(params, "all_vhosts", false); + } + + /** + * Management API path for a list endpoint: the vhost-less variant when + * {@code all_vhosts} is set, otherwise scoped to the effective vhost. + */ + static String managementListPath(JsonObject params, JsonObject conn, String resource) { + if (allVhostsRequested(params)) { + return "/api/" + resource; + } + return "/api/" + resource + "/" + urlEncodeVhost(effectiveVhost(params, conn)); + } + + /** + * Client-side vhost filter for connections/channels (the management API + * always lists these cluster-wide); {@code all_vhosts} disables the filter. + * Without an explicit {@code virtual_host} the filter falls back to the + * connection's effective vhost, matching the topic/exchange list behavior. + */ + static String vhostFilter(JsonObject params, JsonObject conn) { + if (allVhostsRequested(params)) { + return ""; + } + return effectiveVhost(params, conn); + } + + /** Copies the source entry's {@code vhost} into the mapped item (all-vhosts listings). */ + static void attachVhost(Map info, JsonObject source) { + info.put("vhost", stringOrEmpty(source, "vhost")); + } + + private static Connection requireConnection() { + if (connection == null) { + throw new IllegalStateException("Not connected. Call connect first."); + } + return connection; + } + + private static JsonObject requireConnectionConfig(JsonObject params) { + JsonObject conn = currentConnectionConfig(params); + if (conn == null) { + throw new IllegalStateException("Not connected. Call connect first."); + } + return conn; + } + + private static JsonObject currentConnectionConfig(JsonObject params) { + if (params.has("connection") && params.get("connection").isJsonObject()) { + return params.getAsJsonObject("connection"); + } + return cachedConnection; + } + + private static JsonObject connectionObject(JsonObject params) { + JsonElement connection = params.get("connection"); + return connection != null && connection.isJsonObject() + ? connection.getAsJsonObject() : params; + } + + /** Queue name: RabbitMQ semantics are flat, so a {@code namespace} parameter is ignored. */ + private static String queueName(JsonObject params) { + String name = stringOrEmpty(params, "topic"); + if (name.isBlank()) { + name = stringOrEmpty(params, "name"); + } + if (name.isBlank()) { + throw new IllegalArgumentException("topic (queue name) is required"); + } + return name; + } + + private static Object argumentValue(JsonElement element) { + if (element == null || !element.isJsonPrimitive()) { + return null; + } + if (element.getAsJsonPrimitive().isBoolean()) { + return element.getAsBoolean(); + } + if (element.getAsJsonPrimitive().isNumber()) { + return element.getAsLong(); + } + return element.getAsString(); + } + + private static String serverString(Map serverProps, String key) { + Object value = serverProps.get(key); + return value != null ? String.valueOf(value) : null; + } + + private static String tryDecodeUtf8(byte[] bytes) { + try { + String text = new String(bytes, StandardCharsets.UTF_8); + // Verify round-trip + byte[] reEncoded = text.getBytes(StandardCharsets.UTF_8); + if (Arrays.equals(bytes, reEncoded)) { + return text; + } + } catch (Exception ignored) {} + return null; + } + + private static String stringOrNull(JsonObject object, String key) { + JsonElement element = object.get(key); + return element == null || element.isJsonNull() ? null : element.getAsString(); + } + + private static String stringOrEmpty(JsonObject object, String key) { + return stringOrDefault(object, key, ""); + } + + private static String stringOrDefault(JsonObject object, String key, String fallback) { + String value = stringOrNull(object, key); + return value == null ? fallback : value; + } + + private static Integer integerOrNull(JsonObject object, String key) { + JsonElement element = object.get(key); + return element == null || element.isJsonNull() ? null : element.getAsInt(); + } + + private static Long longOrNull(JsonObject object, String key) { + JsonElement element = object.get(key); + return element == null || element.isJsonNull() ? null : element.getAsLong(); + } + + private static int intOrDefault(JsonObject object, String key, int fallback) { + Integer value = integerOrNull(object, key); + return value == null ? fallback : value; + } + + private static long longOrDefault(JsonObject object, String key, long fallback) { + Long value = longOrNull(object, key); + return value == null ? fallback : value; + } + + private static boolean boolOrDefault(JsonObject object, String key, boolean fallback) { + JsonElement element = object.get(key); + return element == null || element.isJsonNull() ? fallback : element.getAsBoolean(); + } + + private static Integer integerProperty(JsonObject properties, String key) { + try { + return integerOrNull(properties, key); + } catch (NumberFormatException e) { + return null; + } + } + + private static Boolean booleanProperty(JsonObject properties, String key) { + JsonElement element = properties.get(key); + return element == null || element.isJsonNull() ? null : element.getAsBoolean(); + } + + private static boolean boolProperty(JsonObject conn, String key) { + JsonObject properties = conn.has("properties") && conn.get("properties").isJsonObject() + ? conn.getAsJsonObject("properties") : null; + return properties != null && boolOrDefault(properties, key, false); + } + + // ----------------------------------------------------------------------- + // Inner types + // ----------------------------------------------------------------------- + + private static final class HandshakeResult { + private final int protocolVersion; + private final int agentProtocolVersion; + private final List capabilities; + + private HandshakeResult(int protocolVersion, int agentProtocolVersion, List capabilities) { + this.protocolVersion = protocolVersion; + this.agentProtocolVersion = agentProtocolVersion; + this.capabilities = capabilities; + } + } + + /** Connection/channel pair cached for one non-default virtual host. */ + private static final class VhostClient { + private final Connection connection; + private final Channel channel; + + private VhostClient(Connection connection, Channel channel) { + this.connection = connection; + this.channel = channel; + } + + private boolean isOpen() { + return connection.isOpen() && channel.isOpen(); + } + + private void closeQuietly() { + RabbitMqAgent.closeQuietly(channel); + RabbitMqAgent.closeQuietly(connection); + } + } +} diff --git a/agents/drivers/rabbitmq/src/test/java/com/dbx/agent/rabbitmq/RabbitMqAgentTest.java b/agents/drivers/rabbitmq/src/test/java/com/dbx/agent/rabbitmq/RabbitMqAgentTest.java new file mode 100644 index 000000000..cdb285046 --- /dev/null +++ b/agents/drivers/rabbitmq/src/test/java/com/dbx/agent/rabbitmq/RabbitMqAgentTest.java @@ -0,0 +1,1779 @@ +package com.dbx.agent.rabbitmq; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.rabbitmq.client.AMQP; +import com.rabbitmq.client.Address; +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.ConnectionFactory; +import com.rabbitmq.client.ShutdownSignalException; +import com.rabbitmq.client.impl.AMQImpl; +import com.sun.net.httpserver.HttpServer; +import java.lang.reflect.Proxy; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class RabbitMqAgentTest { + + // ------------------------------------------------------------------- + // Address parsing + // ------------------------------------------------------------------- + + @Test + void parsesCommaSeparatedHostPortPairs() { + List
addresses = RabbitMqAgent.parseAddresses("a:5672, b:5673", 5672); + assertEquals(2, addresses.size()); + assertEquals("a", addresses.get(0).getHost()); + assertEquals(5672, addresses.get(0).getPort()); + assertEquals("b", addresses.get(1).getHost()); + assertEquals(5673, addresses.get(1).getPort()); + } + + @Test + void bareHostFallsBackToDefaultPort() { + List
addresses = RabbitMqAgent.parseAddresses("rabbit.internal", 5672); + assertEquals(1, addresses.size()); + assertEquals("rabbit.internal", addresses.get(0).getHost()); + assertEquals(5672, addresses.get(0).getPort()); + } + + @Test + void skipsBlankAddressEntries() { + List
addresses = RabbitMqAgent.parseAddresses(" a:5672,, ", 5672); + assertEquals(1, addresses.size()); + assertEquals("a", addresses.get(0).getHost()); + } + + @Test + void rejectsBlankAddressList() { + assertThrows(IllegalArgumentException.class, () -> RabbitMqAgent.parseAddresses(" , ", 5672)); + } + + @Test + void resolveAddressesUsesPortParameterForBareHosts() { + JsonObject conn = JsonParser.parseString(""" + { "addresses": "host1,host2:5673", "port": 5670 } + """).getAsJsonObject(); + List
addresses = RabbitMqAgent.resolveAddresses(conn); + assertEquals(2, addresses.size()); + assertEquals(5670, addresses.get(0).getPort()); + assertEquals(5673, addresses.get(1).getPort()); + } + + @Test + void resolveAddressesDefaultsToAmqpPort() { + JsonObject conn = JsonParser.parseString(""" + { "addresses": "host1" } + """).getAsJsonObject(); + List
addresses = RabbitMqAgent.resolveAddresses(conn); + assertEquals(5672, addresses.get(0).getPort()); + } + + @Test + void resolveAddressesRequiresAddresses() { + JsonObject conn = JsonParser.parseString(""" + { "username": "guest" } + """).getAsJsonObject(); + assertThrows(IllegalArgumentException.class, () -> RabbitMqAgent.resolveAddresses(conn)); + } + + // ------------------------------------------------------------------- + // Peek normalization + // ------------------------------------------------------------------- + + @Test + void normalizesNegativePeekOffsetToZero() { + assertEquals(0L, RabbitMqAgent.normalizePeekOffset(-5)); + } + + @Test + void keepsPositivePeekOffset() { + assertEquals(7L, RabbitMqAgent.normalizePeekOffset(7)); + } + + @Test + void normalizesPeekCountToAtLeastOne() { + assertEquals(1, RabbitMqAgent.normalizePeekCount(0)); + assertEquals(1, RabbitMqAgent.normalizePeekCount(-3)); + assertEquals(10, RabbitMqAgent.normalizePeekCount(10)); + } + + // ------------------------------------------------------------------- + // Send routing key resolution + // ------------------------------------------------------------------- + + @Test + void routingKeyFallsBackToMessageKeyThenQueue() { + assertEquals("q1", RabbitMqAgent.resolveRoutingKey(JsonParser.parseString(""" + { "topic": "q1" } + """).getAsJsonObject(), "q1")); + assertEquals("orders.new", RabbitMqAgent.resolveRoutingKey(JsonParser.parseString(""" + { "topic": "q1", "key": "orders.new" } + """).getAsJsonObject(), "q1")); + assertEquals("rk", RabbitMqAgent.resolveRoutingKey(JsonParser.parseString(""" + { "topic": "q1", "key": "orders.new", "routing_key": "rk" } + """).getAsJsonObject(), "q1")); + assertEquals("rk2", RabbitMqAgent.resolveRoutingKey(JsonParser.parseString(""" + { "topic": "q1", "routingKey": "rk2" } + """).getAsJsonObject(), "q1")); + } + + @Test + void blankRoutingKeyFallsBackToQueue() { + assertEquals("q1", RabbitMqAgent.resolveRoutingKey(JsonParser.parseString(""" + { "topic": "q1", "key": "" } + """).getAsJsonObject(), "q1")); + assertEquals("q1", RabbitMqAgent.resolveRoutingKey(JsonParser.parseString(""" + { "topic": "q1", "key": " " } + """).getAsJsonObject(), "q1")); + assertEquals("q1", RabbitMqAgent.resolveRoutingKey(JsonParser.parseString(""" + { "topic": "q1", "routing_key": "", "key": "" } + """).getAsJsonObject(), "q1")); + } + + // ------------------------------------------------------------------- + // Connection factory + // ------------------------------------------------------------------- + + @Test + void buildsConnectionFactoryWithDefaults() throws Exception { + ConnectionFactory factory = RabbitMqAgent.buildConnectionFactory(JsonParser.parseString(""" + { "addresses": "localhost" } + """).getAsJsonObject()); + assertEquals("guest", factory.getUsername()); + assertEquals("/", factory.getVirtualHost()); + } + + @Test + void buildsConnectionFactoryWithVirtualHostAndCredentials() throws Exception { + ConnectionFactory factory = RabbitMqAgent.buildConnectionFactory(JsonParser.parseString(""" + { "addresses": "localhost", "username": "dbx", "password": "secret", "virtual_host": "/tenant" } + """).getAsJsonObject()); + assertEquals("dbx", factory.getUsername()); + assertEquals("/tenant", factory.getVirtualHost()); + } + + @Test + void appliesExtraPropertiesToConnectionFactory() throws Exception { + ConnectionFactory factory = RabbitMqAgent.buildConnectionFactory(JsonParser.parseString(""" + { + "addresses": "localhost", + "properties": { + "requested_heartbeat": 30, + "connection_timeout_ms": 5000, + "automatic_recovery": false + } + } + """).getAsJsonObject()); + assertEquals(30, factory.getRequestedHeartbeat()); + assertEquals(5000, factory.getConnectionTimeout()); + assertFalse(factory.isAutomaticRecoveryEnabled()); + } + + @Test + void enablesTlsWithoutVerificationWhenSkipVerifyRequested() throws Exception { + ConnectionFactory factory = RabbitMqAgent.buildConnectionFactory(JsonParser.parseString(""" + { "addresses": "localhost", "tls_skip_verify": true } + """).getAsJsonObject()); + assertTrue(factory.isSSL()); + } + + // ------------------------------------------------------------------- + // Management API helpers + // ------------------------------------------------------------------- + + @Test + void buildsBasicAuthHeader() { + assertEquals("Basic Z3Vlc3Q6Z3Vlc3Q=", RabbitMqAgent.basicAuthHeader("guest", "guest")); + } + + @Test + void buildsManagementBaseUrl() { + assertEquals("http://localhost:15672", RabbitMqAgent.managementBaseUrl("localhost", 15672, false)); + assertEquals("https://mq:15671", RabbitMqAgent.managementBaseUrl("mq", 15671, true)); + } + + @Test + void managementPortDefaultsTo15672Or15671ForTls() { + JsonObject plain = JsonParser.parseString(""" + { "addresses": "localhost" } + """).getAsJsonObject(); + assertEquals(15672, RabbitMqAgent.managementPort(plain, false)); + assertEquals(15671, RabbitMqAgent.managementPort(plain, true)); + } + + @Test + void managementPortCanBeOverriddenViaProperties() { + JsonObject conn = JsonParser.parseString(""" + { "addresses": "localhost", "properties": { "management_port": 55672 } } + """).getAsJsonObject(); + assertEquals(55672, RabbitMqAgent.managementPort(conn, false)); + } + + @Test + void managementErrorMessageBlamesCredentialsOn401And403() { + for (int status : new int[] {401, 403}) { + String message = RabbitMqAgent.managementErrorMessage(status, "GET", "/api/queues"); + assertTrue(message.contains("HTTP " + status), message); + assertTrue(message.contains("management permission tag"), message); + assertFalse(message.contains("rabbitmq_management plugin must be enabled"), message); + } + } + + @Test + void managementErrorMessageKeepsPluginHintForOtherStatuses() { + String message = RabbitMqAgent.managementErrorMessage(404, "GET", "/api/queues/%2F/gone"); + assertTrue(message.contains("HTTP 404")); + assertTrue(message.contains("rabbitmq_management plugin must be enabled")); + assertFalse(message.contains("management permission tag")); + } + + @Test + void managementRequestSurfaces401AsCredentialError() throws Exception { + // A local stub server standing in for the management API: the agent must + // attribute a 401 to credentials/permissions, not to a missing plugin. + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api", exchange -> { + exchange.sendResponseHeaders(401, -1); + exchange.close(); + }); + server.start(); + try { + JsonObject conn = JsonParser.parseString(""" + { "addresses": "127.0.0.1", "properties": { "management_port": %d } } + """.formatted(server.getAddress().getPort())).getAsJsonObject(); + Exception error = assertThrows(IllegalStateException.class, + () -> RabbitMqAgent.managementGet(conn, "/api/queues")); + assertTrue(error.getMessage().contains("HTTP 401"), error.getMessage()); + assertTrue(error.getMessage().contains("management permission tag"), error.getMessage()); + assertFalse(error.getMessage().contains("plugin must be enabled"), error.getMessage()); + } finally { + server.stop(0); + } + } + + @Test + void explicitManagementUrlIsUsedVerbatimWithTrailingSlashTrimmed() { + JsonObject conn = JsonParser.parseString(""" + { "addresses": "mq1:5672,mq2:5672", "management_url": "https://proxy:8443/rmq/" } + """).getAsJsonObject(); + assertEquals(List.of("https://proxy:8443/rmq"), RabbitMqAgent.managementBaseUrls(conn)); + } + + @Test + void explicitManagementUrlDoesNotRequireAddresses() { + JsonObject conn = JsonParser.parseString(""" + { "management_url": "http://mgmt:15672" } + """).getAsJsonObject(); + assertEquals(List.of("http://mgmt:15672"), RabbitMqAgent.managementBaseUrls(conn)); + } + + @Test + void derivedManagementBaseUrlsCoverAllAddresses() { + JsonObject conn = JsonParser.parseString(""" + { "addresses": "mq1:5672,mq2:5673" } + """).getAsJsonObject(); + assertEquals(List.of("http://mq1:15672", "http://mq2:15672"), + RabbitMqAgent.managementBaseUrls(conn)); + } + + @Test + void tlsSkipVerifyAloneDoesNotFlipDerivedSchemeToHttps() { + // tls_skip_verify is a verification flag, not a scheme indicator. + JsonObject skipVerifyOnly = JsonParser.parseString(""" + { "addresses": "mq1", "tls_skip_verify": true } + """).getAsJsonObject(); + assertEquals(List.of("http://mq1:15672"), RabbitMqAgent.managementBaseUrls(skipVerifyOnly)); + + JsonObject tlsObject = JsonParser.parseString(""" + { "addresses": "mq1", "tls": { "skip_verify": true } } + """).getAsJsonObject(); + assertEquals(List.of("https://mq1:15671"), RabbitMqAgent.managementBaseUrls(tlsObject)); + + JsonObject sslProperty = JsonParser.parseString(""" + { "addresses": "mq1", "properties": { "ssl": true } } + """).getAsJsonObject(); + assertEquals(List.of("https://mq1:15671"), RabbitMqAgent.managementBaseUrls(sslProperty)); + } + + @Test + void blankCredentialsFallBackToGuest() throws Exception { + ConnectionFactory factory = RabbitMqAgent.buildConnectionFactory(JsonParser.parseString(""" + { "addresses": "localhost", "username": "", "password": " " } + """).getAsJsonObject()); + assertEquals("guest", factory.getUsername()); + assertEquals("guest", factory.getPassword()); + + ConnectionFactory nullCredentials = RabbitMqAgent.buildConnectionFactory(JsonParser.parseString(""" + { "addresses": "localhost", "username": null } + """).getAsJsonObject()); + assertEquals("guest", nullCredentials.getUsername()); + } + + @Test + void managementGetAllPaginatesToLastPage() throws Exception { + List requestedPages = new ArrayList<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/queues", exchange -> { + String query = exchange.getRequestURI().getQuery(); + int page = Integer.parseInt(query.replaceAll(".*page=(\\d+).*", "$1")); + requestedPages.add(page); + byte[] body = switch (page) { + case 1 -> """ + { "items": [ { "name": "q1" } ], "page": 1, "page_count": 3, "total_count": 3 } + """.getBytes(StandardCharsets.UTF_8); + case 2 -> """ + { "items": [ { "name": "q2" } ], "page": 2, "page_count": 3, "total_count": 3 } + """.getBytes(StandardCharsets.UTF_8); + default -> """ + { "items": [ { "name": "q3" } ], "page": 3, "page_count": 3, "total_count": 3 } + """.getBytes(StandardCharsets.UTF_8); + }; + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + try { + JsonObject conn = JsonParser.parseString(""" + { "addresses": "127.0.0.1", "properties": { "management_port": %d } } + """.formatted(server.getAddress().getPort())).getAsJsonObject(); + JsonArray all = RabbitMqAgent.managementGetAll(conn, "/api/queues"); + assertEquals(3, all.size()); + assertEquals("q1", all.get(0).getAsJsonObject().get("name").getAsString()); + assertEquals("q3", all.get(2).getAsJsonObject().get("name").getAsString()); + assertEquals(List.of(1, 2, 3), requestedPages); + } finally { + server.stop(0); + } + } + + @Test + void managementGetAllAcceptsPlainArrayResponse() throws Exception { + List requests = new ArrayList<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/users", exchange -> { + requests.add(exchange.getRequestURI().toString()); + byte[] body = """ + [ { "name": "guest", "tags": "administrator" } ] + """.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + try { + JsonObject conn = JsonParser.parseString(""" + { "addresses": "127.0.0.1", "properties": { "management_port": %d } } + """.formatted(server.getAddress().getPort())).getAsJsonObject(); + JsonArray all = RabbitMqAgent.managementGetAll(conn, "/api/users"); + assertEquals(1, all.size()); + // A plain-array answer means the broker ignored pagination: stop there. + assertEquals(1, requests.size()); + } finally { + server.stop(0); + } + } + + @Test + void managementRequestFailsOverAcrossDerivedCandidates() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/queues", exchange -> { + byte[] body = "[]".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + try { + // 127.0.0.2 refuses the connection; the second candidate answers. + JsonObject conn = JsonParser.parseString(""" + { "addresses": "127.0.0.2,127.0.0.1", "properties": { "management_port": %d } } + """.formatted(server.getAddress().getPort())).getAsJsonObject(); + assertTrue(RabbitMqAgent.managementGet(conn, "/api/queues").isJsonArray()); + } finally { + server.stop(0); + } + } + + @Test + void httpErrorStatusDoesNotTriggerFailover() throws Exception { + HttpServer rejecting = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + rejecting.createContext("/api", exchange -> { + exchange.sendResponseHeaders(401, -1); + exchange.close(); + }); + rejecting.start(); + int port = rejecting.getAddress().getPort(); + try { + JsonObject conn = JsonParser.parseString(""" + { "addresses": "127.0.0.1,127.0.0.2", "properties": { "management_port": %d } } + """.formatted(port)).getAsJsonObject(); + Exception error = assertThrows(IllegalStateException.class, + () -> RabbitMqAgent.managementGet(conn, "/api/queues")); + // If the second candidate were attempted, its connection failure + // would replace this terminal HTTP status with an I/O error. + assertTrue(error.getMessage().contains("HTTP 401"), error.getMessage()); + } finally { + rejecting.stop(0); + } + } + + @Test + void managementUrlWithPathPrefixReachesStub() throws Exception { + List requests = new ArrayList<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/rmq/api/queues", exchange -> { + requests.add(exchange.getRequestURI().getRawPath()); + byte[] body = """ + [ { "name": "dbx-q1", "durable": true, "state": "running" } ] + """.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + try { + JsonObject response = JsonParser.parseString(RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 70, "method": "mq_list_topics", + "params": { "connection": { "addresses": "192.0.2.1:5672", + "management_url": "http://127.0.0.1:%d/rmq/" } } } + """.formatted(server.getAddress().getPort()))).getAsJsonObject(); + JsonArray topics = response.getAsJsonObject("result").getAsJsonArray("topics"); + assertEquals("dbx-q1", topics.get(0).getAsJsonObject().get("name").getAsString()); + // The reverse-proxy path prefix is preserved verbatim. + assertEquals("/rmq/api/queues/%2F", requests.get(0)); + } finally { + server.stop(0); + } + } + + @Test + void encodesDefaultVhostForManagementApi() { + assertEquals("%2F", RabbitMqAgent.urlEncodeVhost("/")); + assertEquals("tenant-a", RabbitMqAgent.urlEncodeVhost("tenant-a")); + } + + @Test + void urlEncodePathSegmentEncodesSpacesAsPercent20() { + // URLEncoder's form-style '+' for spaces 404s on the management API. + assertEquals("dbx-space%20test", RabbitMqAgent.urlEncodePathSegment("dbx-space test")); + assertEquals("plain-name", RabbitMqAgent.urlEncodePathSegment("plain-name")); + assertEquals("a%2Fb%3Ac", RabbitMqAgent.urlEncodePathSegment("a/b:c")); + assertEquals("%E4%B8%AD%E6%96%87%20queue", RabbitMqAgent.urlEncodePathSegment("中文 queue")); + } + + @Test + void tlsSkipVerifyReadsTopLevelAndNestedFlags() { + assertFalse(RabbitMqAgent.tlsSkipVerify(JsonParser.parseString(""" + { "addresses": "localhost" } + """).getAsJsonObject())); + assertTrue(RabbitMqAgent.tlsSkipVerify(JsonParser.parseString(""" + { "addresses": "localhost", "tls_skip_verify": true } + """).getAsJsonObject())); + assertTrue(RabbitMqAgent.tlsSkipVerify(JsonParser.parseString(""" + { "addresses": "localhost", "tls": { "skip_verify": true } } + """).getAsJsonObject())); + assertFalse(RabbitMqAgent.tlsSkipVerify(JsonParser.parseString(""" + { "addresses": "localhost", "tls": { "skip_verify": false } } + """).getAsJsonObject())); + } + + // ------------------------------------------------------------------- + // JSON-RPC envelope (no broker required) + // ------------------------------------------------------------------- + + @Test + void handshakeReportsCapabilities() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 1, "method": "handshake", "params": {} } + """); + JsonObject result = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("result"); + assertEquals(1, result.get("protocolVersion").getAsInt()); + assertTrue(result.getAsJsonArray("capabilities").toString().contains("mq_topics")); + assertTrue(result.getAsJsonArray("capabilities").toString().contains("mq_messages")); + } + + @Test + void unknownMethodReturnsError() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 2, "method": "mq_bogus", "params": {} } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("Unknown method")); + } + + @Test + void topicOperationsRequireConnection() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 3, "method": "mq_create_topic", "params": { "name": "q1" } } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("Not connected")); + } + + @Test + void alterTopicConfigIsRejectedAsUnsupported() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 4, "method": "mq_alter_topic_config", "params": { "name": "q1", "configs": [] } } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("immutable")); + } + + @Test + void testConnectionFailsFastWithoutAddresses() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 5, "method": "test_connection", "params": { "connection": { "addresses": "" } } } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("addresses is required")); + } + + @Test + void malformedRequestReturnsErrorWithNullId() { + // A request that is not valid JSON must not kill the agent process. + JsonObject response = JsonParser.parseString( + RabbitMqAgent.handleRequest("this is not json")).getAsJsonObject(); + assertTrue(response.get("id").isJsonNull()); + assertEquals(-1, response.getAsJsonObject("error").get("code").getAsInt()); + + JsonObject notAnObject = JsonParser.parseString( + RabbitMqAgent.handleRequest("[1, 2, 3]")).getAsJsonObject(); + assertTrue(notAnObject.get("id").isJsonNull()); + assertTrue(notAnObject.has("error")); + } + + @Test + void missingMethodReturnsErrorButKeepsRequestId() { + JsonObject response = JsonParser.parseString(RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 6, "params": {} } + """)).getAsJsonObject(); + assertEquals(6, response.get("id").getAsInt()); + assertEquals(-1, response.getAsJsonObject("error").get("code").getAsInt()); + } + + // ------------------------------------------------------------------- + // all_vhosts fail-fast on non-list operations + // ------------------------------------------------------------------- + + @Test + void allVhostsIsRejectedForNonListOperations() { + List methods = List.of( + "mq_create_topic", "mq_delete_topic", "mq_purge_queue", "mq_send_message", + "mq_bind", "mq_unbind", "mq_create_exchange", "mq_delete_exchange", + "mq_peek_messages", "mq_get_topic_stats", "mq_list_consumers", "mq_close_connection"); + for (String method : methods) { + JsonObject response = JsonParser.parseString(RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 40, "method": "%s", + "params": { "all_vhosts": true, "topic": "q1", "name": "q1", + "source": "ex1", "destination": "q1", "destinationType": "queue", + "type": "direct" } } + """.formatted(method))).getAsJsonObject(); + JsonObject error = response.getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt(), method); + assertEquals("all_vhosts is only supported for list operations", + error.get("message").getAsString(), method); + } + } + + @Test + void allVhostsRejectionPrecedesConnectionCheck() { + // The semantic error must win over "Not connected": no broker is needed. + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 41, "method": "mq_purge_queue", + "params": { "all_vhosts": true, "topic": "q1" } } + """); + String message = JsonParser.parseString(response).getAsJsonObject() + .getAsJsonObject("error").get("message").getAsString(); + assertTrue(message.contains("all_vhosts is only supported for list operations")); + assertFalse(message.contains("Not connected")); + } + + @Test + void listOperationsStillAcceptAllVhosts() { + // Without a connection these fail with "Not connected", proving the + // all_vhosts guard did not reject them first. + for (String method : List.of("mq_list_topics", "mq_list_exchanges", "mq_list_bindings", + "mq_list_connections", "mq_list_channels")) { + JsonObject response = JsonParser.parseString(RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 42, "method": "%s", "params": { "all_vhosts": true } } + """.formatted(method))).getAsJsonObject(); + String message = response.getAsJsonObject("error").get("message").getAsString(); + assertTrue(message.contains("Not connected"), method + ": " + message); + } + } + + // ------------------------------------------------------------------- + // Effective virtual host resolution + // ------------------------------------------------------------------- + + @Test + void explicitVirtualHostParameterWins() { + JsonObject conn = JsonParser.parseString(""" + { "addresses": "localhost", "virtual_host": "/default" } + """).getAsJsonObject(); + JsonObject params = JsonParser.parseString(""" + { "topic": "q1", "virtual_host": "/tenant" } + """).getAsJsonObject(); + assertEquals("/tenant", RabbitMqAgent.effectiveVhost(params, conn)); + } + + @Test + void blankVirtualHostFallsBackToConnectionVhost() { + JsonObject conn = JsonParser.parseString(""" + { "addresses": "localhost", "virtual_host": "/default" } + """).getAsJsonObject(); + assertEquals("/default", RabbitMqAgent.effectiveVhost(JsonParser.parseString(""" + { "topic": "q1", "virtual_host": "" } + """).getAsJsonObject(), conn)); + assertEquals("/default", RabbitMqAgent.effectiveVhost(JsonParser.parseString(""" + { "topic": "q1", "virtual_host": " " } + """).getAsJsonObject(), conn)); + assertEquals("/default", RabbitMqAgent.effectiveVhost(JsonParser.parseString(""" + { "topic": "q1", "virtual_host": null } + """).getAsJsonObject(), conn)); + } + + @Test + void missingVirtualHostFallsBackToConnectionThenSlash() { + JsonObject conn = JsonParser.parseString(""" + { "addresses": "localhost", "virtual_host": "/default" } + """).getAsJsonObject(); + JsonObject params = JsonParser.parseString(""" + { "topic": "q1" } + """).getAsJsonObject(); + assertEquals("/default", RabbitMqAgent.effectiveVhost(params, conn)); + assertEquals("/", RabbitMqAgent.effectiveVhost(params, null)); + JsonObject noVhostConn = JsonParser.parseString(""" + { "addresses": "localhost" } + """).getAsJsonObject(); + assertEquals("/", RabbitMqAgent.effectiveVhost(params, noVhostConn)); + } + + // ------------------------------------------------------------------- + // All-vhosts listing + // ------------------------------------------------------------------- + + @Test + void allVhostsRequestedDefaultsToFalse() { + assertFalse(RabbitMqAgent.allVhostsRequested(JsonParser.parseString(""" + { "virtual_host": "/tenant" } + """).getAsJsonObject())); + assertFalse(RabbitMqAgent.allVhostsRequested(JsonParser.parseString(""" + { "all_vhosts": false } + """).getAsJsonObject())); + assertTrue(RabbitMqAgent.allVhostsRequested(JsonParser.parseString(""" + { "all_vhosts": true } + """).getAsJsonObject())); + } + + @Test + void managementListPathScopesToEffectiveVhostByDefault() { + JsonObject conn = JsonParser.parseString(""" + { "addresses": "localhost", "virtual_host": "/default" } + """).getAsJsonObject(); + assertEquals("/api/queues/%2Fdefault", RabbitMqAgent.managementListPath( + JsonParser.parseString("{}").getAsJsonObject(), conn, "queues")); + assertEquals("/api/exchanges/%2Ftenant", RabbitMqAgent.managementListPath( + JsonParser.parseString(""" + { "virtual_host": "/tenant" } + """).getAsJsonObject(), conn, "exchanges")); + JsonObject noVhostConn = JsonParser.parseString(""" + { "addresses": "localhost" } + """).getAsJsonObject(); + assertEquals("/api/bindings/%2F", RabbitMqAgent.managementListPath( + JsonParser.parseString("{}").getAsJsonObject(), noVhostConn, "bindings")); + } + + @Test + void managementListPathUsesVhostlessVariantWhenAllVhosts() { + JsonObject conn = JsonParser.parseString(""" + { "addresses": "localhost", "virtual_host": "/default" } + """).getAsJsonObject(); + JsonObject params = JsonParser.parseString(""" + { "all_vhosts": true } + """).getAsJsonObject(); + assertEquals("/api/queues", RabbitMqAgent.managementListPath(params, conn, "queues")); + assertEquals("/api/exchanges", RabbitMqAgent.managementListPath(params, conn, "exchanges")); + assertEquals("/api/bindings", RabbitMqAgent.managementListPath(params, conn, "bindings")); + } + + @Test + void allVhostsWinsOverExplicitVirtualHost() { + JsonObject conn = JsonParser.parseString(""" + { "addresses": "localhost" } + """).getAsJsonObject(); + JsonObject params = JsonParser.parseString(""" + { "all_vhosts": true, "virtual_host": "/tenant" } + """).getAsJsonObject(); + assertEquals("/api/queues", RabbitMqAgent.managementListPath(params, conn, "queues")); + assertEquals("", RabbitMqAgent.vhostFilter(params, conn)); + } + + @Test + void vhostFilterPassesThroughExplicitVirtualHost() { + JsonObject conn = JsonParser.parseString(""" + { "addresses": "localhost", "virtual_host": "/default" } + """).getAsJsonObject(); + assertEquals("/tenant", RabbitMqAgent.vhostFilter(JsonParser.parseString(""" + { "virtual_host": "/tenant" } + """).getAsJsonObject(), conn)); + } + + @Test + void vhostFilterFallsBackToConnectionVhost() { + JsonObject conn = JsonParser.parseString(""" + { "addresses": "localhost", "virtual_host": "/default" } + """).getAsJsonObject(); + JsonObject params = JsonParser.parseString("{}").getAsJsonObject(); + assertEquals("/default", RabbitMqAgent.vhostFilter(params, conn)); + assertEquals("/", RabbitMqAgent.vhostFilter(params, null)); + JsonObject noVhostConn = JsonParser.parseString(""" + { "addresses": "localhost" } + """).getAsJsonObject(); + assertEquals("/", RabbitMqAgent.vhostFilter(params, noVhostConn)); + } + + @Test + void attachVhostCopiesSourceVhost() { + java.util.Map info = new java.util.LinkedHashMap<>(); + RabbitMqAgent.attachVhost(info, JsonParser.parseString(""" + { "name": "q1", "vhost": "/tenant-a" } + """).getAsJsonObject()); + assertEquals("/tenant-a", info.get("vhost")); + + java.util.Map missing = new java.util.LinkedHashMap<>(); + RabbitMqAgent.attachVhost(missing, JsonParser.parseString(""" + { "name": "q1" } + """).getAsJsonObject()); + assertEquals("", missing.get("vhost")); + } + + // ------------------------------------------------------------------- + // consumer_details mapping + // ------------------------------------------------------------------- + + @Test + void mapsConsumerDetailsFromQueueInfo() { + JsonObject info = JsonParser.parseString(""" + { + "name": "q1", + "consumer_details": [ + { + "consumer_tag": "amq.ctag-abc", + "ack_required": true, + "prefetch_count": 20, + "active": true, + "channel_details": { "name": "10.0.0.1:5672 -> 10.0.0.2:41234 (1)", "number": 1 } + }, + { + "consumer_tag": "amq.ctag-def", + "ack_required": false, + "active": false + } + ] + } + """).getAsJsonObject(); + var consumers = RabbitMqAgent.consumersFromQueueInfo(info); + assertEquals(2, consumers.size()); + + var first = consumers.get(0); + assertEquals("10.0.0.1:5672 -> 10.0.0.2:41234 (1)", first.get("name")); + assertEquals("amq.ctag-abc", first.get("tag")); + assertEquals(true, first.get("active")); + assertEquals(true, first.get("ackRequired")); + assertEquals(20, first.get("prefetch")); + + var second = consumers.get(1); + assertEquals("", second.get("name")); + assertEquals("amq.ctag-def", second.get("tag")); + assertEquals(false, second.get("active")); + assertEquals(false, second.get("ackRequired")); + assertFalse(second.containsKey("prefetch")); + } + + @Test + void missingConsumerDetailsMapsToEmptyList() { + assertTrue(RabbitMqAgent.consumersFromQueueInfo(JsonParser.parseString(""" + { "name": "q1" } + """).getAsJsonObject()).isEmpty()); + assertTrue(RabbitMqAgent.consumersFromQueueInfo(JsonParser.parseString(""" + { "name": "q1", "consumer_details": [] } + """).getAsJsonObject()).isEmpty()); + } + + // ------------------------------------------------------------------- + // Purge queue + // ------------------------------------------------------------------- + + @Test + void purgeQueueRequiresTopic() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 10, "method": "mq_purge_queue", "params": {} } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("topic (queue name) is required")); + } + + @Test + void purgeQueueRequiresConnection() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 11, "method": "mq_purge_queue", "params": { "topic": "q1" } } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("Not connected")); + } + + // ------------------------------------------------------------------- + // Consumers / namespaces (no broker required) + // ------------------------------------------------------------------- + + @Test + void listConsumersRequiresConnection() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 12, "method": "mq_list_consumers", "params": { "topic": "q1" } } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("Not connected")); + } + + @Test + void listNamespacesRequiresConnection() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 13, "method": "mq_list_namespaces", "params": {} } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("Not connected")); + } + + @Test + void createNamespaceRequiresName() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 14, "method": "mq_create_namespace", "params": { "namespace": " " } } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("namespace is required")); + } + + @Test + void createNamespaceRejectsAllVhostsMarker() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 14, "method": "mq_create_namespace", "params": { "namespace": "*" } } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("all-vhosts")); + } + + @Test + void deleteNamespaceRejectsAllVhostsMarker() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 15, "method": "mq_delete_namespace", "params": { "namespace": "*" } } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("all-vhosts")); + } + + @Test + void deleteNamespaceRejectsDefaultVhost() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 15, "method": "mq_delete_namespace", "params": { "namespace": "/" } } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("cannot be deleted")); + } + + @Test + void deleteNamespaceGuardRejectsConnectedVhost() { + assertThrows(IllegalArgumentException.class, + () -> RabbitMqAgent.assertNamespaceDeletable("/", null)); + assertThrows(IllegalArgumentException.class, + () -> RabbitMqAgent.assertNamespaceDeletable("/tenant", "/tenant")); + RabbitMqAgent.assertNamespaceDeletable("/tenant", "/"); + RabbitMqAgent.assertNamespaceDeletable("dbx-tier1-vhost", null); + } + + // ------------------------------------------------------------------- + // Exchanges & bindings + // ------------------------------------------------------------------- + + @Test + void validatesExchangeTypeWhitelist() { + assertEquals("direct", RabbitMqAgent.validateExchangeType("direct")); + assertEquals("fanout", RabbitMqAgent.validateExchangeType("fanout")); + assertEquals("topic", RabbitMqAgent.validateExchangeType("topic")); + assertEquals("headers", RabbitMqAgent.validateExchangeType("headers")); + assertThrows(IllegalArgumentException.class, () -> RabbitMqAgent.validateExchangeType("")); + assertThrows(IllegalArgumentException.class, () -> RabbitMqAgent.validateExchangeType("x-delayed-message")); + assertThrows(IllegalArgumentException.class, () -> RabbitMqAgent.validateExchangeType("Direct")); + } + + @Test + void exchangeDeletionGuardRejectsDefaultAndBuiltIns() { + assertThrows(IllegalArgumentException.class, () -> RabbitMqAgent.assertExchangeDeletable("")); + assertThrows(IllegalArgumentException.class, () -> RabbitMqAgent.assertExchangeDeletable("amq.direct")); + assertThrows(IllegalArgumentException.class, () -> RabbitMqAgent.assertExchangeDeletable("amq.topic")); + RabbitMqAgent.assertExchangeDeletable("dbx-ex-change"); + RabbitMqAgent.assertExchangeDeletable("amqp.custom"); + } + + @Test + void mapsExchangeInfoWithDefaultExchangeType() { + var defaultExchange = RabbitMqAgent.exchangeInfoFromJson(JsonParser.parseString(""" + { "name": "", "type": "", "durable": true, "auto_delete": false, "internal": false } + """).getAsJsonObject()); + assertEquals("", defaultExchange.get("name")); + assertEquals("default", defaultExchange.get("type")); + assertEquals(true, defaultExchange.get("durable")); + assertEquals(false, defaultExchange.get("autoDelete")); + assertEquals(false, defaultExchange.get("internal")); + } + + @Test + void mapsExchangeInfoKeepsDeclaredType() { + var exchange = RabbitMqAgent.exchangeInfoFromJson(JsonParser.parseString(""" + { "name": "amq.topic", "type": "topic", "durable": true, "auto_delete": false, "internal": false } + """).getAsJsonObject()); + assertEquals("amq.topic", exchange.get("name")); + assertEquals("topic", exchange.get("type")); + } + + @Test + void mapsBindingInfoToCamelCase() { + var binding = RabbitMqAgent.bindingInfoFromJson(JsonParser.parseString(""" + { + "source": "dbx-ex-change", + "destination": "dbx-ex-test", + "destination_type": "queue", + "routing_key": "dbx.key", + "arguments": { "x-match": "all", "retries": 3, "drop": null } + } + """).getAsJsonObject()); + assertEquals("dbx-ex-change", binding.get("source")); + assertEquals("dbx-ex-test", binding.get("destination")); + assertEquals("queue", binding.get("destinationType")); + assertEquals("dbx.key", binding.get("routingKey")); + var arguments = (java.util.Map) binding.get("arguments"); + assertEquals("all", arguments.get("x-match")); + assertEquals(3L, arguments.get("retries")); + assertFalse(arguments.containsKey("drop")); + } + + @Test + void bindingInfoOmitsEmptyArguments() { + var binding = RabbitMqAgent.bindingInfoFromJson(JsonParser.parseString(""" + { + "source": "ex1", + "destination": "ex2", + "destination_type": "exchange", + "routing_key": "", + "arguments": {} + } + """).getAsJsonObject()); + assertEquals("exchange", binding.get("destinationType")); + assertFalse(binding.containsKey("arguments")); + } + + @Test + void createExchangeRejectsInvalidTypeBeforeConnecting() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 20, "method": "mq_create_exchange", + "params": { "name": "ex1", "type": "bogus" } } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("Invalid exchange type")); + } + + @Test + void createExchangeRequiresName() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 21, "method": "mq_create_exchange", + "params": { "name": " ", "type": "direct" } } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("name is required")); + } + + @Test + void deleteExchangeRejectsDefaultAndBuiltInsBeforeConnecting() { + String defaultResponse = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 22, "method": "mq_delete_exchange", "params": { "name": "" } } + """); + assertTrue(JsonParser.parseString(defaultResponse).getAsJsonObject().getAsJsonObject("error") + .get("message").getAsString().contains("default exchange cannot be deleted")); + + String builtInResponse = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 23, "method": "mq_delete_exchange", "params": { "name": "amq.direct" } } + """); + assertTrue(JsonParser.parseString(builtInResponse).getAsJsonObject().getAsJsonObject("error") + .get("message").getAsString().contains("built-in exchange 'amq.direct' cannot be deleted")); + } + + @Test + void listExchangesRequiresConnection() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 24, "method": "mq_list_exchanges", "params": {} } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("Not connected")); + } + + @Test + void listBindingsRequiresConnection() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 25, "method": "mq_list_bindings", "params": { "queue": "q1" } } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("Not connected")); + } + + @Test + void bindRequiresSourceAndDestination() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 26, "method": "mq_bind", + "params": { "source": "", "destination": "q1", "destinationType": "queue" } } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("source is required")); + } + + @Test + void bindRejectsUnknownDestinationType() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 27, "method": "mq_bind", + "params": { "source": "ex1", "destination": "q1", "destinationType": "stream" } } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("destinationType must be 'queue' or 'exchange'")); + } + + // ------------------------------------------------------------------- + // Client connections & channels + // ------------------------------------------------------------------- + + @Test + void mapsClientConnectionInfoWithRatesAndConnectedAt() { + var connection = RabbitMqAgent.clientConnectionInfoFromJson(JsonParser.parseString(""" + { + "name": "10.0.0.1:52364 -> 10.0.0.2:5672", + "user": "spring", + "peer_host": "10.0.0.1", + "peer_port": 52364, + "state": "running", + "channels": 3, + "vhost": "/", + "recv_oct_details": { "rate": 12.5 }, + "send_oct_details": { "rate": 0.0 }, + "connected_at": 1751900000000 + } + """).getAsJsonObject()); + assertEquals("10.0.0.1:52364 -> 10.0.0.2:5672", connection.get("name")); + assertEquals("spring", connection.get("user")); + assertEquals("10.0.0.1", connection.get("peerHost")); + assertEquals(52364L, connection.get("peerPort")); + assertEquals("running", connection.get("state")); + assertEquals(3L, connection.get("channels")); + assertEquals(12.5, (Double) connection.get("recvRate"), 0.0001); + assertEquals(0.0, (Double) connection.get("sendRate"), 0.0001); + assertEquals(1751900000000L, connection.get("connectedAt")); + } + + @Test + void clientConnectionInfoOmitsMissingRatesAndConnectedAt() { + var connection = RabbitMqAgent.clientConnectionInfoFromJson(JsonParser.parseString(""" + { + "name": "c1", + "user": "guest", + "peer_host": "10.0.0.1", + "peer_port": 1, + "state": "blocked", + "channels": 0 + } + """).getAsJsonObject()); + assertEquals("c1", connection.get("name")); + assertFalse(connection.containsKey("recvRate")); + assertFalse(connection.containsKey("sendRate")); + assertFalse(connection.containsKey("connectedAt")); + } + + @Test + void mapsChannelInfoToCamelCase() { + var channel = RabbitMqAgent.channelInfoFromJson(JsonParser.parseString(""" + { + "name": "10.0.0.1:52364 -> 10.0.0.2:5672 (1)", + "connection_details": { "name": "10.0.0.1:52364 -> 10.0.0.2:5672" }, + "state": "running", + "prefetch_count": 20, + "messages_unacknowledged": 4, + "consumer_count": 2 + } + """).getAsJsonObject()); + assertEquals("10.0.0.1:52364 -> 10.0.0.2:5672 (1)", channel.get("name")); + assertEquals("10.0.0.1:52364 -> 10.0.0.2:5672", channel.get("connectionName")); + assertEquals("running", channel.get("state")); + assertEquals(20, channel.get("prefetch")); + assertEquals(4L, channel.get("messagesUnacked")); + assertEquals(2L, channel.get("consumerCount")); + } + + @Test + void channelInfoOmitsMissingOptionalFields() { + var channel = RabbitMqAgent.channelInfoFromJson(JsonParser.parseString(""" + { "name": "c (1)", "state": "running" } + """).getAsJsonObject()); + assertFalse(channel.containsKey("connectionName")); + assertFalse(channel.containsKey("prefetch")); + assertFalse(channel.containsKey("messagesUnacked")); + assertFalse(channel.containsKey("consumerCount")); + } + + @Test + void channelMatchesConnectionByDetailsNameOrNamePrefix() { + var channel = RabbitMqAgent.channelInfoFromJson(JsonParser.parseString(""" + { + "name": "10.0.0.1:52364 -> 10.0.0.2:5672 (1)", + "connection_details": { "name": "10.0.0.1:52364 -> 10.0.0.2:5672" } + } + """).getAsJsonObject()); + assertTrue(RabbitMqAgent.channelMatchesConnection(channel, "10.0.0.1:52364 -> 10.0.0.2:5672")); + // Prefix match works even without connection_details. + var noDetails = RabbitMqAgent.channelInfoFromJson(JsonParser.parseString(""" + { "name": "10.0.0.1:52364 -> 10.0.0.2:5672 (1)" } + """).getAsJsonObject()); + assertTrue(RabbitMqAgent.channelMatchesConnection(noDetails, "10.0.0.1:52364 -> 10.0.0.2:5672")); + assertFalse(RabbitMqAgent.channelMatchesConnection(channel, "10.0.0.9:11111 -> 10.0.0.2:5672")); + assertFalse(RabbitMqAgent.channelMatchesConnection(noDetails, "10.0.0.9:11111 -> 10.0.0.2:5672")); + } + + @Test + void urlEncodeNameEncodesSpacesAndArrows() { + assertEquals("10.0.0.1%3A52364%20-%3E%2010.0.0.2%3A5672", + RabbitMqAgent.urlEncodeName("10.0.0.1:52364 -> 10.0.0.2:5672")); + assertEquals("plain-name", RabbitMqAgent.urlEncodeName("plain-name")); + } + + @Test + void listConnectionsRequiresConnection() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 30, "method": "mq_list_connections", "params": {} } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("Not connected")); + } + + @Test + void listChannelsRequiresConnection() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 31, "method": "mq_list_channels", "params": {} } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("Not connected")); + } + + @Test + void closeConnectionRequiresName() { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 32, "method": "mq_close_connection", "params": { "name": " " } } + """); + JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error"); + assertEquals(-1, error.get("code").getAsInt()); + assertTrue(error.get("message").getAsString().contains("name is required")); + } + + // ------------------------------------------------------------------- + // AMQP error mapping + // ------------------------------------------------------------------- + + @Test + void mapsResourceLockedToExclusiveQueueHint() { + String message = RabbitMqAgent.mapAmqpError(405, + "RESOURCE_LOCKED - cannot obtain exclusive access to locked queue " + + "'springCloudBus.anonymous.abc' in vhost '/'"); + assertTrue(message.contains("Queue 'springCloudBus.anonymous.abc' is exclusive")); + assertTrue(message.contains("owned by another connection")); + assertTrue(message.contains("Hint:")); + assertTrue(message.contains("management API")); + } + + @Test + void mapsResourceLockedWithoutQueueName() { + String message = RabbitMqAgent.mapAmqpError(405, "RESOURCE_LOCKED"); + assertTrue(message.startsWith("The queue is exclusive")); + assertTrue(message.contains("Hint:")); + } + + @Test + void mapsNotFoundToFriendlyQueueMessage() { + String message = RabbitMqAgent.mapAmqpError(404, "NOT_FOUND - no queue 'gone' in vhost '/'"); + assertEquals("Queue 'gone' was not found." + + " Hint: it may have been deleted, or it never existed on this virtual host.", message); + } + + @Test + void mapsNotFoundForExchange() { + String message = RabbitMqAgent.mapAmqpError(404, "NOT_FOUND - no exchange 'ex1' in vhost '/'"); + assertTrue(message.startsWith("Exchange 'ex1' was not found.")); + } + + @Test + void mapsPreconditionFailedToImmutableParametersHint() { + String message = RabbitMqAgent.mapAmqpError(406, + "PRECONDITION_FAILED - inequivalent arg 'durable' for queue 'q1' in vhost '/':" + + " received 'false' but current is 'true'"); + // The resource name is the queue, not the mismatched argument. + assertTrue(message.contains("Queue 'q1' already exists with different parameters.")); + assertFalse(message.contains("'durable' already exists")); + assertTrue(message.contains("Hint:")); + assertTrue(message.contains("immutable")); + assertTrue(message.contains("delete and re-declare")); + } + + @Test + void mapsPreconditionFailedForExchange() { + String message = RabbitMqAgent.mapAmqpError(406, + "PRECONDITION_FAILED - inequivalent arg 'type' for exchange 'ex1' in vhost '/':" + + " received 'fanout' but current is 'direct'"); + assertTrue(message.startsWith("Exchange 'ex1' already exists with different parameters.")); + assertTrue(message.contains("delete and re-declare the exchange")); + } + + @Test + void mapsPreconditionFailedWithoutResourceName() { + String message = RabbitMqAgent.mapAmqpError(406, "PRECONDITION_FAILED"); + assertTrue(message.startsWith("The queue already exists with different parameters.")); + assertTrue(message.contains("Hint:")); + } + + @Test + void mapsAccessRefusedToPermissionHint() { + String message = RabbitMqAgent.mapAmqpError(403, + "ACCESS_REFUSED - access to queue 'q1' in vhost '/' refused for user 'dbx'"); + assertTrue(message.contains("Access to 'q1' was refused.")); + assertTrue(message.contains("Hint:")); + assertTrue(message.contains("configure/write/read permissions")); + } + + @Test + void mapsAccessRefusedWithoutResourceName() { + String message = RabbitMqAgent.mapAmqpError(403, "ACCESS_REFUSED"); + assertTrue(message.startsWith("Access to the requested resource was refused.")); + assertTrue(message.contains("Hint:")); + } + + @Test + void leavesOtherReplyCodesUnmapped() { + assertNull(RabbitMqAgent.mapAmqpError(503, "COMMAND_INVALID")); + assertNull(RabbitMqAgent.mapAmqpError(501, "FRAME_ERROR")); + } + + @Test + void extractsDeclaredResourceNameFromPreconditionFailedText() { + assertEquals("q1", RabbitMqAgent.extractDeclaredResourceName( + "PRECONDITION_FAILED - inequivalent arg 'durable' for queue 'q1' in vhost '/'")); + assertEquals("ex1", RabbitMqAgent.extractDeclaredResourceName( + "PRECONDITION_FAILED - inequivalent arg 'type' for exchange 'ex1' in vhost '/'")); + assertNull(RabbitMqAgent.extractDeclaredResourceName("no resource here")); + assertNull(RabbitMqAgent.extractDeclaredResourceName(null)); + } + + @Test + void extractsFirstQuotedNameFromReplyText() { + assertEquals("q1", RabbitMqAgent.extractQuotedName("NOT_FOUND - no queue 'q1' in vhost '/'")); + assertNull(RabbitMqAgent.extractQuotedName("no quoted name here")); + assertNull(RabbitMqAgent.extractQuotedName(null)); + } + + @Test + void normalizeErrorMessageUsesAmqpFriendlyMapping() { + ShutdownSignalException shutdown = new ShutdownSignalException(true, false, + new AMQImpl.Channel.Close(405, + "RESOURCE_LOCKED - cannot obtain exclusive access to locked queue 'q1' in vhost '/'", + 0, 0), null); + String message = RabbitMqAgent.normalizeErrorMessage( + new java.io.IOException("channel is already closed", shutdown)); + assertTrue(message.contains("Queue 'q1' is exclusive")); + assertFalse(message.contains("RESOURCE_LOCKED")); + } + + @Test + void normalizeErrorMessageKeepsRawMessageForUnmappedCodes() { + ShutdownSignalException shutdown = new ShutdownSignalException(true, false, + new AMQImpl.Channel.Close(503, "COMMAND_INVALID - unknown method", 0, 0), null); + String message = RabbitMqAgent.normalizeErrorMessage(new java.io.IOException("boom", shutdown)); + assertTrue(message.contains("COMMAND_INVALID")); + } + + @Test + void normalizeErrorMessagePassesThroughPlainExceptions() { + String message = RabbitMqAgent.normalizeErrorMessage(new IllegalStateException("Not connected")); + assertEquals("Not connected", message); + } + + // ------------------------------------------------------------------- + // Users & permissions + // ------------------------------------------------------------------- + + @Test + void mapsUserInfoWithTagsArray() { + var user = RabbitMqAgent.userInfoFromJson(JsonParser.parseString(""" + { "name": "jjsd", "tags": "administrator,management" } + """).getAsJsonObject()); + assertEquals("jjsd", user.get("name")); + assertEquals(List.of("administrator", "management"), user.get("tags")); + } + + @Test + void parseUserTagsTrimsAndDropsBlanks() { + assertEquals(List.of("administrator", "monitoring"), + RabbitMqAgent.parseUserTags("administrator, monitoring,,")); + assertTrue(RabbitMqAgent.parseUserTags("").isEmpty()); + assertTrue(RabbitMqAgent.parseUserTags(" , ").isEmpty()); + } + + @Test + void userTagsParamAcceptsArrayOrString() { + assertEquals("management,policymaker", RabbitMqAgent.userTagsParam(JsonParser.parseString(""" + { "tags": ["management", " policymaker "] } + """).getAsJsonObject())); + assertEquals("administrator", RabbitMqAgent.userTagsParam(JsonParser.parseString(""" + { "tags": "administrator" } + """).getAsJsonObject())); + assertEquals("", RabbitMqAgent.userTagsParam(JsonParser.parseString(""" + { "name": "dbx-test-user" } + """).getAsJsonObject())); + } + + @Test + void mapsPermissionInfo() { + var permission = RabbitMqAgent.permissionInfoFromJson(JsonParser.parseString(""" + { "user": "jjsd", "vhost": "/", "configure": ".*", "write": ".*", "read": ".*" } + """).getAsJsonObject()); + assertEquals("jjsd", permission.get("user")); + assertEquals("/", permission.get("vhost")); + assertEquals(".*", permission.get("configure")); + assertEquals(".*", permission.get("write")); + assertEquals(".*", permission.get("read")); + } + + @Test + void permissionPatternDefaultsToMatchAll() { + JsonObject params = JsonParser.parseString(""" + { "write": "^dbx-", "read": "" } + """).getAsJsonObject(); + assertEquals(".*", RabbitMqAgent.permissionPattern(params, "configure")); + assertEquals("^dbx-", RabbitMqAgent.permissionPattern(params, "write")); + assertEquals(".*", RabbitMqAgent.permissionPattern(params, "read")); + } + + @Test + void permissionVhostRejectsBlankAndAllVhostsSentinel() { + assertThrows(IllegalArgumentException.class, () -> RabbitMqAgent.permissionVhost( + JsonParser.parseString("{}").getAsJsonObject())); + assertThrows(IllegalArgumentException.class, () -> RabbitMqAgent.permissionVhost( + JsonParser.parseString(""" + { "virtual_host": "*" } + """).getAsJsonObject())); + assertEquals("/", RabbitMqAgent.permissionVhost(JsonParser.parseString(""" + { "virtual_host": "/" } + """).getAsJsonObject())); + } + + @Test + void userGuardRejectsConnectedUser() { + assertThrows(IllegalArgumentException.class, + () -> RabbitMqAgent.assertNotConnectedUser("delete", "jjsd", "jjsd")); + assertThrows(IllegalArgumentException.class, + () -> RabbitMqAgent.assertNotConnectedUser("create or modify", "jjsd", "jjsd")); + RabbitMqAgent.assertNotConnectedUser("delete", "dbx-test-user", "jjsd"); + } + + @Test + void createUserRequiresNameAndPassword() { + String noName = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 50, "method": "mq_create_user", "params": { "password": "x" } } + """); + assertTrue(JsonParser.parseString(noName).getAsJsonObject().getAsJsonObject("error") + .get("message").getAsString().contains("user name is required")); + + String noPassword = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 51, "method": "mq_create_user", "params": { "name": "dbx-test-user" } } + """); + assertTrue(JsonParser.parseString(noPassword).getAsJsonObject().getAsJsonObject("error") + .get("message").getAsString().contains("password is required")); + } + + @Test + void grantAndRevokeRequireVhostBeforeConnecting() { + String grant = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 52, "method": "mq_grant_permission", "params": { "user": "dbx-test-user" } } + """); + String grantMessage = JsonParser.parseString(grant).getAsJsonObject().getAsJsonObject("error") + .get("message").getAsString(); + assertTrue(grantMessage.contains("virtual_host is required"), grantMessage); + + String revoke = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 53, "method": "mq_revoke_permission", + "params": { "user": "dbx-test-user", "virtual_host": "*" } } + """); + String revokeMessage = JsonParser.parseString(revoke).getAsJsonObject().getAsJsonObject("error") + .get("message").getAsString(); + assertTrue(revokeMessage.contains("all_vhosts is only supported for list operations"), revokeMessage); + } + + @Test + void grantAndRevokeRejectAllVhostsFlag() { + for (String method : List.of("mq_grant_permission", "mq_revoke_permission")) { + JsonObject response = JsonParser.parseString(RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 54, "method": "%s", + "params": { "all_vhosts": true, "user": "dbx-test-user", "virtual_host": "/" } } + """.formatted(method))).getAsJsonObject(); + assertEquals("all_vhosts is only supported for list operations", + response.getAsJsonObject("error").get("message").getAsString(), method); + } + } + + @Test + void userAndPermissionOperationsRequireConnection() { + List requests = List.of(""" + { "jsonrpc": "2.0", "id": 55, "method": "mq_list_users", "params": {} } + """, """ + { "jsonrpc": "2.0", "id": 56, "method": "mq_list_permissions", "params": {} } + """, """ + { "jsonrpc": "2.0", "id": 57, "method": "mq_delete_user", "params": { "name": "dbx-test-user" } } + """); + for (String request : requests) { + String message = JsonParser.parseString(RabbitMqAgent.handleRequest(request)) + .getAsJsonObject().getAsJsonObject("error").get("message").getAsString(); + assertTrue(message.contains("Not connected"), message); + } + } + + @Test + void deleteUserRejectsConnectedUserBeforeHttpCall() { + // The guard must win over the management API call: no broker is needed. + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 58, "method": "mq_delete_user", + "params": { "name": "jjsd", + "connection": { "addresses": "127.0.0.1:1", "username": "jjsd" } } } + """); + String message = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error") + .get("message").getAsString(); + assertTrue(message.contains("Cannot delete user 'jjsd' while connected as that user"), message); + } + + // ------------------------------------------------------------------- + // Policies + // ------------------------------------------------------------------- + + @Test + void policyInfoMapsApplyToAndDefinition() { + Map policy = RabbitMqAgent.policyInfoFromJson(JsonParser.parseString(""" + { "name": "dbx-pol", "vhost": "/", "pattern": "^dbx-", + "apply-to": "exchanges", "priority": 5, + "definition": { "max-length": 100, "alternate-exchange": "dbx-ae", "skip": null } } + """).getAsJsonObject()); + assertEquals("dbx-pol", policy.get("name")); + assertEquals("/", policy.get("vhost")); + assertEquals("^dbx-", policy.get("pattern")); + assertEquals("exchanges", policy.get("applyTo")); + assertEquals(5L, policy.get("priority")); + @SuppressWarnings("unchecked") + Map definition = (Map) policy.get("definition"); + assertEquals(100L, definition.get("max-length")); + assertEquals("dbx-ae", definition.get("alternate-exchange")); + assertFalse(definition.containsKey("skip")); + } + + @Test + void listPoliciesMapsEntriesViaManagementApi() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/policies", exchange -> { + byte[] body = """ + [ { "name": "dbx-pol", "vhost": "/", "pattern": "^dbx-", + "apply-to": "queues", "priority": 0, + "definition": { "max-length": 100 } } ] + """.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + try { + JsonObject response = JsonParser.parseString(RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 60, "method": "mq_list_policies", + "params": { "all_vhosts": true, + "connection": { "addresses": "127.0.0.1", + "properties": { "management_port": %d } } } } + """.formatted(server.getAddress().getPort()))).getAsJsonObject(); + JsonObject policy = response.getAsJsonObject("result").getAsJsonArray("policies") + .get(0).getAsJsonObject(); + assertEquals("dbx-pol", policy.get("name").getAsString()); + assertEquals("/", policy.get("vhost").getAsString()); + assertEquals("queues", policy.get("applyTo").getAsString()); + assertEquals(100, policy.getAsJsonObject("definition").get("max-length").getAsInt()); + } finally { + server.stop(0); + } + } + + @Test + void setPolicyAppliesDefaultsAndMapsApplyTo() throws Exception { + String[] capturedRequest = new String[2]; // [0] "METHOD path", [1] request body + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + capturedRequest[0] = exchange.getRequestMethod() + " " + exchange.getRequestURI().getRawPath(); + capturedRequest[1] = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + exchange.sendResponseHeaders(204, -1); + exchange.close(); + }); + server.start(); + try { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 61, "method": "mq_set_policy", + "params": { "virtual_host": "/", "name": "dbx-pol", "pattern": "^dbx-", + "definition": { "max-length": 100 }, + "connection": { "addresses": "127.0.0.1", + "properties": { "management_port": %d } } } } + """.formatted(server.getAddress().getPort())); + assertTrue(JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("result") + .get("ok").getAsBoolean()); + assertEquals("PUT /api/policies/%2F/dbx-pol", capturedRequest[0]); + JsonObject body = JsonParser.parseString(capturedRequest[1]).getAsJsonObject(); + // applyTo defaults to queues and priority to 0. + assertEquals("queues", body.get("apply-to").getAsString()); + assertEquals(0, body.get("priority").getAsInt()); + assertEquals("^dbx-", body.get("pattern").getAsString()); + assertEquals(100, body.getAsJsonObject("definition").get("max-length").getAsInt()); + } finally { + server.stop(0); + } + } + + @Test + void deletePolicyCallsManagementApiDelete() throws Exception { + String[] capturedRequest = new String[1]; + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + capturedRequest[0] = exchange.getRequestMethod() + " " + exchange.getRequestURI().getRawPath(); + exchange.sendResponseHeaders(204, -1); + exchange.close(); + }); + server.start(); + try { + String response = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 62, "method": "mq_delete_policy", + "params": { "virtual_host": "/", "name": "dbx-pol", + "connection": { "addresses": "127.0.0.1", + "properties": { "management_port": %d } } } } + """.formatted(server.getAddress().getPort())); + assertTrue(JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("result") + .get("ok").getAsBoolean()); + assertEquals("DELETE /api/policies/%2F/dbx-pol", capturedRequest[0]); + } finally { + server.stop(0); + } + } + + @Test + void setAndDeletePolicyRejectAllVhostsSentinel() { + for (String method : List.of("mq_set_policy", "mq_delete_policy")) { + JsonObject response = JsonParser.parseString(RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 63, "method": "%s", + "params": { "virtual_host": "*", "name": "dbx-pol", "pattern": "^dbx-", + "definition": {} } } + """.formatted(method))).getAsJsonObject(); + assertEquals("all_vhosts is only supported for list operations", + response.getAsJsonObject("error").get("message").getAsString(), method); + } + } + + @Test + void setAndDeletePolicyRejectAllVhostsFlag() { + for (String method : List.of("mq_set_policy", "mq_delete_policy")) { + JsonObject response = JsonParser.parseString(RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 64, "method": "%s", + "params": { "all_vhosts": true, "virtual_host": "/", "name": "dbx-pol" } } + """.formatted(method))).getAsJsonObject(); + assertEquals("all_vhosts is only supported for list operations", + response.getAsJsonObject("error").get("message").getAsString(), method); + } + } + + @Test + void setPolicyRequiresNamePatternAndDefinition() { + String noName = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 65, "method": "mq_set_policy", + "params": { "virtual_host": "/" } } + """); + assertTrue(JsonParser.parseString(noName).getAsJsonObject().getAsJsonObject("error") + .get("message").getAsString().contains("name is required")); + + String noPattern = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 66, "method": "mq_set_policy", + "params": { "virtual_host": "/", "name": "dbx-pol", "definition": {} } } + """); + assertTrue(JsonParser.parseString(noPattern).getAsJsonObject().getAsJsonObject("error") + .get("message").getAsString().contains("pattern is required")); + + String noDefinition = RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 67, "method": "mq_set_policy", + "params": { "virtual_host": "/", "name": "dbx-pol", "pattern": "^dbx-" } } + """); + assertTrue(JsonParser.parseString(noDefinition).getAsJsonObject().getAsJsonObject("error") + .get("message").getAsString().contains("definition is required")); + } + + // ------------------------------------------------------------------- + // Overview & nodes + // ------------------------------------------------------------------- + + @Test + void overviewInfoMapsTotalsAndRates() { + Map overview = RabbitMqAgent.overviewInfoFromJson(JsonParser.parseString(""" + { "queue_totals": { "messages_ready": 12, "messages_unacknowledged": 3 }, + "message_stats": { "publish": 100, "publish_details": { "rate": 1.5 }, + "deliver_get": 90, "deliver_get_details": { "rate": 2.5 }, + "ack": 80, "ack_details": { "rate": 0.5 } }, + "object_totals": { "connections": 4, "channels": 6, "exchanges": 8, + "queues": 10, "consumers": 2 } } + """).getAsJsonObject()); + assertEquals(12L, overview.get("messagesReady")); + assertEquals(3L, overview.get("messagesUnacked")); + assertEquals(1.5, (Double) overview.get("publishRate"), 0.0001); + assertEquals(2.5, (Double) overview.get("deliverRate"), 0.0001); + assertEquals(0.5, (Double) overview.get("ackRate"), 0.0001); + assertEquals(10L, overview.get("totalQueues")); + assertEquals(8L, overview.get("totalExchanges")); + assertEquals(4L, overview.get("totalConnections")); + assertEquals(6L, overview.get("totalChannels")); + assertEquals(2L, overview.get("totalConsumers")); + } + + @Test + void overviewInfoOmitsMissingStats() { + Map overview = RabbitMqAgent.overviewInfoFromJson(JsonParser.parseString(""" + { "queue_totals": { "messages_ready": 1 } } + """).getAsJsonObject()); + assertEquals(1L, overview.get("messagesReady")); + assertFalse(overview.containsKey("messagesUnacked")); + assertFalse(overview.containsKey("publishRate")); + assertFalse(overview.containsKey("totalQueues")); + } + + @Test + void nodeInfoMapsSnakeCaseToCamelCase() { + Map node = RabbitMqAgent.nodeInfoFromJson(JsonParser.parseString(""" + { "name": "rabbit@node1", "running": true, "mem_used": 1000, "mem_limit": 2000, + "disk_free": 3000, "fd_used": 10, "fd_total": 100, "sockets_used": 5, + "sockets_total": 50, "uptime": 123456 } + """).getAsJsonObject()); + assertEquals("rabbit@node1", node.get("name")); + assertEquals(true, node.get("running")); + assertEquals(1000L, node.get("memUsed")); + assertEquals(2000L, node.get("memLimit")); + assertEquals(3000L, node.get("diskFree")); + assertEquals(10L, node.get("fdUsed")); + assertEquals(100L, node.get("fdTotal")); + assertEquals(5L, node.get("socketsUsed")); + assertEquals(50L, node.get("socketsTotal")); + assertEquals(123456L, node.get("uptimeMs")); + } + + @Test + void listNodesMapsEntriesViaManagementApi() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/nodes", exchange -> { + byte[] body = """ + [ { "name": "rabbit@node1", "running": true, "mem_used": 1000, + "uptime": 123456 } ] + """.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + try { + JsonObject response = JsonParser.parseString(RabbitMqAgent.handleRequest(""" + { "jsonrpc": "2.0", "id": 68, "method": "mq_list_nodes", + "params": { "connection": { "addresses": "127.0.0.1", + "properties": { "management_port": %d } } } } + """.formatted(server.getAddress().getPort()))).getAsJsonObject(); + JsonObject node = response.getAsJsonObject("result").getAsJsonArray("nodes") + .get(0).getAsJsonObject(); + assertEquals("rabbit@node1", node.get("name").getAsString()); + assertTrue(node.get("running").getAsBoolean()); + assertEquals(1000, node.get("memUsed").getAsLong()); + assertEquals(123456, node.get("uptimeMs").getAsLong()); + } finally { + server.stop(0); + } + } + + // ------------------------------------------------------------------- + // Channel self-healing decision + // ------------------------------------------------------------------- + + @Test + void nullChannelNeedsRecreation() { + assertTrue(RabbitMqAgent.needsNewChannel(null)); + } + + @Test + void closedChannelNeedsRecreation() { + assertTrue(RabbitMqAgent.needsNewChannel(stubChannel(false))); + } + + @Test + void openChannelIsReused() { + assertFalse(RabbitMqAgent.needsNewChannel(stubChannel(true))); + } + + private static Channel stubChannel(boolean open) { + return (Channel) Proxy.newProxyInstance( + RabbitMqAgentTest.class.getClassLoader(), + new Class[] { Channel.class }, + (proxy, method, args) -> { + if ("isOpen".equals(method.getName())) { + return open; + } + throw new UnsupportedOperationException(method.getName()); + }); + } +} diff --git a/agents/scripts/validate_agents.py b/agents/scripts/validate_agents.py index b497c859b..7e75c4122 100644 --- a/agents/scripts/validate_agents.py +++ b/agents/scripts/validate_agents.py @@ -11,7 +11,7 @@ SOURCE_GLOBS = ("*/src/main/**/*.java", "drivers/*/src/main/**/*.java") KOTLIN_FILE_SUFFIXES = (".kt", ".kts") KOTLIN_SCAN_EXCLUDED_PARTS = {".git", ".gradle", "build"} DEFAULT_AGENT_JRE_KEY = "21" -NON_JDBC_AGENT_MODULES = {"mongodb", "etcd", "zookeeper", "kafka", "rocketmq"} +NON_JDBC_AGENT_MODULES = {"mongodb", "etcd", "zookeeper", "kafka", "rocketmq", "rabbitmq"} NATIVE_ONLY_AGENT_MODULES = { "oracle": "drivers/oracle-go", "xugu": "drivers/xugu", diff --git a/agents/settings.gradle b/agents/settings.gradle index 0dc271153..c91774f88 100644 --- a/agents/settings.gradle +++ b/agents/settings.gradle @@ -6,7 +6,7 @@ def driverModules = [ 'teradata', 'vertica', 'firebird', 'exasol', 'oceanbase-oracle', 'gbase8a', 'gbase8s', 'bigquery', 'kylin', 'sundb', 'h2', 'h2-legacy', 'snowflake', 'trino', 'hive', 'spark', 'db2', 'informix', 'neo4j', 'cassandra', 'mongodb', 'highgo', 'tdengine', 'yashandb', 'oscar', - 'iris', 'iotdb', 'etcd', 'zookeeper', 'kafka', 'rocketmq', 'sqlserver-legacy' + 'iris', 'iotdb', 'etcd', 'zookeeper', 'kafka', 'rocketmq', 'rabbitmq', 'sqlserver-legacy' ] include(*(infrastructureModules + driverModules)) diff --git a/agents/versions.json b/agents/versions.json index 6d09ed1ef..3f5ecedee 100644 --- a/agents/versions.json +++ b/agents/versions.json @@ -40,5 +40,6 @@ "zookeeper": "0.1.11", "kafka": "0.1.4", "rocketmq": "0.1.0", + "rabbitmq": "0.1.0", "sqlserver-legacy": "0.1.4" } diff --git a/apps/desktop/public/icons/database/rabbitmq.svg b/apps/desktop/public/icons/database/rabbitmq.svg new file mode 100644 index 000000000..a4947bc27 --- /dev/null +++ b/apps/desktop/public/icons/database/rabbitmq.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 3c1613363..477154324 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -49,6 +49,7 @@ import { postgresTlsModeForForm } from "@/lib/connection/postgresTlsMode"; import { normalizeKafkaBootstrapServers } from "@/lib/connection/kafkaBootstrapServers"; import { assertCompleteDatabaseCategories, databaseSelectionForCategory } from "@/lib/connection/databaseCategoryOptions"; import { normalizeRocketmqNamesrvAddr } from "@/lib/connection/rocketmqNamesrv"; +import { normalizeRabbitmqAddresses } from "@/lib/connection/rabbitmqAddresses"; import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth"; import { driverInstallProgressPercent, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi"; import { requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityConfig, sqlServerUsesLegacyCompatibility, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility"; @@ -518,6 +519,8 @@ const mqAdminUrl = ref("http://127.0.0.1:8080"); const mqSystemKind = ref("pulsar"); const mqRocketmqNamesrvAddr = ref("127.0.0.1:9876"); const mqRocketmqClusterName = ref(""); +const mqRabbitmqAddresses = ref("127.0.0.1:5672"); +const mqRabbitmqVirtualHost = ref("/"); const mqKafkaBootstrapServers = ref("127.0.0.1:9092"); const mqKafkaSecurityProtocol = ref(MQ_KAFKA_SECURITY_PROTOCOL_AUTO); const mqKafkaSaslMechanism = ref("PLAIN"); @@ -544,11 +547,13 @@ const MQ_DRIVER_LABELS: Record = { pulsar: "Apache Pulsar", kafka: "Apache Kafka", rocketmq: "Apache RocketMQ", + rabbitmq: "RabbitMQ", }; function mqSystemKindFromProfile(profile: string): MqSystemKind { if (profile === "kafka") return "kafka"; if (profile === "rocketmq") return "rocketmq"; + if (profile === "rabbitmq") return "rabbitmq"; return "pulsar"; } @@ -558,7 +563,7 @@ function syncMqSystemKindFromSelectedType() { } function resolveMqSystemKind(config?: Partial): MqSystemKind { - if (config?.systemKind === "kafka" || config?.systemKind === "rocketmq" || config?.systemKind === "pulsar") { + if (config?.systemKind === "kafka" || config?.systemKind === "rocketmq" || config?.systemKind === "rabbitmq" || config?.systemKind === "pulsar") { return config.systemKind; } return mqSystemKindFromProfile(selectedType.value); @@ -804,6 +809,7 @@ const driverProfiles: Record< mq: { type: "mq", port: 8080, user: "", label: "Apache Pulsar", icon: "pulsar", host: "127.0.0.1" }, kafka: { type: "mq", port: 9092, user: "", label: "Apache Kafka", icon: "kafka", host: "127.0.0.1" }, rocketmq: { type: "mq", port: 9876, user: "", label: "Apache RocketMQ", icon: "rocketmq", host: "127.0.0.1" }, + rabbitmq: { type: "mq", port: 5672, user: "", label: "RabbitMQ", icon: "rabbitmq", host: "127.0.0.1" }, nacos: { type: "nacos", port: 8848, user: "nacos", label: "Nacos", icon: "nacos", host: "127.0.0.1" }, iris: { type: "iris", port: 1972, user: "_SYSTEM", label: "IRIS", icon: "iris" }, influxdb: { type: "influxdb", port: 8086, user: "", label: "InfluxDB", icon: "InfluxDB" }, @@ -835,6 +841,7 @@ function profileForConfig(config: ConnectionConfig) { const kind = (config.external_config as MqAdminConfig | undefined)?.systemKind; if (kind === "kafka") return "kafka"; if (kind === "rocketmq") return "rocketmq"; + if (kind === "rabbitmq") return "rabbitmq"; return "mq"; } if (config.db_type === "dameng") return "dm"; @@ -883,10 +890,13 @@ function resetMqFields(config?: Partial) { const properties = mqExtraProperties(extra); const jaasConfig = mqExtraPropertyString(extra, "sasl.jaas.config"); mqSystemKind.value = systemKind; - mqAdminUrl.value = config?.adminUrl?.trim() || (systemKind === "kafka" || systemKind === "rocketmq" ? "" : "http://127.0.0.1:8080"); + const storedAdminUrl = config?.adminUrl?.trim() || (config ? mqExtraString(config as Record, "admin_url").trim() : ""); + mqAdminUrl.value = storedAdminUrl || (systemKind === "kafka" || systemKind === "rocketmq" || systemKind === "rabbitmq" ? "" : "http://127.0.0.1:8080"); mqKafkaBootstrapServers.value = mqExtraString(extra, "bootstrapServers") || "127.0.0.1:9092"; mqRocketmqNamesrvAddr.value = mqExtraString(extra, "namesrvAddr") || mqExtraString(extra, "namesrv_addr") || "127.0.0.1:9876"; mqRocketmqClusterName.value = mqExtraString(extra, "clusterName") || mqExtraString(extra, "cluster_name"); + mqRabbitmqAddresses.value = mqExtraString(extra, "addresses") || "127.0.0.1:5672"; + mqRabbitmqVirtualHost.value = mqExtraString(extra, "virtualHost") || "/"; mqKafkaSecurityProtocol.value = mqExtraString(extra, "securityProtocol") || MQ_KAFKA_SECURITY_PROTOCOL_AUTO; mqKafkaSaslMechanism.value = mqExtraString(extra, "saslMechanism") || "PLAIN"; mqKafkaKerberosPrincipal.value = parseJaasStringProperty(jaasConfig, "principal"); @@ -934,6 +944,14 @@ function defaultMqFieldsForProfile(profile: string): Partial | un extra: { namesrvAddr: "127.0.0.1:9876" }, }; } + if (profile === "rabbitmq") { + return { + systemKind: "rabbitmq", + adminUrl: "", + auth: { kind: "none" }, + extra: { addresses: "127.0.0.1:5672", virtualHost: "/" }, + }; + } return undefined; } @@ -960,6 +978,12 @@ watch(mqSystemKind, (kind) => { if (!isMqAuthKindAllowedForSystem(kind, mqAuthKind.value)) mqAuthKind.value = "none"; return; } + if (kind === "rabbitmq") { + if (!mqRabbitmqAddresses.value.trim()) mqRabbitmqAddresses.value = "127.0.0.1:5672"; + if (!mqRabbitmqVirtualHost.value.trim()) mqRabbitmqVirtualHost.value = "/"; + if (!isMqAuthKindAllowedForSystem(kind, mqAuthKind.value)) mqAuthKind.value = "none"; + return; + } if (!mqAdminUrl.value.trim()) mqAdminUrl.value = "http://127.0.0.1:8080"; }); @@ -1126,6 +1150,21 @@ function buildMqAdminConfig(): MqAdminConfig { }; } + if (systemKind === "rabbitmq") { + const addresses = normalizeRabbitmqAddresses(mqRabbitmqAddresses.value); + const extra: Record = { + addresses, + virtualHost: mqRabbitmqVirtualHost.value.trim() || "/", + }; + return { + systemKind: "rabbitmq", + adminUrl: mqAdminUrl.value.trim(), + auth: buildMqAuth(), + tlsSkipVerify: mqTlsSkipVerify.value || undefined, + extra, + }; + } + return { systemKind: mqSystemKind.value, adminUrl: requireMqField(mqAdminUrl.value, t("connection.mqAdminUrlRequired")), @@ -1493,6 +1532,20 @@ function applyMqKafkaBootstrapServers(config: LegacyConnectionConfig, bootstrapS config.ssl = securityProtocol === "SSL" || securityProtocol === "SASL_SSL"; } +function applyMqRabbitmqAddresses(config: LegacyConnectionConfig, addresses: string) { + const first = normalizeRabbitmqAddresses(addresses).split(",")[0]; + if (!first) throw new Error(t("connection.mqRabbitmqAddressesRequired")); + let parsed: URL; + try { + parsed = new URL(`amqp://${first}`); + } catch { + throw new Error(t("connection.mqRabbitmqAddressesInvalid")); + } + config.host = parsed.hostname; + config.port = Number(parsed.port) || 5672; + config.ssl = false; +} + function applyNacosServerAddr(config: LegacyConnectionConfig, serverAddr: string) { let parsed: URL; try { @@ -1987,6 +2040,7 @@ const iconTypeMap: Record = { mq: "mq", kafka: "kafka", rocketmq: "rocketmq", + rabbitmq: "rabbitmq", nacos: "nacos", dm: "dm", h2: "h2", @@ -2082,6 +2136,7 @@ const dbOptions: DbOption[] = [ { value: "mq", label: "Apache Pulsar" }, { value: "kafka", label: "Apache Kafka" }, { value: "rocketmq", label: "Apache RocketMQ" }, + { value: "rabbitmq", label: "RabbitMQ" }, { value: "nacos", label: "Nacos" }, { value: "influxdb", label: "InfluxDB" }, { value: "iris", label: "IRIS" }, @@ -2495,6 +2550,7 @@ const hasRequiredConnectionTarget = computed(() => { if (form.value.db_type === "mq") { if (mqSystemKind.value === "kafka") return !!mqKafkaBootstrapServers.value.trim(); if (mqSystemKind.value === "rocketmq") return !!mqRocketmqNamesrvAddr.value.trim(); + if (mqSystemKind.value === "rabbitmq") return !!mqRabbitmqAddresses.value.trim(); return !!mqAdminUrl.value.trim(); } if (form.value.db_type === "zookeeper") return !!(form.value.host || form.value.connection_string || connectionUrlInput.value.trim()); @@ -2829,6 +2885,9 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo } else if (mqConfig.systemKind === "rocketmq") { const extra = mqExtraRecord(mqConfig); applyMqRocketmqNamesrv(config, mqExtraString(extra, "namesrvAddr") || mqExtraString(extra, "namesrv_addr")); + } else if (mqConfig.systemKind === "rabbitmq") { + const extra = mqExtraRecord(mqConfig); + applyMqRabbitmqAddresses(config, mqExtraString(extra, "addresses")); } else { applyMqAdminUrl(config, mqConfig.adminUrl); } @@ -4737,6 +4796,25 @@ function openExternalUrl(url: string) { +