diff --git a/bin/nodetool b/bin/nodetool index 4ca496b4ea..4e3062d4fd 100755 --- a/bin/nodetool +++ b/bin/nodetool @@ -43,30 +43,51 @@ if [ -z "$CASSANDRA_CONF" -o -z "$CLASSPATH" ]; then exit 1 fi -JMX_PORT="" - -# Try to parse port from configure_jmx method call, when not commented out -if [ -f "$CASSANDRA_CONF/cassandra-env.sh" ]; then - jmx_method_call=$(grep "^configure_jmx \+[0-9]\+$" "$CASSANDRA_CONF/cassandra-env.sh") - if [ ! "x${jmx_method_call}" = "x" ]; then - JMX_PORT=$(echo "${jmx_method_call}" | tr -s " " | cut -d " " -f2) +# Check if protocol is cql (from environment variable) +PROTOCOL_IS_CQL="" +if [ "x$CASSANDRA_CLI_EXECUTION_PROTOCOL" != "x" ]; then + protocol_lower=$(echo "$CASSANDRA_CLI_EXECUTION_PROTOCOL" | tr '[:upper:]' '[:lower:]') + if [ "$protocol_lower" = "cql" ]; then + PROTOCOL_IS_CQL="true" fi fi -# In case JMX_PORT is not set (when configure_jmx in cassandra-env.sh is commented out), -# try to parse it from cassandra.yaml. -if [ "x$JMX_PORT" = "x" ]; then +# Check for cql protocol in system properties to determine if we should parse CQL or JMX port +for arg in "$@"; do + case "$arg" in + -Dcassandra.cli.execution.protocol=cql|-Dcassandra.cli.execution.protocol=CQL) + PROTOCOL_IS_CQL="true" + break + ;; + esac +done + +# Parse port from config files based on protocol +CONNECTION_PORT="" +if [ "$PROTOCOL_IS_CQL" = "true" ]; then + # For CQL, parse native_transport_management_port from cassandra.yaml if [ -f "$CASSANDRA_CONF/cassandra.yaml" ]; then - JMX_PORT=$(grep jmx_port "$CASSANDRA_CONF/cassandra.yaml" | cut -d ':' -f 2 | tr -d '[[:space:]]') + CONNECTION_PORT=$(grep native_transport_management_port "$CASSANDRA_CONF/cassandra.yaml" | cut -d ':' -f 2 | tr -d '[[:space:]]') + fi +else + # For JMX, parse port from configure_jmx method call, when not commented out + if [ -f "$CASSANDRA_CONF/cassandra-env.sh" ]; then + jmx_method_call=$(grep "^configure_jmx \+[0-9]\+$" "$CASSANDRA_CONF/cassandra-env.sh") + if [ ! "x${jmx_method_call}" = "x" ]; then + CONNECTION_PORT=$(echo "${jmx_method_call}" | tr -s " " | cut -d " " -f2) + fi + fi + + # In case CONNECTION_PORT is not set (when configure_jmx in cassandra-env.sh is commented out), + # try to parse it from cassandra.yaml. + if [ "x$CONNECTION_PORT" = "x" ]; then + if [ -f "$CASSANDRA_CONF/cassandra.yaml" ]; then + CONNECTION_PORT=$(grep jmx_port "$CASSANDRA_CONF/cassandra.yaml" | cut -d ':' -f 2 | tr -d '[[:space:]]') + fi fi fi -# If, by any chance, it is not there either, set it to default. -if [ "x$JMX_PORT" = "x" ]; then - JMX_PORT=7199 -fi - -# JMX Port passed via cmd line args (-p 9999 / --port 9999 / --port=9999) +# Connection Port passed via cmd line args (-p 9999 / --port 9999 / --port=9999) # should override the value from cassandra-env.sh ARGS="" JVM_ARGS="" @@ -76,19 +97,19 @@ do if [ ! $1 ]; then break; fi case $1 in -p) - JMX_PORT=$2 + CONNECTION_PORT=$2 shift ;; --port=*) - JMX_PORT=$(echo $1 | cut -d '=' -f 2) + CONNECTION_PORT=$(echo $1 | cut -d '=' -f 2) ;; --port) - JMX_PORT=$2 + CONNECTION_PORT=$2 shift ;; --ssl) if [ -f $SSL_FILE ] - then + then SSL_ARGS=$(cat $SSL_FILE | tr '\n' ' ') fi JVM_ARGS="$JVM_ARGS -Dssl.enable=true $SSL_ARGS" @@ -108,6 +129,14 @@ do shift done +if [ "x$CONNECTION_PORT" = "x" ]; then + if [ "$PROTOCOL_IS_CQL" = "true" ]; then + CONNECTION_PORT=11211 + else + CONNECTION_PORT=7199 + fi +fi + if [ "x$MAX_HEAP_SIZE" = "x" ]; then MAX_HEAP_SIZE="128m" fi @@ -123,7 +152,7 @@ CMD=$(echo "$JAVA" $JAVA_AGENT -ea -cp "$CLASSPATH" $JVM_OPTS -Xmx$MAX_HEAP_SIZE -Dcassandra.logdir="$CASSANDRA_LOG_DIR" \ -Dlogback.configurationFile=logback-tools.xml \ $JVM_ARGS \ - org.apache.cassandra.tools.NodeTool -p $JMX_PORT $ARGS) + org.apache.cassandra.tools.NodeTool -p $CONNECTION_PORT $ARGS) if [ "x$ARCHIVE_COMMAND" != "x" ] then diff --git a/conf/cassandra.yaml b/conf/cassandra.yaml index 2f64b7e54e..645026ef8a 100644 --- a/conf/cassandra.yaml +++ b/conf/cassandra.yaml @@ -1134,6 +1134,57 @@ rpc_address: localhost # enable or disable keepalive on rpc/native connections rpc_keepalive: true +# Whether to start the native management transport server. +# The address on which the native management transport is bound can be configured +# using rpc_management_address or rpc_management_interface. If neither is set, +# it defaults to rpc_address (or rpc_interface if that was used). +start_native_transport_management: false + +# The address or interface to bind the native management transport server to. +# This allows you to bind management operations to a different network interface +# than regular client connections, providing better security isolation. +# +# Set rpc_management_address OR rpc_management_interface, not both. +# +# If neither rpc_management_address nor rpc_management_interface is set, +# the management transport will use the same address as the regular native +# transport (rpc_address or rpc_interface). +# +# For security reasons, you should not expose this port to the internet. +# Firewall it if needed. Consider binding to a management network interface +# that is not accessible from public networks. +# rpc_management_address: localhost + +# Set rpc_management_address OR rpc_management_interface, not both. Interfaces +# must correspond to a single address, IP aliasing is not supported. +# +# This allows you to bind the management transport to a specific network +# interface by name (e.g., eth1, eth0). The IP address will be automatically +# resolved from the interface. This is useful when the interface IP may change +# (e.g., DHCP) or when you want to bind to a specific interface without +# hardcoding the IP address. +# rpc_management_interface: eth1 + +# If you choose to specify the management interface by name and the interface +# has both an ipv4 and an ipv6 address, you can specify which should be chosen +# using rpc_management_interface_prefer_ipv6. If false the first ipv4 address +# will be used. If true the first ipv6 address will be used. Defaults to false +# preferring ipv4. If there is only one address it will be selected regardless +# of ipv4/ipv6. +# rpc_management_interface_prefer_ipv6: false + +# Port for the management CQL native transport to listen for clients on. +# For security reasons, you should not expose this port to the internet. +# Firewall it if needed. +native_transport_management_port: 11211 + +# The maximum threads for handling management requests are set +# separately from regular requests to allow better isolation and +# prioritization of management operations. This is important to +# ensure that management operations can proceed even under a high +# load of regular client requests. Defaults to 2. +# native_transport_management_max_threads: 2 + # Uncomment to set socket buffer size for internode communication # Note that when setting this, the buffer size is limited by net.core.wmem_max # and when not setting it it is defined by net.ipv4.tcp_wmem diff --git a/conf/cassandra_latest.yaml b/conf/cassandra_latest.yaml index 63da058327..c02aba8ac6 100644 --- a/conf/cassandra_latest.yaml +++ b/conf/cassandra_latest.yaml @@ -1119,6 +1119,57 @@ rpc_address: localhost # enable or disable keepalive on rpc/native connections rpc_keepalive: true +# Whether to start the native management transport server. +# The address on which the native management transport is bound can be configured +# using rpc_management_address or rpc_management_interface. If neither is set, +# it defaults to rpc_address (or rpc_interface if that was used). +start_native_transport_management: false + +# The address or interface to bind the native management transport server to. +# This allows you to bind management operations to a different network interface +# than regular client connections, providing better security isolation. +# +# Set rpc_management_address OR rpc_management_interface, not both. +# +# If neither rpc_management_address nor rpc_management_interface is set, +# the management transport will use the same address as the regular native +# transport (rpc_address or rpc_interface). +# +# For security reasons, you should not expose this port to the internet. +# Firewall it if needed. Consider binding to a management network interface +# that is not accessible from public networks. +# rpc_management_address: localhost + +# Set rpc_management_address OR rpc_management_interface, not both. Interfaces +# must correspond to a single address, IP aliasing is not supported. +# +# This allows you to bind the management transport to a specific network +# interface by name (e.g., eth1, eth0). The IP address will be automatically +# resolved from the interface. This is useful when the interface IP may change +# (e.g., DHCP) or when you want to bind to a specific interface without +# hardcoding the IP address. +# rpc_management_interface: eth1 + +# If you choose to specify the management interface by name and the interface +# has both an ipv4 and an ipv6 address, you can specify which should be chosen +# using rpc_management_interface_prefer_ipv6. If false the first ipv4 address +# will be used. If true the first ipv6 address will be used. Defaults to false +# preferring ipv4. If there is only one address it will be selected regardless +# of ipv4/ipv6. +# rpc_management_interface_prefer_ipv6: false + +# Port for the management CQL native transport to listen for clients on. +# For security reasons, you should not expose this port to the internet. +# Firewall it if needed. +native_transport_management_port: 11211 + +# The maximum threads for handling management requests are set +# separately from regular requests to allow better isolation and +# prioritization of management operations. This is important to +# ensure that management operations can proceed even under a high +# load of regular client requests. Defaults to 2. +# native_transport_management_max_threads: 2 + # Uncomment to set socket buffer size for internode communication # Note that when setting this, the buffer size is limited by net.core.wmem_max # and when not setting it it is defined by net.ipv4.tcp_wmem diff --git a/doc/native_protocol_v5.spec b/doc/native_protocol_v5.spec index b5543fa6f4..673dc149c4 100644 --- a/doc/native_protocol_v5.spec +++ b/doc/native_protocol_v5.spec @@ -1498,6 +1498,15 @@ Table of Contents acknowledged the request. is an [int] representing the number of replicas whose acknowledgement is required to achieve . + 0x1800 COMMAND_FAILED: An exception occurred during command execution. This error is returned + when a command executed via the native management interface fails during execution. + The exception may or may not include more detail in the accompanying error message. + The rest of the ERROR message body will be + + where: + is a [uuid] representing the unique identifier for the command + execution that failed. This identifier can be used to correlate the + error with command execution logs on the server. 0x2000 Syntax_error: The submitted query has a syntax error. 0x2100 Unauthorized: The logged user doesn't have the right to perform diff --git a/src/antlr/Lexer.g b/src/antlr/Lexer.g index c7065f7351..3aad2a9965 100644 --- a/src/antlr/Lexer.g +++ b/src/antlr/Lexer.g @@ -148,6 +148,8 @@ K_MODIFY: M O D I F Y; K_AUTHORIZE: A U T H O R I Z E; K_DESCRIBE: D E S C R I B E; K_EXECUTE: E X E C U T E; +K_COMMAND: C O M M A N D; +K_INVOKE: I N V O K E; K_NORECURSIVE: N O R E C U R S I V E; K_MBEAN: M B E A N; K_MBEANS: M B E A N S; diff --git a/src/antlr/Parser.g b/src/antlr/Parser.g index c459716406..dfc3a09b63 100644 --- a/src/antlr/Parser.g +++ b/src/antlr/Parser.g @@ -294,6 +294,7 @@ cqlStatement returns [CQLStatement.Raw stmt] | st55=securityLabelOnUserTypeStatement { $stmt = st55; } | st56=commentOnUserTypeFieldStatement { $stmt = st56; } | st57=securityLabelOnUserTypeFieldStatement { $stmt = st57; } + | st58=executeCommandStatement { $stmt = st58; } ; /* @@ -1370,6 +1371,53 @@ securityLabelOnUserTypeFieldStatement returns [SecurityLabelOnUserTypeFieldState { $stmt = new SecurityLabelOnUserTypeFieldStatement.Raw($typeFieldRef.typeName, $typeFieldRef.field, label != null ? $label.text : null, provider); } ; +/** + * INVOKE COMMAND [WITH key1 = value1 AND key2 = value2]; + */ +executeCommandStatement returns [ExecuteCommandStatement.Raw stmt] + @init { + stmtBegins(); + java.util.Map args = new java.util.LinkedHashMap<>(); + } + : K_INVOKE K_COMMAND cmdName=noncol_ident + (K_WITH commandProperties[args])? + { + $stmt = new ExecuteCommandStatement.Raw(cmdName.toString(), args); + } + ; + +commandProperties[java.util.Map args] + : commandProperty[args] (K_AND commandProperty[args])* + ; + +commandProperty[java.util.Map args] + : k=noncol_ident '=' v=commandPropertyValue + { + String key = k.toString(); + Object value = v; + if (args.put(key, value) != null) + { + addRecognitionError("Duplicate argument: " + key); + } + } + ; + +commandPropertyValue returns [Object value] + : s=STRING_LITERAL { $value = $s.text; } + | i=INTEGER { $value = $i.text; } + | f=FLOAT { $value = $f.text; } + | b=BOOLEAN { $value = $b.text; } + | l=commandListValue { $value = l; } + ; + +commandListValue returns [java.util.List value] + @init { java.util.List l = new java.util.ArrayList<>(); } + : '[' ( s1=STRING_LITERAL { l.add($s1.text); } + ( ',' sn=STRING_LITERAL { l.add($sn.text); } )* + )? ']' + { $value = l; } + ; + /** * DROP TABLE [IF EXISTS] ; */ @@ -1980,7 +2028,7 @@ collectionLiteral returns [Term.Raw value] listLiteral returns [Term.Raw value] @init {List l = new ArrayList();} - @after {$value = new ArrayLiteral(l);} +@after {$value = new ArrayLiteral(l);} : '[' ( t1=term { l.add(t1); } ( ',' tn=term { l.add(tn); } )* )? ']' { $value = new ArrayLiteral(l); } ; @@ -2483,5 +2531,7 @@ basic_unreserved_keyword returns [String str] | K_LABELS | K_FIELD | K_COLUMN + | K_COMMAND + | K_INVOKE ) { $str = $k.text; } ; diff --git a/src/java/org/apache/cassandra/audit/AuditLogEntryType.java b/src/java/org/apache/cassandra/audit/AuditLogEntryType.java index ff61f2f2a7..68a6d65f2b 100644 --- a/src/java/org/apache/cassandra/audit/AuditLogEntryType.java +++ b/src/java/org/apache/cassandra/audit/AuditLogEntryType.java @@ -82,7 +82,8 @@ public enum AuditLogEntryType UNAUTHORIZED_ATTEMPT(AuditLogEntryCategory.AUTH), LOGIN_SUCCESS(AuditLogEntryCategory.AUTH), LIST_SUPERUSERS(AuditLogEntryCategory.DCL), - JMX(AuditLogEntryCategory.JMX); + JMX(AuditLogEntryCategory.JMX), + EXECUTE_COMMAND(AuditLogEntryCategory.OTHER); private final AuditLogEntryCategory category; diff --git a/src/java/org/apache/cassandra/auth/AbstractCIDRAuthorizer.java b/src/java/org/apache/cassandra/auth/AbstractCIDRAuthorizer.java index a2d591318e..d3de6bd291 100644 --- a/src/java/org/apache/cassandra/auth/AbstractCIDRAuthorizer.java +++ b/src/java/org/apache/cassandra/auth/AbstractCIDRAuthorizer.java @@ -28,8 +28,8 @@ import org.apache.cassandra.metrics.CIDRAuthorizerMetrics; */ public abstract class AbstractCIDRAuthorizer implements ICIDRAuthorizer { - protected static CIDRPermissionsManager cidrPermissionsManager; - protected static CIDRGroupsMappingManager cidrGroupsMappingManager; + public static CIDRPermissionsManager cidrPermissionsManager; + public static CIDRGroupsMappingManager cidrGroupsMappingManager; protected static CIDRAuthorizerMetrics cidrAuthorizerMetrics; diff --git a/src/java/org/apache/cassandra/auth/AuthCache.java b/src/java/org/apache/cassandra/auth/AuthCache.java index 8c9df378fc..0375ef91d9 100644 --- a/src/java/org/apache/cassandra/auth/AuthCache.java +++ b/src/java/org/apache/cassandra/auth/AuthCache.java @@ -45,6 +45,7 @@ import com.google.common.util.concurrent.Uninterruptibles; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.apache.cassandra.auth.jmx.AuthorizationProxy; import org.apache.cassandra.cache.UnweightedCacheSize; import org.apache.cassandra.concurrent.ExecutorPlus; import org.apache.cassandra.concurrent.ScheduledExecutors; @@ -468,6 +469,36 @@ public class AuthCache implements AuthCacheMBean, UnweightedCacheSize, Shu return Ints.checkedCast(getEstimatedSize()); } + /** + * Accepts a visitor for this cache instance. Subclasses should override this method to dispatch + * to the appropriate visitor method based on the specific MBean interface they implement. + * @param visitor the visitor to accept + */ + public void accept(MBeanVisitor visitor) + { + visitor.visit(this); + } + + /** + * Visitor interface for processing auth cache MBeans. + * Allows type-safe iteration over different cache types without instanceof checks. + */ + public interface MBeanVisitor + { + /** Visits a credentials cache MBean. */ + default void visitCredentials(PasswordAuthenticator.CredentialsCacheMBean cache) {} + /** Visits a JMX permissions cache MBean. */ + default void visitJmxPermissions(AuthorizationProxy.JmxPermissionsCacheMBean cache) {} + /** Visits a permissions cache MBean. */ + default void visitPermissions(PermissionsCacheMBean cache) {} + /** Visits a network permissions cache MBean. */ + default void visitNetwork(NetworkPermissionsCacheMBean cache) {} + /** Visits a roles cache MBean. */ + default void visitRoles(RolesCacheMBean cache) {} + /** Visits a generic auth cache (fallback for caches that don't implement specific MBean interfaces).*/ + default void visit(AuthCacheMBean cache) {} + } + private class MetricsUpdater implements StatsCounter { @Override diff --git a/src/java/org/apache/cassandra/auth/AuthCacheService.java b/src/java/org/apache/cassandra/auth/AuthCacheService.java index 8fddd1e0e9..13c08e3ba2 100644 --- a/src/java/org/apache/cassandra/auth/AuthCacheService.java +++ b/src/java/org/apache/cassandra/auth/AuthCacheService.java @@ -26,6 +26,7 @@ import javax.annotation.concurrent.ThreadSafe; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; +import com.google.common.collect.Sets; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -60,6 +61,11 @@ public class AuthCacheService } } + public synchronized Set> getCaches() + { + return Sets.newHashSet(caches); + } + /** * NOTE: Can only be called once per instance run. * diff --git a/src/java/org/apache/cassandra/auth/NetworkPermissionsCache.java b/src/java/org/apache/cassandra/auth/NetworkPermissionsCache.java index f232cf354f..3108bf8fbf 100644 --- a/src/java/org/apache/cassandra/auth/NetworkPermissionsCache.java +++ b/src/java/org/apache/cassandra/auth/NetworkPermissionsCache.java @@ -52,4 +52,10 @@ public class NetworkPermissionsCache extends AuthCache> implements Ro { invalidate(RoleResource.role(roleName)); } + + @Override + public void accept(MBeanVisitor visitor) + { + visitor.visitRoles(this); + } } diff --git a/src/java/org/apache/cassandra/auth/jmx/AuthorizationProxy.java b/src/java/org/apache/cassandra/auth/jmx/AuthorizationProxy.java index 183050b4c4..7bfe922b3f 100644 --- a/src/java/org/apache/cassandra/auth/jmx/AuthorizationProxy.java +++ b/src/java/org/apache/cassandra/auth/jmx/AuthorizationProxy.java @@ -46,6 +46,7 @@ import org.slf4j.LoggerFactory; import org.apache.cassandra.audit.AuditLogManager; import org.apache.cassandra.auth.AuthCache; import org.apache.cassandra.auth.AuthCacheMBean; +import org.apache.cassandra.auth.AuthCacheService; import org.apache.cassandra.auth.AuthenticatedUser; import org.apache.cassandra.auth.JMXResource; import org.apache.cassandra.auth.Permission; @@ -596,6 +597,8 @@ public class AuthorizationProxy implements InvocationHandler () -> true); MBeanWrapper.instance.registerMBean(this, MBEAN_NAME_BASE + DEPRECATED_CACHE_NAME); + // Registration makes this cache discovearble by the management transport MBean accessor + AuthCacheService.instance.register(this); } public void invalidatePermissions(String roleName) @@ -609,6 +612,12 @@ public class AuthorizationProxy implements InvocationHandler super.unregisterMBean(); MBeanWrapper.instance.unregisterMBean(MBEAN_NAME_BASE + DEPRECATED_CACHE_NAME, MBeanWrapper.OnException.LOG); } + + @Override + public void accept(MBeanVisitor visitor) + { + visitor.visitJmxPermissions(this); + } } public static interface JmxPermissionsCacheMBean extends AuthCacheMBean diff --git a/src/java/org/apache/cassandra/config/CassandraRelevantEnv.java b/src/java/org/apache/cassandra/config/CassandraRelevantEnv.java index e563c59176..0fd816c1e1 100644 --- a/src/java/org/apache/cassandra/config/CassandraRelevantEnv.java +++ b/src/java/org/apache/cassandra/config/CassandraRelevantEnv.java @@ -36,6 +36,16 @@ public enum CassandraRelevantEnv JAVA_HOME ("JAVA_HOME"), CIRCLECI("CIRCLECI"), CASSANDRA_SKIP_SYNC("CASSANDRA_SKIP_SYNC"), + /** + * Defines the protocol used by the Cassandra CLI to connect to Cassandra nodes. + * Possible values are {@code "static_mbeans"}, {@code "command_mbeans"} or {@code cql}. + * By default, the Cassandra CLI uses the JMX protocol via static MBeans. + */ + CASSANDRA_CLI_EXECUTION_PROTOCOL("CASSANDRA_CLI_EXECUTION_PROTOCOL"), + /** Whether to show the execution id for cli command output, useful for correlating commands with logs and tracing. */ + CASSANDRA_CLI_EXECUTION_SHOW_EXECUTION_ID("CASSANDRA_CLI_EXECUTION_SHOW_EXECUTION_ID"), + /** How long the Cassandra CLI waits for a server response when executing a command over CQL, in seconds. */ + CASSANDRA_CLI_EXECUTION_TIMEOUT_SECONDS("CASSANDRA_CLI_EXECUTION_TIMEOUT_SECONDS"), /** By default, the standard Cassandra CLI layout is used for backward compatibility, however, * the new Picocli layout can be enabled by setting this property to the {@code "picocli"}. */ CASSANDRA_CLI_LAYOUT("CASSANDRA_CLI_LAYOUT"), diff --git a/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java b/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java index 2be55fbe5c..78362c6b46 100644 --- a/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java +++ b/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java @@ -97,6 +97,16 @@ public enum CassandraRelevantProperties CACHEABLE_MUTATION_SIZE_LIMIT("cassandra.cacheable_mutation_size_limit_bytes", convertToString(1_000_000)), CASSANDRA_ALLOW_SIMPLE_STRATEGY("cassandra.allow_simplestrategy"), CASSANDRA_AVAILABLE_PROCESSORS("cassandra.available_processors"), + /** + * Defines the protocol used by the Cassandra CLI to connect to Cassandra nodes. + * Possible values are {@code "static_mbean"}, {@code "command_mbean"} or {@code cql}. + * By default, the Cassandra CLI uses the JMX protocol via static MBeans. + */ + CASSANDRA_CLI_EXECUTION_PROTOCOL("cassandra.cli.execution.protocol", "static_mbean"), + /** Whether to show the execution id for cli command output, useful for correlating commands with logs and tracing. */ + CASSANDRA_CLI_EXECUTION_SHOW_EXECUTION_ID("cassandra.cli.execution.show_execution_id", "false"), + /** How long the Cassandra CLI waits for a server response when executing a command over CQL, in seconds. */ + CASSANDRA_CLI_EXECUTION_TIMEOUT_SECONDS("cassandra.cli.execution.timeout_seconds", "0"), /** By default, the standard Cassandra CLI layout is used for backward compatibility, however, * the new Picocli layout can be enabled by setting this property to the {@code "picocli"}. */ CASSANDRA_CLI_LAYOUT("cassandra.cli.layout", "airline"), @@ -419,6 +429,8 @@ public enum CassandraRelevantProperties MX4JPORT("mx4jport"), NANOTIMETOMILLIS_TIMESTAMP_UPDATE_INTERVAL("cassandra.NANOTIMETOMILLIS_TIMESTAMP_UPDATE_INTERVAL", "10000"), NATIVE_EPOLL_ENABLED("cassandra.native.epoll.enabled", "true"), + /** This is the port used with RPC address for the management native protocol to communicate with clients that manage the node. */ + NATIVE_TRANSPORT_MANAGEMENT_PORT("cassandra.native_transport_management_port"), /** This is the port used with RPC address for the native protocol to communicate with clients. Now that thrift RPC is no longer in use there is no RPC port. */ NATIVE_TRANSPORT_PORT("cassandra.native_transport_port"), NEVER_PURGE_TOMBSTONES("cassandra.never_purge_tombstones"), @@ -580,6 +592,7 @@ public enum CassandraRelevantProperties SSL_ENABLE("ssl.enable"), SSL_STORAGE_PORT("cassandra.ssl_storage_port"), START_GOSSIP("cassandra.start_gossip", "true"), + START_NATIVE_MANAGEMENT_TRANSPORT("cassandra.start_native_management_transport"), START_NATIVE_TRANSPORT("cassandra.start_native_transport"), STORAGE_DIR("cassandra.storagedir"), STORAGE_HOOK("cassandra.storage_hook"), diff --git a/src/java/org/apache/cassandra/config/Config.java b/src/java/org/apache/cassandra/config/Config.java index 8df1a05cf1..b664ab4db2 100644 --- a/src/java/org/apache/cassandra/config/Config.java +++ b/src/java/org/apache/cassandra/config/Config.java @@ -370,6 +370,20 @@ public class Config @Replaces(oldName = "native_transport_receive_queue_capacity_in_bytes", converter = Converters.BYTES_DATASTORAGE, deprecated = true) public DataStorageSpec.IntBytesBound native_transport_receive_queue_capacity = new DataStorageSpec.IntBytesBound("1MiB"); + /** + * Management RPC address and interface refer to the address/interface used for the native management protocol + * to communicate with management clients. If not explicitly configured, these default to the regular RPC address + * configuration (rpc_address or rpc_interface). + *

+ * native_transport_management_port is the port paired with the management RPC address to bind on. + */ + public String rpc_management_address; + public String rpc_management_interface; + public boolean rpc_management_interface_prefer_ipv6 = false; + public boolean start_native_transport_management = false; + public int native_transport_management_port = 11211; + public int native_transport_management_max_threads = 2; + /** * Max size of values in SSTables, in MebiBytes. * Default is the same as the native protocol frame limit: 256MiB. diff --git a/src/java/org/apache/cassandra/config/DatabaseDescriptor.java b/src/java/org/apache/cassandra/config/DatabaseDescriptor.java index 5bc6de7ac1..f28ff79838 100644 --- a/src/java/org/apache/cassandra/config/DatabaseDescriptor.java +++ b/src/java/org/apache/cassandra/config/DatabaseDescriptor.java @@ -148,6 +148,7 @@ import static org.apache.cassandra.config.CassandraRelevantProperties.CONFIG_LOA import static org.apache.cassandra.config.CassandraRelevantProperties.DISABLE_STCS_IN_L0; import static org.apache.cassandra.config.CassandraRelevantProperties.INITIAL_TOKEN; import static org.apache.cassandra.config.CassandraRelevantProperties.IO_NETTY_TRANSPORT_ESTIMATE_SIZE_ON_SUBMIT; +import static org.apache.cassandra.config.CassandraRelevantProperties.NATIVE_TRANSPORT_MANAGEMENT_PORT; import static org.apache.cassandra.config.CassandraRelevantProperties.NATIVE_TRANSPORT_PORT; import static org.apache.cassandra.config.CassandraRelevantProperties.OS_ARCH; import static org.apache.cassandra.config.CassandraRelevantProperties.PARTITIONER; @@ -216,6 +217,7 @@ public class DatabaseDescriptor private static InetAddress broadcastAddress; private static InetAddress rpcAddress; private static InetAddress broadcastRpcAddress; + private static InetAddress rpcManagementAddress; private static SeedProvider seedProvider; private static IInternodeAuthenticator internodeAuthenticator = new AllowAllInternodeAuthenticator(); @@ -1494,6 +1496,7 @@ public class DatabaseDescriptor rpcAddress = null; broadcastAddress = null; broadcastRpcAddress = null; + rpcManagementAddress = null; /* Local IP, hostname or interface to bind services to */ if (config.listen_address != null && config.listen_interface != null) @@ -1581,6 +1584,32 @@ public class DatabaseDescriptor throw new ConfigurationException("If rpc_address is set to a wildcard address (" + config.rpc_address + "), then " + "you must set broadcast_rpc_address to a value other than " + config.rpc_address, false); } + + /* Local IP, hostname or interface to bind Management RPC server to */ + if (config.rpc_management_address != null && config.rpc_management_interface != null) + { + throw new ConfigurationException("Set rpc_management_address OR rpc_management_interface, not both", false); + } + else if (config.rpc_management_address != null) + { + try + { + rpcManagementAddress = InetAddress.getByName(config.rpc_management_address); + } + catch (UnknownHostException e) + { + throw new ConfigurationException("Unknown host in rpc_management_address " + config.rpc_management_address, false); + } + } + else if (config.rpc_management_interface != null) + { + rpcManagementAddress = getNetworkInterfaceAddress(config.rpc_management_interface, "rpc_management_interface", config.rpc_management_interface_prefer_ipv6); + } + else + { + // Default to regular rpc_address if not specified + rpcManagementAddress = rpcAddress; + } } public static void applyEncryptionContext() @@ -3606,6 +3635,18 @@ public class DatabaseDescriptor return rpcAddress; } + /** + * This is the address used to bind for the native management protocol to communicate with management clients. + * If not explicitly configured via rpc_management_address or rpc_management_interface, defaults to the regular + * rpc_address. The address alone is not enough to uniquely identify this instance because multiple instances + * might use the same interface with different ports. + */ + public static InetAddress getRpcManagementAddress() + { + assert rpcManagementAddress != null; + return rpcManagementAddress; + } + public static void setBroadcastRpcAddress(InetAddress broadcastRPCAddr) { broadcastRpcAddress = broadcastRPCAddr; @@ -3850,6 +3891,38 @@ public class DatabaseDescriptor ); } + public static boolean startNativeTransportManagement() + { + return conf.start_native_transport_management; + } + + @VisibleForTesting + public static void setStartNativeTransportManagement(boolean start) + { + conf.start_native_transport_management = start; + } + + public static int getNativeTransportManagementPort() + { + return NATIVE_TRANSPORT_MANAGEMENT_PORT.getInt(conf.native_transport_management_port); + } + + @VisibleForTesting + public static void setNativeTransportPortManagement(int port) + { + conf.native_transport_management_port = port; + } + + public static int getNativeTransportManagementMaxThreads() + { + return conf.native_transport_management_max_threads; + } + + public static void setNativeTransportManagementMaxThreads(int max_threads) + { + conf.native_transport_management_max_threads = max_threads; + } + public static Config.PaxosVariant getPaxosVariant() { return conf.paxos_variant; diff --git a/src/java/org/apache/cassandra/config/GuardrailsOptions.java b/src/java/org/apache/cassandra/config/GuardrailsOptions.java index e8c82dacd9..7aab095ec4 100644 --- a/src/java/org/apache/cassandra/config/GuardrailsOptions.java +++ b/src/java/org/apache/cassandra/config/GuardrailsOptions.java @@ -20,12 +20,14 @@ package org.apache.cassandra.config; import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Consumer; import java.util.function.Supplier; import java.util.regex.Pattern; +import java.util.stream.Collectors; import javax.annotation.Nullable; @@ -49,7 +51,6 @@ import org.apache.cassandra.service.disk.usage.DiskUsageMonitor; import org.apache.cassandra.utils.LocalizeString; import static java.lang.String.format; -import static java.util.stream.Collectors.toSet; /** * Configuration settings for guardrails populated from the Yaml file. @@ -1581,12 +1582,24 @@ public class GuardrailsOptions implements GuardrailsConfig "than the fail threshold %s", warn, name, fail)); } + /** + * See {@code GuardrailsConfigCommandTest}, that relies on the fact that the error messages for invalid properties + * are deterministic and contain the list of invalid properties in the same order as they were provided in the command. + * + * @param properties set of properties to validate. + * @param name name of the property for error messages. + * @return set of properties converted to lower case. + */ private static Set validateTableProperties(Set properties, String name) { if (properties == null) throw new IllegalArgumentException(format("Invalid value for %s: null is not allowed", name)); - Set lowerCaseProperties = properties.stream().map(String::toLowerCase).collect(toSet()); + // We use LinkedHashSet to preserve the order of properties in error messages. This is + // used to avoid confusion in an error message when the CQL/nodetool commands are invoked + // with properties in a specific order. + Set lowerCaseProperties = properties.stream().map(LocalizeString::toLowerCaseLocalized) + .collect(Collectors.toCollection(LinkedHashSet::new)); Set diff = Sets.difference(lowerCaseProperties, TableAttributes.allKeywords()); @@ -1601,7 +1614,11 @@ public class GuardrailsOptions implements GuardrailsConfig if (properties == null) throw new IllegalArgumentException(format("Invalid value for %s: null is not allowed", name)); - Set lowerCaseProperties = properties.stream().map(LocalizeString::toLowerCaseLocalized).collect(toSet()); + // We use LinkedHashSet to preserve the order of properties in error messages. This is + // used to avoid confusion in an error message when the CQL/nodetool commands are invoked + // with properties in a specific order. + Set lowerCaseProperties = properties.stream().map(LocalizeString::toLowerCaseLocalized) + .collect(Collectors.toCollection(LinkedHashSet::new)); for (String requiredKeyword : KeyspaceAttributes.requiredKeywords()) { diff --git a/src/java/org/apache/cassandra/cql3/statements/ExecuteCommandStatement.java b/src/java/org/apache/cassandra/cql3/statements/ExecuteCommandStatement.java new file mode 100644 index 0000000000..02518948f2 --- /dev/null +++ b/src/java/org/apache/cassandra/cql3/statements/ExecuteCommandStatement.java @@ -0,0 +1,168 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.cassandra.cql3.statements; + +import java.util.List; +import java.util.Map; + +import org.apache.cassandra.audit.AuditLogContext; +import org.apache.cassandra.audit.AuditLogEntryType; +import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.cql3.CQLStatement; +import org.apache.cassandra.cql3.ColumnIdentifier; +import org.apache.cassandra.cql3.ColumnSpecification; +import org.apache.cassandra.cql3.QueryOptions; +import org.apache.cassandra.cql3.ResultSet; +import org.apache.cassandra.db.marshal.UTF8Type; +import org.apache.cassandra.db.marshal.UUIDType; +import org.apache.cassandra.exceptions.CommandRequestExecutionException; +import org.apache.cassandra.exceptions.InvalidRequestException; +import org.apache.cassandra.exceptions.UnauthorizedException; +import org.apache.cassandra.management.CommandAuthorizationException; +import org.apache.cassandra.management.CommandExecutionArgsSerde; +import org.apache.cassandra.management.CommandExecutionException; +import org.apache.cassandra.management.CommandInvokerService; +import org.apache.cassandra.management.CommandValidationException; +import org.apache.cassandra.management.api.Command; +import org.apache.cassandra.service.ClientState; +import org.apache.cassandra.service.QueryState; +import org.apache.cassandra.transport.Dispatcher; +import org.apache.cassandra.transport.messages.ResultMessage; + +import static org.apache.cassandra.management.ManagementUtils.causeMessages; +import static org.apache.cassandra.management.ManagementUtils.findRegistryCommand; +import static org.apache.cassandra.utils.ByteBufferUtil.bytes; + +public class ExecuteCommandStatement +{ + public static final String COMMAND_RESULT_SCHEMA_EXECUTION_ID = "execution_id"; + public static final String COMMAND_RESULT_SCHEMA_OUTPUT = "output"; + + public static class Raw extends CQLStatement.Raw implements CQLStatement + { + private final String commandName; + private final Map args; + + public Raw(String commandName, Map args) + { + this.commandName = commandName; + this.args = args; + } + + public String commandName() + { + return commandName; + } + + public Map args() + { + return args; + } + + public CQLStatement prepare(ClientState state) + { + return this; + } + + public void authorize(ClientState state) throws UnauthorizedException + { + // TODO: CASSANDRA-XXXXX Restrict command execution to deployments without authentication + // This is a temporary limitation until full authentication support is implemented. + if (DatabaseDescriptor.getAuthenticator().requireAuthentication()) + { + throw new UnauthorizedException("Command execution via management port is currently only supported " + + "when authentication is disabled (AllowAllAuthenticator). " + + "Full authentication and authorization support will be added in a " + + "future release."); + } + + // Validate login (will succeed with AllowAllAuthenticator) + state.validateLogin(); + } + + @Override + public void validate(ClientState state) throws InvalidRequestException + { + Command command = findRegistryCommand(commandName, CommandInvokerService.instance.getRegistry()); + if (command == null) + throw new InvalidRequestException("Command not found: " + commandName); + } + + @Override + public ResultMessage execute(QueryState state, QueryOptions options, Dispatcher.RequestTime requestTime) + { + try + { + ClientState clientState = state.getClientState(); + if (!clientState.isInternal && !clientState.isManagement()) + throw new InvalidRequestException("Command execution is only allowed via native management interface"); + + Command command = findRegistryCommand(commandName, CommandInvokerService.instance.getRegistry()); + if (command == null) + throw new InvalidRequestException("Command not found: " + commandName); + + CommandInvokerService.CommandResult result = CommandInvokerService.instance + .invokeCommand(commandName, + () -> CommandExecutionArgsSerde.fromMap(args, command.metadata())); + + ResultSet resultSet = getCommandResultSet(); + resultSet.addColumnValue(bytes(result.getExecutionId())); + resultSet.addColumnValue(bytes(result.getOutput())); + return new ResultMessage.Rows(resultSet); + } + catch (CommandAuthorizationException e) + { + throw new UnauthorizedException(e.getMessage()); + } + catch (CommandValidationException e) + { + throw new InvalidRequestException(causeMessages(e), e.getCause()); + } + catch (CommandExecutionException e) + { + throw new CommandRequestExecutionException(e.getExecutionId(), + causeMessages(e), + e.getCause()); + } + } + + private static ResultSet getCommandResultSet() + { + ColumnSpecification executionIdColumn = new ColumnSpecification("system", + "command_output", + new ColumnIdentifier(COMMAND_RESULT_SCHEMA_EXECUTION_ID, true), + UUIDType.instance); + ColumnSpecification outputColumn = new ColumnSpecification("system", + "command_output", + new ColumnIdentifier(COMMAND_RESULT_SCHEMA_OUTPUT, true), + UTF8Type.instance); + return new ResultSet(new ResultSet.ResultMetadata(List.of(executionIdColumn, outputColumn))); + } + + public ResultMessage executeLocally(QueryState state, QueryOptions options) throws InvalidRequestException + { + return execute(state, options, Dispatcher.RequestTime.forImmediateExecution()); + } + + @Override + public AuditLogContext getAuditLogContext() + { + return new AuditLogContext(AuditLogEntryType.EXECUTE_COMMAND, commandName); + } + } +} diff --git a/src/java/org/apache/cassandra/db/compression/CompressionDictionaryManager.java b/src/java/org/apache/cassandra/db/compression/CompressionDictionaryManager.java index 264e318f43..f795f1672f 100644 --- a/src/java/org/apache/cassandra/db/compression/CompressionDictionaryManager.java +++ b/src/java/org/apache/cassandra/db/compression/CompressionDictionaryManager.java @@ -177,7 +177,8 @@ public class CompressionDictionaryManager implements CompressionDictionaryManage // Validate table supports dictionary compression if (!isEnabled) { - throw new UnsupportedOperationException("Table " + keyspaceName + '.' + tableName + " does not support dictionary compression"); + throw new IllegalStateException(format("The compression on table %s.%s is not enabled or SSTable compressor is not a dictionary compressor.", + keyspaceName, tableName)); } // resolve training config and fail fast when invalid, so we do not reach logic which would e.g. flush unnecessarily. @@ -353,11 +354,11 @@ public class CompressionDictionaryManager implements CompressionDictionaryManage if (lastTraining.isAfter(now.minus(config.minTrainingFrequency, ChronoUnit.MINUTES))) { Instant nextEarliestTraining = lastTraining.plus(config.minTrainingFrequency, ChronoUnit.MINUTES); - throw new IllegalArgumentException(format("The next training or importing can occur only at least after %s from the last training which happened at %s. " + - "You can train again no earlier than at %s.", - new DurationSpec.IntMinutesBound(config.minTrainingFrequency, TimeUnit.MINUTES), - lastTraining, - nextEarliestTraining)); + throw new RuntimeException(format("The next training or importing can occur only at least after %s from the last training which happened at %s. " + + "You can train again no earlier than at %s.", + new DurationSpec.IntMinutesBound(config.minTrainingFrequency, TimeUnit.MINUTES), + lastTraining, + nextEarliestTraining)); } } } diff --git a/src/java/org/apache/cassandra/db/virtual/CIDRFilteringMetricsTable.java b/src/java/org/apache/cassandra/db/virtual/CIDRFilteringMetricsTable.java index 795b44ca06..8203320fd9 100644 --- a/src/java/org/apache/cassandra/db/virtual/CIDRFilteringMetricsTable.java +++ b/src/java/org/apache/cassandra/db/virtual/CIDRFilteringMetricsTable.java @@ -56,7 +56,7 @@ public class CIDRFilteringMetricsTable implements CIDRFilteringMetricsTableMBean { public static final String MBEAN_NAME = "org.apache.cassandra.db:type=CIDRFilteringMetricsTable"; - private static final CIDRFilteringMetricsTable instance = new CIDRFilteringMetricsTable(); + public static final CIDRFilteringMetricsTable instance = new CIDRFilteringMetricsTable(); CIDRFilteringMetricsTable() { diff --git a/src/java/org/apache/cassandra/exceptions/CommandRequestExecutionException.java b/src/java/org/apache/cassandra/exceptions/CommandRequestExecutionException.java new file mode 100644 index 0000000000..7a3ef01a33 --- /dev/null +++ b/src/java/org/apache/cassandra/exceptions/CommandRequestExecutionException.java @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.exceptions; + +import java.util.UUID; + +public class CommandRequestExecutionException extends RequestExecutionException +{ + private final UUID commandExecutionId; + + public CommandRequestExecutionException(UUID commandExecutionId, String msg, Throwable cause) + { + super(ExceptionCode.COMMAND_FAILED, msg, cause); + this.commandExecutionId = commandExecutionId; + } + + public CommandRequestExecutionException(UUID commandExecutionId, String msg) + { + super(ExceptionCode.COMMAND_FAILED, msg); + this.commandExecutionId = commandExecutionId; + } + + public UUID getCommandExecutionId() + { + return commandExecutionId; + } +} diff --git a/src/java/org/apache/cassandra/exceptions/ExceptionCode.java b/src/java/org/apache/cassandra/exceptions/ExceptionCode.java index 8bb0cfd779..bdc9481869 100644 --- a/src/java/org/apache/cassandra/exceptions/ExceptionCode.java +++ b/src/java/org/apache/cassandra/exceptions/ExceptionCode.java @@ -48,6 +48,7 @@ public enum ExceptionCode WRITE_FAILURE (0x1500), CDC_WRITE_FAILURE (0x1600), CAS_WRITE_UNKNOWN (0x1700), + COMMAND_FAILED (0x1800), // 2xx: problem validating the request SYNTAX_ERROR (0x2000), diff --git a/src/java/org/apache/cassandra/management/CassandraCommandRegistry.java b/src/java/org/apache/cassandra/management/CassandraCommandRegistry.java new file mode 100644 index 0000000000..5fc07a219f --- /dev/null +++ b/src/java/org/apache/cassandra/management/CassandraCommandRegistry.java @@ -0,0 +1,141 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.cassandra.management.api.Command; +import org.apache.cassandra.management.api.CommandExecutionArgs; +import org.apache.cassandra.management.api.CommandExecutionContext; +import org.apache.cassandra.management.api.CommandMetadata; +import org.apache.cassandra.management.api.CommandRegistry; +import org.apache.cassandra.management.api.CommandsProvider; +import org.apache.cassandra.management.api.OptionMetadata; +import org.apache.cassandra.management.api.ParameterMetadata; + +import static org.apache.cassandra.management.ManagementUtils.loadService; + +public class CassandraCommandRegistry implements CommandRegistry +{ + private static final Logger logger = LoggerFactory.getLogger(CassandraCommandRegistry.class); + + // TODO CASSANDRA-XXXXX These long-running commands block the calling thread for + // the entire command duration. They are excluded from the management API until a + // ProgressCommand (extends Command) interface is implemented to support long-running + // command execution with progress reporting. + static final Set UNSUPPORTED_COMMANDS = Set.of("repair", "consensus_admin"); + + private final Map> commandMap = new ConcurrentHashMap<>(); + + public CassandraCommandRegistry() + { + for (CommandsProvider provider : loadService(CommandsProvider.class)) + provider.commands().forEach(this::register); + } + + public void register(Command command) + { + if (UNSUPPORTED_COMMANDS.contains(command.name())) + { + logger.info("Skipping unsupported command '{}': long-running commands require " + + "ProgressCommand support (not yet implemented)", command.name()); + return; + } + + Command prev = commandMap.putIfAbsent(command.name(), command); + + if (prev != null) + throw new IllegalStateException(String.format("Command name conflict: '%s' is already registered", + command.name())); + } + + @Override + @SuppressWarnings("unchecked") + public Command command(String name) + { + return (Command) commandMap.get(name); + } + + @Override + public Iterable>> commands() + { + return commandMap.entrySet(); + } + + @Override + public CommandMetadata metadata() + { + // Return metadata that reflects this is a registry, not a leaf command + return new RootCommandMetadata(); + } + + @Override + public String execute(CommandExecutionArgs arguments, CommandExecutionContext context) + { + // CommandRegistry is not directly executable: routing happens at the invoker/CQL layer. + throw new UnsupportedOperationException( + String.format("CommandRegistry '%s' is not directly executable. " + + "Specify a subcommand. Available commands: %s", + name(), + String.join(", ", commandMap.keySet()))); + } + + private class RootCommandMetadata implements CommandMetadata + { + @Override + public String name() + { + return "root"; + } + + @Override + public String description() + { + return "Root command registry - use a specific subcommand"; + } + + @Override + public List options() + { + return Collections.emptyList(); + } + + @Override + public List parameters() + { + return Collections.emptyList(); + } + + @Override + public List subcommands() + { + return commandMap.values().stream() + .map(Command::metadata) + .collect(Collectors.toList()); + } + } +} diff --git a/src/java/org/apache/cassandra/management/CommandAuthorizationException.java b/src/java/org/apache/cassandra/management/CommandAuthorizationException.java new file mode 100644 index 0000000000..c1e85bf106 --- /dev/null +++ b/src/java/org/apache/cassandra/management/CommandAuthorizationException.java @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management; + +public class CommandAuthorizationException extends Exception +{ + public CommandAuthorizationException(String msg) + { + super(msg); + } +} diff --git a/src/java/org/apache/cassandra/management/CommandExecutionArgsSerde.java b/src/java/org/apache/cassandra/management/CommandExecutionArgsSerde.java new file mode 100644 index 0000000000..35df212b14 --- /dev/null +++ b/src/java/org/apache/cassandra/management/CommandExecutionArgsSerde.java @@ -0,0 +1,252 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management; + +import java.lang.reflect.Array; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +import org.apache.cassandra.management.api.ArgumentMetadata; +import org.apache.cassandra.management.api.CommandExecutionArgs; +import org.apache.cassandra.management.api.CommandMetadata; +import org.apache.cassandra.management.api.OptionMetadata; +import org.apache.cassandra.management.api.ParameterMetadata; +import org.apache.cassandra.management.picocli.PicocliCommandMetadata; +import org.apache.cassandra.management.picocli.PicocliOptionMetadata; +import org.apache.cassandra.management.picocli.PicocliParameterMetadata; +import org.apache.cassandra.management.picocli.TypeConverterRegistry; +import org.apache.cassandra.utils.JsonUtils; + +import static org.apache.cassandra.management.ManagementUtils.normalizeOptionName; +import static org.apache.cassandra.management.api.ParameterMetadata.COMMAND_POSITIONAL_PARAM_PREFIX; + +public class CommandExecutionArgsSerde +{ + public static String toJson(CommandExecutionArgs args) + { + Map params = new LinkedHashMap<>(); + + for (Map.Entry entry : args.options().entrySet()) + { + OptionMetadata optionMetadata = entry.getKey(); + Object value = entry.getValue(); + + if (value == null) + continue; + + String primaryName = normalizeOptionName(optionMetadata.paramLabel()); + params.put(primaryName, normalizeJsonValue(value)); + } + + for (Map.Entry entry : args.parameters().entrySet()) + { + ParameterMetadata paramMetadata = entry.getKey(); + Object value = entry.getValue(); + + if (value == null) + continue; + + int index = paramMetadata.index(); + String indexKey = COMMAND_POSITIONAL_PARAM_PREFIX + index; + params.put(indexKey, normalizeJsonValue(value)); + } + + return JsonUtils.writeAsJsonString(params); + } + + public static CommandExecutionArgs fromJson(String json, CommandMetadata metadata) + { + if (!(metadata instanceof PicocliCommandMetadata)) + throw new IllegalArgumentException("CommandMetadata must be PicocliCommandMetadata for picocli commands"); + + Map jsonMap = JsonUtils.fromJsonMap(json); + Map options = new LinkedHashMap<>(); + Map parameters = new LinkedHashMap<>(); + + convertFromMap(jsonMap, metadata, options, parameters); + return new SimpleCommandExecutionArgs(options, parameters); + } + + public static CommandExecutionArgs fromMap(Map format, CommandMetadata metadata) + { + if (!(metadata instanceof PicocliCommandMetadata)) + throw new IllegalArgumentException("CommandMetadata must be PicocliCommandMetadata for picocli commands"); + + Map options = new LinkedHashMap<>(); + Map parameters = new LinkedHashMap<>(); + + convertFromMap(format, metadata, options, parameters); + return new SimpleCommandExecutionArgs(options, parameters); + } + + private static void convertFromMap(Map source, + CommandMetadata metadata, + Map options, + Map parameters) + { + for (Map.Entry entry : source.entrySet()) + { + String paramName = entry.getKey(); + Object value = entry.getValue(); + + if (value == null) + continue; + + OptionMetadata option = findOptionByNameIgnoreCase(metadata, paramName); + if (option != null) + { + Object convertedValue = convertValue(value, option); + options.put(option, convertedValue); + continue; + } + + ParameterMetadata param = findParameterByName(metadata, paramName); + if (param != null) + { + Object convertedValue = convertValue(value, param); + parameters.put(param, convertedValue); + } + else + { + throw new IllegalArgumentException("Unknown parameter: " + paramName); + } + } + } + + /** Convert value using custom converter if provided, otherwise use basic conversion. */ + private static Object convertValue(Object value, ArgumentMetadata argSpec) + { + try + { + // Custom type converters are only supported for picocli-based metadata. + if (argSpec instanceof PicocliParameterMetadata) + return ((PicocliParameterMetadata) argSpec).convertValue(value); + else if (argSpec instanceof PicocliOptionMetadata) + return ((PicocliOptionMetadata) argSpec).convertValue(value); + + return TypeConverterRegistry.convertValueBasic(value, argSpec.type()); + } + catch (IllegalArgumentException e) + { + throw e; + } + catch (Exception e) + { + throw new IllegalArgumentException(String.format("Failed to convert value '%s' to type %s: %s", + value, + argSpec.type().getName(), + e.getMessage()), e); + } + } + + /** + * This method converts values from CommandExecutionArgs into JSON-serializable formats before + * they're put into a Map(String, Object) that will be serialized to JSON. Different implementations + * (e.g., Set, LinkedHashSet) should be normalized to List for consistent JSON output. + *

+ * The conversion rules are: + * - Converts primitive arrays (String[], int[], etc.) -> Object[] (JSON-serializable) + * - Converts any Collection -> List (JSON-serializable) + * - Leaves other types unchanged + * + * @param value the value to convert. + * @return the converted value. + */ + private static Object normalizeJsonValue(Object value) + { + if (value == null) + return null; + + if (value.getClass().isArray()) + { + int length = Array.getLength(value); + Object[] array = new Object[length]; + for (int i = 0; i < length; i++) + array[i] = normalizeJsonValue(Array.get(value, i)); + return array; + } + + if (value instanceof java.util.Collection) + { + return ((java.util.Collection) value).stream() + .map(CommandExecutionArgsSerde::normalizeJsonValue) + .collect(Collectors.toList()); + } + + return value; + } + + /** + * Find ParameterMetadata by name (supports both an index format and paramLabel). + */ + private static ParameterMetadata findParameterByName(CommandMetadata metadata, String name) + { + if (name.startsWith(COMMAND_POSITIONAL_PARAM_PREFIX)) + { + try + { + int index = Integer.parseInt(name.substring(COMMAND_POSITIONAL_PARAM_PREFIX.length())); + for (ParameterMetadata param : metadata.parameters()) + { + if (param.index() == index) + return param; + } + } + catch (NumberFormatException e) + { + // Not a valid index format, continue to check paramLabel + } + } + + for (ParameterMetadata param : metadata.parameters()) + { + String paramLabel = param.paramLabel(); + if (paramLabel != null && paramLabel.equalsIgnoreCase(name)) + return param; + } + + return null; + } + + /** + * Find OptionMetadata by name (case-insensitive, normalized). + * Matches against paramLabel and all names/aliases. + */ + private static OptionMetadata findOptionByNameIgnoreCase(CommandMetadata metadata, String name) + { + String normalizedName = normalizeOptionName(name); + + for (OptionMetadata option : metadata.options()) + { + String paramLabel = normalizeOptionName(option.paramLabel()); + if (paramLabel.equalsIgnoreCase(normalizedName)) + return option; + + for (String alias : option.names()) + { + String normalizedAlias = normalizeOptionName(alias); + if (normalizedAlias.equalsIgnoreCase(normalizedName)) + return option; + } + } + + return null; + } +} diff --git a/src/java/org/apache/cassandra/management/CommandExecutionException.java b/src/java/org/apache/cassandra/management/CommandExecutionException.java new file mode 100644 index 0000000000..3c4a2d712f --- /dev/null +++ b/src/java/org/apache/cassandra/management/CommandExecutionException.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management; + +import java.util.UUID; + +public class CommandExecutionException extends Exception +{ + private final UUID executionId; + + public CommandExecutionException(String message, Throwable cause, UUID executionId) + { + super(message, cause); + this.executionId = executionId; + } + + public UUID getExecutionId() + { + return executionId; + } +} diff --git a/src/java/org/apache/cassandra/management/CommandInvokerService.java b/src/java/org/apache/cassandra/management/CommandInvokerService.java new file mode 100644 index 0000000000..d96ff41959 --- /dev/null +++ b/src/java/org/apache/cassandra/management/CommandInvokerService.java @@ -0,0 +1,508 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management; + + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +import javax.management.ObjectName; + +import com.google.common.annotations.VisibleForTesting; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.management.api.Command; +import org.apache.cassandra.management.api.CommandExecutionArgs; +import org.apache.cassandra.management.api.CommandExecutionContext; +import org.apache.cassandra.management.api.CommandMetadata; +import org.apache.cassandra.management.api.CommandRegistry; +import org.apache.cassandra.management.api.OptionMetadata; +import org.apache.cassandra.management.api.ParameterMetadata; +import org.apache.cassandra.serializers.MarshalException; +import org.apache.cassandra.tools.NodeProbe; +import org.apache.cassandra.tools.Output; +import org.apache.cassandra.utils.Clock; +import org.apache.cassandra.utils.MBeanWrapper; + +import static java.lang.String.format; +import static org.apache.cassandra.management.ManagementUtils.countCommands; +import static org.apache.cassandra.management.ManagementUtils.findRegistryCommand; +import static org.apache.cassandra.management.ManagementUtils.fullCommandName; + +/** + * Service that manages the command registry lifecycle and execution. + *

+ * Similar to StorageService, SnapshotManager the service coordinates various aspects of command management: + * - Manages lifecycle (initialization, shutdown) + * - Coordinates MBean registration + * - Provides command execution coordination + * - Integrates with daemon startup + * - Manages state (registry, MBean instances) + */ +public class CommandInvokerService implements CommandInvokerServiceMBean +{ + private static final String MBEAN_DOMAIN = "org.apache.cassandra.management"; + private static final String MBEAN_TYPE_COMMAND = "Command"; + private static final int MAX_EXECUTION_HISTORY = 100; + private static final Logger logger = LoggerFactory.getLogger(CommandInvokerService.class); + + public static final CommandInvokerService instance = new CommandInvokerService(); + + private final BoundedExecutionHistory executionHistory = new BoundedExecutionHistory(MAX_EXECUTION_HISTORY); + private final Map commandMBeanNames = new ConcurrentHashMap<>(); + private final MBeanAccessor accessor = new InternalNodeMBeanAccessor(); + private final CommandRegistry registry; + + private volatile boolean started = false; + + private CommandInvokerService() + { + this.registry = new CassandraCommandRegistry(); + } + + public synchronized void start() + { + if (started) + return; + + logger.info("Starting command service"); + + registerCommandMBeansRecursively(registry, ""); + MBeanWrapper.instance.registerMBean(this, MBEAN_NAME); + + started = true; + logger.info("Command service started with '{}' commands", countCommands(registry)); + } + + public static void shutdown() + { + instance.stop(); + } + + public synchronized void stop() + { + if (!started) + return; + + logger.info("Stopping command service"); + + unregisterCommandMBeans(); + + try + { + MBeanWrapper.instance.unregisterMBean(MBEAN_NAME); + } + catch (Exception e) + { + logger.warn("Failed to unregister CommandInvokerService MBean", e); + } + + started = false; + logger.info("Command service stopped"); + } + + public CommandRegistry getRegistry() + { + return registry; + } + + /** + * @param fullCommandName Full command name, including command demimiter for subcommands. + * @param argumentsSupplier Supplier of command execution arguments. + * This is used to defer argument construction until after all validation checks are passed. + * @return captured output of the command execution. + */ + public CommandResult invokeCommand(String fullCommandName, Supplier argumentsSupplier) + throws CommandExecutionException, CommandValidationException, CommandAuthorizationException + { + if (!started) + throw new IllegalStateException("CommandInvokerService is not started"); + + Command command = findRegistryCommand(fullCommandName, registry); + if (command == null) + throw new IllegalArgumentException("Command not found: " + fullCommandName); + + return invokeCommand(fullCommandName, command, argumentsSupplier); + } + + /** + * Execute a command by name with the given arguments. + * + * @param command to execute. + * @param argumentsSupplier supplier of command execution arguments. + * @return captured output of the command execution. + */ + private CommandResult invokeCommand(String fullCommandName, Command command, Supplier argumentsSupplier) + throws CommandExecutionException, CommandValidationException, CommandAuthorizationException + { + if (!started) + throw new IllegalStateException("CommandInvokerService is not started"); + + UUID executionId = UUID.randomUUID(); + CapturingOutput captured = new CapturingOutput(); + ExecutionHistory record = new ExecutionHistory(executionId, fullCommandName, Clock.Global.currentTimeMillis()); + CommandExecutionContext executionContext = new ServerCommandExecutionContext(new NodeProbe(accessor, captured.createOutput())); + executionHistory.add(record); + + try + { + // TODO: CASSANDRA-XXXXX. Restrict command execution to environments without authentication + // This is a temporary limitation until full authentication support is implemented. + if (DatabaseDescriptor.getAuthenticator().requireAuthentication()) + { + throw new CommandAuthorizationException(format("Command execution '%s' via management port is currently " + + "only supported when authentication is disabled " + + "(AllowAllAuthenticator). Full authentication and authorization " + + "support will be added in a future release.", fullCommandName)); + } + + logger.info("Executing command '{}' with execution ID: {}", fullCommandName, executionId); + + CommandExecutionArgs arguments = argumentsSupplier.get(); + validateArguments(arguments, command.metadata()); + + // Currently, for picocli-based commands in C*, which have no structured result, + // the output is written to the Output in the context. + Object ignore = command.execute(arguments, executionContext); + + record.completed(Clock.Global.currentTimeMillis()); + logger.info("Command '{}' (execution ID: {}) completed successfully", fullCommandName, executionId); + return new CommandResult(executionId, + captured.getCapturedOutput(), + record.startTime, + record.endTime - record.startTime); + } + catch (CommandAuthorizationException e) + { + record.failed(Clock.Global.currentTimeMillis(), e); + logger.error("Command '{}' (execution ID: {}) authorization failed", fullCommandName, executionId, e); + throw e; + } + catch (IllegalStateException | IllegalArgumentException | MarshalException e) + { + record.failed(Clock.Global.currentTimeMillis(), e); + String msg = format("Bad usage for command '%s' (execution ID: %s)", fullCommandName, executionId); + logger.error(msg, e); + throw new CommandValidationException(msg, e); + } + catch (Exception e) + { + record.failed(Clock.Global.currentTimeMillis(), e); + String msg = format("Command '%s' (execution ID: %s) execution failed", fullCommandName, executionId); + logger.error(msg, e); + throw new CommandExecutionException(msg, e, executionId); + } + catch (Throwable e) + { + record.failed(Clock.Global.currentTimeMillis(), e); + logger.error("Command '{}' (execution ID: {}) unexpected error", fullCommandName, executionId, e); + throw new CommandExecutionException(format("Unexpected error while executing '%s': %s", + fullCommandName, e.getMessage()), + e, + executionId); + } + } + + @Override + public String[] getCommandNames() + { + List commandNames = new ArrayList<>(); + collectCommandNamesRecursively(registry, "", commandNames); + return commandNames.toArray(new String[0]); + } + + @VisibleForTesting + List executionHistory() + { + return executionHistory.snapshot(); + } + + /** Validate command arguments against metadata. */ + protected void validateArguments(CommandExecutionArgs arguments, CommandMetadata metadata) + { + for (OptionMetadata option : metadata.options()) + { + if (option.required() && !arguments.hasOption(option)) + throw new IllegalArgumentException(String.format("Required option '%s' is missing", option.paramLabel())); + } + + for (ParameterMetadata param : metadata.parameters()) + { + if (param.required() && !arguments.hasParameter(param)) + throw new IllegalArgumentException(String.format("Required parameter at index %d ('%s') is missing", + param.index(), param.paramLabel())); + } + } + + private void collectCommandNamesRecursively(CommandRegistry registry, + String parentCommandName, + List result) + { + for (Map.Entry> entry : registry.commands()) + { + String commandName = entry.getKey(); + Command command = entry.getValue(); + + String fullCommandName = fullCommandName(parentCommandName, commandName); + if (command instanceof CommandRegistry) + collectCommandNamesRecursively((CommandRegistry) command, fullCommandName, result); + else + result.add(fullCommandName); + } + } + + @Override + public int getCommandCount() + { + return countCommands(registry); + } + + @Override + public String getCommandMBeanName(String fullCommandName) + { + ObjectName objectName = commandMBeanNames.get(fullCommandName); + if (objectName == null) + throw new IllegalArgumentException("Command MBean name not found: " + fullCommandName); + return objectName.toString(); + } + + public boolean isStarted() + { + return started; + } + + private void registerCommandMBeansRecursively(CommandRegistry registry, String parentCommandName) + { + for (Map.Entry> e : registry.commands()) + { + String commandName = e.getKey(); + Command command = e.getValue(); + String fullCommandName = fullCommandName(parentCommandName, commandName); + + if (command instanceof CommandRegistry) + { + registerCommandMBeansRecursively((CommandRegistry) command, fullCommandName); + } + else + { + try + { + String escapedName = ObjectName.quote(fullCommandName); + ObjectName objectName = new ObjectName(format("%s:type=%s,name=%s", + MBEAN_DOMAIN, MBEAN_TYPE_COMMAND, escapedName)); + CommandMBeanAdapter commandMBean = new CommandMBeanAdapter(fullCommandName, command, this::invokeCommand); + MBeanWrapper.instance.registerMBean(commandMBean, objectName, MBeanWrapper.OnException.LOG); + + ObjectName prev = commandMBeanNames.putIfAbsent(fullCommandName, objectName); + if (prev != null) + { + throw new IllegalStateException("Command name conflict during MBean registration: '" + + fullCommandName + "' is already registered"); + } + logger.debug("Registered command MBean: {} -> {}", fullCommandName, objectName); + } + catch (Exception ex) + { + logger.warn("Failed to register MBean for command: {}", fullCommandName, ex); + } + } + } + } + + private void unregisterCommandMBeans() + { + for (ObjectName objectName : commandMBeanNames.values()) + MBeanWrapper.instance.unregisterMBean(objectName, MBeanWrapper.OnException.LOG); + commandMBeanNames.clear(); + } + + @VisibleForTesting + static class CapturingOutput + { + private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + private final PrintStream output = new PrintStream(buffer, true, StandardCharsets.UTF_8); + private final PrintStream error = new PrintStream(buffer, true, StandardCharsets.UTF_8); + + public String getCapturedOutput() + { + output.flush(); + error.flush(); + return buffer.toString(StandardCharsets.UTF_8); + } + + Output createOutput() + { + return new Output(output, error); + } + } + + private static class ServerCommandExecutionContext implements CommandExecutionContext + { + private final Map, Object> services = new HashMap<>(); + + public ServerCommandExecutionContext(NodeProbe probe) + { + services.put(NodeProbe.class, probe); + services.put(Output.class, probe.output()); + } + + @Override + @SuppressWarnings("unchecked") + public T service(Class serviceType) + { + T svc = (T) services.get(serviceType); + if (svc == null) + throw new IllegalArgumentException("Service not available in this context: " + serviceType.getName()); + return svc; + } + + @Override + public Set> services() + { + return services.keySet(); + } + } + + public static class CommandResult + { + private final UUID executionId; + private final String output; + private final long startTime; + private final long durationMillis; + + public CommandResult(UUID executionId, String output, long startTime, long durationMillis) + { + this.executionId = executionId; + this.output = output; + this.startTime = startTime; + this.durationMillis = durationMillis; + } + + public UUID getExecutionId() + { + return executionId; + } + + public String getOutput() + { + return output; + } + + public long getStartTime() + { + return startTime; + } + + public long getDurationMillis() + { + return durationMillis; + } + } + + /** + * Record of a single command execution. Retained in {@link BoundedExecutionHistory} and is + * surfaced to operators as command execution history via virtual tables. + */ + static class ExecutionHistory + { + final UUID executionId; + final String commandName; + final long startTime; + volatile long endTime; + volatile boolean success; + volatile Throwable error; + + ExecutionHistory(UUID executionId, String commandName, long startTime) + { + this.executionId = executionId; + this.commandName = commandName; + this.startTime = startTime; + } + + void completed(long endTime) + { + this.endTime = endTime; + this.success = true; + } + + void failed(long endTime, Throwable error) + { + this.endTime = endTime; + this.success = false; + this.error = error; + } + + public UUID executionId() { return executionId; } + public String commandName() { return commandName; } + public boolean isSuccess() { return success; } + public Throwable error() { return error; } + } + + private static class BoundedExecutionHistory + { + private final Deque dq = new ConcurrentLinkedDeque<>(); + private final AtomicInteger size = new AtomicInteger(0); + private final int maxSize; + + public BoundedExecutionHistory(int maxSize) + { + this.maxSize = maxSize; + } + + public void add(ExecutionHistory info) + { + dq.offer(info); + + if (size.incrementAndGet() > maxSize) + { + ExecutionHistory removed = dq.pollFirst(); + if (removed != null) + size.decrementAndGet(); + } + } + + /** Returns a point-in-time snapshot of the retained records, oldest first. */ + public List snapshot() + { + return new ArrayList<>(dq); + } + } + + @FunctionalInterface + public interface Executor + { + CommandResult execute(String commandName, Supplier argsSupplier) + throws CommandExecutionException, CommandValidationException, CommandAuthorizationException; + } +} diff --git a/src/java/org/apache/cassandra/management/CommandInvokerServiceMBean.java b/src/java/org/apache/cassandra/management/CommandInvokerServiceMBean.java new file mode 100644 index 0000000000..bc81118e01 --- /dev/null +++ b/src/java/org/apache/cassandra/management/CommandInvokerServiceMBean.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management; + +/** + * MBean interface for CommandInvokerService. + * Exposes registry-level operations (not individual commands). + */ +public interface CommandInvokerServiceMBean +{ + String MBEAN_NAME = "org.apache.cassandra.management:type=CommandInvokerService"; + + /** + * Get a list of all command names in the registry. + * @return array of command names + */ + String[] getCommandNames(); + + /** + * Get the total number of commands in the registry. + * @return number of commands + */ + int getCommandCount(); + + /** + * Get ObjectName string for a specific command MBean. + * @param fullCommandName name of the command + * @return ObjectName string for the command MBean + * @throws IllegalArgumentException if command not found + */ + String getCommandMBeanName(String fullCommandName); +} diff --git a/src/java/org/apache/cassandra/management/CommandMBeanAdapter.java b/src/java/org/apache/cassandra/management/CommandMBeanAdapter.java new file mode 100644 index 0000000000..3702c350ca --- /dev/null +++ b/src/java/org/apache/cassandra/management/CommandMBeanAdapter.java @@ -0,0 +1,349 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; + +import javax.management.Attribute; +import javax.management.AttributeList; +import javax.management.AttributeNotFoundException; +import javax.management.DynamicMBean; +import javax.management.InvalidAttributeValueException; +import javax.management.MBeanException; +import javax.management.MBeanInfo; +import javax.management.MBeanOperationInfo; +import javax.management.MBeanParameterInfo; +import javax.management.ReflectionException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.cassandra.management.api.ArgumentMetadata; +import org.apache.cassandra.management.api.Command; +import org.apache.cassandra.management.api.CommandMetadata; +import org.apache.cassandra.management.api.OptionMetadata; +import org.apache.cassandra.management.api.ParameterMetadata; +import org.apache.cassandra.utils.JsonUtils; + +import static javax.management.MBeanOperationInfo.ACTION; +import static javax.management.MBeanOperationInfo.INFO; +import static org.apache.cassandra.cql3.statements.ExecuteCommandStatement.COMMAND_RESULT_SCHEMA_EXECUTION_ID; +import static org.apache.cassandra.cql3.statements.ExecuteCommandStatement.COMMAND_RESULT_SCHEMA_OUTPUT; +import static org.apache.cassandra.management.api.ParameterMetadata.COMMAND_POSITIONAL_PARAM_PREFIX; +import static org.apache.cassandra.utils.JsonUtils.convertDefaultValue; +import static org.apache.cassandra.utils.JsonUtils.getJsonType; + +/** + * Command MBean exposes a single management command to the JMX interface. + * + *

Uses JSON-based parameter format: + *

    + *
  • Single "invoke" operation with JSON string parameter
  • + *
  • JSON format: {"optionName": "value", "param0": "value", ...}
  • + *
  • Option names: use option name or any alias (e.g., "concurrent-compactors", "--concurrent-compactors")
  • + *
  • Positional parameters: use "param0", "param1", etc. or parameter name
  • + *
  • Returns command output as String
  • + *
+ * + *

+ * Invocation: + *

+ * // Command: setconcurrentcompactors --concurrent-compactors 4
+ * mbean.invoke("invoke",
+ *     new Object[]{"{\"concurrent-compactors\": \"4\"}"},
+ *     new String[]{ "String" });
+ *
+ * // Command: getendpoints keyspace table
+ * mbean.invoke("invoke",
+ *     new Object[]{"{\"param0\": \"mykeyspace\", \"param1\": \"mytable\"}"},
+ *     new String[]{ "String" });
+ * 
+ * + *

The {@code invoke} operation accepts exactly one argument: the JSON string above. Other forms + * (e.g. name-value pairs spread across multiple arguments) are not supported and are rejected with an + * {@link IllegalArgumentException}. + */ +public class CommandMBeanAdapter implements DynamicMBean +{ + public static final String INVOKE_METHOD = "invoke"; + public static final String GET_JSON_SCHEMA_METHOD = "getJsonSchema"; + public static final String GET_PARAMETER_INFO_METHOD = "getParameterInfo"; + + private static final Logger logger = LoggerFactory.getLogger(CommandMBeanAdapter.class); + + private final String fullCommandName; + private final CommandMetadata metadata; + private final CommandInvokerService.Executor executor; + + public CommandMBeanAdapter(String fullCommandName, Command command, CommandInvokerService.Executor executor) + { + this.fullCommandName = fullCommandName; + this.metadata = command.metadata(); + this.executor = executor; + } + + @Override + public MBeanInfo getMBeanInfo() + { + List operations = new ArrayList<>(); + + operations.add(new MBeanOperationInfo( + INVOKE_METHOD, + "Execute command with JSON parameters. Format: {\"optionName\": \"value\", \"param0\": \"value\", ...}", + new MBeanParameterInfo[]{ + new MBeanParameterInfo("jsonParameters", + String.class.getName(), + "JSON object with command parameters. Use getJsonSchema() to see available parameters and types.") + }, + String.class.getName(), + ACTION)); + + operations.add(new MBeanOperationInfo( + GET_JSON_SCHEMA_METHOD, + "Get JSON schema describing all available parameters (name -> type mapping)", + new MBeanParameterInfo[0], + String.class.getName(), + INFO)); + + operations.add(new MBeanOperationInfo( + GET_PARAMETER_INFO_METHOD, + "Get information about all available parameters (human-readable format)", + new MBeanParameterInfo[0], + String.class.getName(), + INFO)); + + return new MBeanInfo(CommandMBeanAdapter.class.getName(), + metadata.description() != null ? metadata.description() : "Command: " + metadata.name(), + null, + null, + operations.toArray(new MBeanOperationInfo[0]), + null); + } + + @Override + public Object invoke(String actionName, Object[] params, String[] signature) throws MBeanException, ReflectionException + { + if (INVOKE_METHOD.equals(actionName)) + { + if (params == null || params.length != 1 || !(params[0] instanceof String)) + throw new IllegalArgumentException("invoke requires exactly one parameter (JSON string)"); + + String jsonParams = (String) params[0]; + try + { + CommandInvokerService.CommandResult result = executor.execute(fullCommandName, + () -> CommandExecutionArgsSerde.fromJson(jsonParams, metadata)); + + Map jsonResult = new TreeMap<>(); + jsonResult.put(COMMAND_RESULT_SCHEMA_EXECUTION_ID, result.getExecutionId().toString()); + jsonResult.put(COMMAND_RESULT_SCHEMA_OUTPUT, result.getOutput()); + return JsonUtils.writeAsJsonString(jsonResult); + } + catch (CommandAuthorizationException e) + { + logger.error("Authorization error executing command: {}", metadata.name(), e); + throw new SecurityException("Access Denied: " + e.getMessage()); + } + catch (CommandValidationException e) + { + logger.error("Validation error for command: {}", metadata.name(), e); + throw new IllegalArgumentException(ManagementUtils.causeMessages(e)); + } + catch (CommandExecutionException e) + { + logger.error("Error executing command: {}", metadata.name(), e); + throw new RuntimeException(ManagementUtils.causeMessages(e)); + } + catch (Exception e) + { + logger.error("Unexpected error executing command: {}", metadata.name(), e); + throw new RuntimeException("Unexpected error executing command: " + e.getMessage(), e); + } + } + + if (GET_JSON_SCHEMA_METHOD.equals(actionName)) + return getJsonSchema(); + + if (GET_PARAMETER_INFO_METHOD.equals(actionName)) + return getParameterInfo(); + + throw new UnsupportedOperationException("Unknown operation: " + actionName); + } + + public String getJsonSchema() + { + try + { + Map schema = new LinkedHashMap<>(); + schema.put("$schema", "http://json-schema.org/draft-07/schema#"); + schema.put("type", "object"); + schema.put("title", metadata.name()); + schema.put("description", metadata.description()); + + Map properties = new LinkedHashMap<>(); + List required = new ArrayList<>(); + + for (OptionMetadata option : metadata.options()) + { + String primaryName = option.paramLabel(); + properties.put(primaryName, buildJsonSchemaProperty(option)); + + if (option.required()) + required.add(primaryName); + } + + List sortedParams = new ArrayList<>(metadata.parameters()); + sortedParams.sort(Comparator.comparingInt(ParameterMetadata::index)); + + for (ParameterMetadata param : sortedParams) + { + String paramName = COMMAND_POSITIONAL_PARAM_PREFIX + param.index(); + properties.put(paramName, buildJsonSchemaProperty(param)); + + if (param.required()) + required.add(paramName); + } + + schema.put("properties", properties); + + if (!required.isEmpty()) + schema.put("required", required); + + return JsonUtils.writeAsPrettyJsonString(schema); + } + catch (Exception e) + { + logger.error("Error generating JSON schema for command: {}", metadata.name(), e); + throw new RuntimeException("Failed to generate JSON schema: " + e.getMessage(), e); + } + } + + private Map buildJsonSchemaProperty(ArgumentMetadata arg) + { + Map prop = new LinkedHashMap<>(); + prop.put("type", getJsonType(arg.type())); + + if (arg.names() != null && arg.names().length > 0) + prop.put("aliases", Arrays.stream(arg.names()) + .filter(name -> !name.equals(arg.paramLabel())) + .collect(Collectors.toList())); + + if (arg.description() != null && !arg.description().isEmpty()) + prop.put("description", arg.description()); + + String defaultValue = arg.defaultValue(); + if (defaultValue != null && !defaultValue.isEmpty()) + prop.put("default", convertDefaultValue(defaultValue, arg.type())); + + if (arg.type().isArray() || List.class.isAssignableFrom(arg.type())) + { + prop.put("type", "array"); + Map items = new LinkedHashMap<>(); + items.put("type", "string"); + prop.put("items", items); + } + + if (arg.type().isEnum()) + { + prop.put("enum", Arrays.stream(arg.type().getEnumConstants()) + .map(Object::toString) + .collect(Collectors.toList())); + } + + return prop; + } + + private static String getTypeName(Class type) + { + return type.getCanonicalName(); + } + + private String getParameterInfo() + { + StringBuilder info = new StringBuilder(); + info.append("Command: ").append(metadata.name()).append('\n'); + info.append("Description: ").append(metadata.description()).append("\n\n"); + + info.append("Options:\n"); + for (OptionMetadata option : metadata.options()) + { + info.append(" - ").append(option.paramLabel()); + if (option.names().length > 0) + info.append(" (aliases: ").append(String.join(", ", option.names())).append(')'); + appendRequiredClause(info, getTypeName(option.type()), option.required(), option.description()); + } + + info.append("\nPositional Parameters:\n"); + List sortedParams = new ArrayList<>(metadata.parameters()); + sortedParams.sort((a, b) -> Integer.compare(a.index(), b.index())); + + for (ParameterMetadata param : sortedParams) + { + info.append(" - param").append(param.index()); + if (param.paramLabel() != null && !param.paramLabel().isEmpty()) + info.append(" (").append(param.paramLabel()).append(')'); + appendRequiredClause(info, getTypeName(param.type()), param.required(), param.description()); + } + + return info.toString(); + } + + private static void appendRequiredClause(StringBuilder info, + String typeName, + boolean required, + String description) + { + info.append(" [").append(typeName).append(']'); + if (required) + info.append(" [REQUIRED]"); + if (description != null && !description.isEmpty()) + info.append("\n ").append(description); + info.append('\n'); + } + + @Override + public Object getAttribute(String attribute) throws AttributeNotFoundException, MBeanException, ReflectionException + { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void setAttribute(Attribute attribute) throws AttributeNotFoundException, InvalidAttributeValueException, MBeanException, ReflectionException + { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public AttributeList getAttributes(String[] attributes) + { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public AttributeList setAttributes(AttributeList attributes) + { + throw new UnsupportedOperationException("Not supported yet."); + } +} diff --git a/src/java/org/apache/cassandra/management/CommandValidationException.java b/src/java/org/apache/cassandra/management/CommandValidationException.java new file mode 100644 index 0000000000..4419c4ca7c --- /dev/null +++ b/src/java/org/apache/cassandra/management/CommandValidationException.java @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management; + +public class CommandValidationException extends Exception +{ + public CommandValidationException(String message, Throwable cause) + { + super(message, cause); + } +} diff --git a/src/java/org/apache/cassandra/management/InternalNodeMBeanAccessor.java b/src/java/org/apache/cassandra/management/InternalNodeMBeanAccessor.java new file mode 100644 index 0000000000..2f7ef6e606 --- /dev/null +++ b/src/java/org/apache/cassandra/management/InternalNodeMBeanAccessor.java @@ -0,0 +1,508 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management; + +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.lang.management.RuntimeMXBean; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Hashtable; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import javax.management.JMX; +import javax.management.MBeanServer; +import javax.management.MalformedObjectNameException; +import javax.management.ObjectName; + +import org.apache.cassandra.audit.AuditLogManager; +import org.apache.cassandra.audit.AuditLogManagerMBean; +import org.apache.cassandra.auth.AbstractCIDRAuthorizer; +import org.apache.cassandra.auth.AuthCache; +import org.apache.cassandra.auth.AuthCacheMBean; +import org.apache.cassandra.auth.AuthCacheService; +import org.apache.cassandra.auth.CIDRGroupsMappingManagerMBean; +import org.apache.cassandra.auth.CIDRPermissionsManagerMBean; +import org.apache.cassandra.auth.NetworkPermissionsCacheMBean; +import org.apache.cassandra.auth.PasswordAuthenticator; +import org.apache.cassandra.auth.PermissionsCacheMBean; +import org.apache.cassandra.auth.RolesCacheMBean; +import org.apache.cassandra.auth.jmx.AuthorizationProxy; +import org.apache.cassandra.batchlog.BatchlogManager; +import org.apache.cassandra.batchlog.BatchlogManagerMBean; +import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.db.ColumnFamilyStore; +import org.apache.cassandra.db.ColumnFamilyStoreMBean; +import org.apache.cassandra.db.Keyspace; +import org.apache.cassandra.db.compaction.CompactionManager; +import org.apache.cassandra.db.compaction.CompactionManagerMBean; +import org.apache.cassandra.db.compression.CompressionDictionaryManager; +import org.apache.cassandra.db.compression.CompressionDictionaryManagerMBean; +import org.apache.cassandra.db.guardrails.Guardrails; +import org.apache.cassandra.db.guardrails.GuardrailsMBean; +import org.apache.cassandra.db.virtual.CIDRFilteringMetricsTable; +import org.apache.cassandra.db.virtual.CIDRFilteringMetricsTableMBean; +import org.apache.cassandra.gms.FailureDetector; +import org.apache.cassandra.gms.FailureDetectorMBean; +import org.apache.cassandra.gms.Gossiper; +import org.apache.cassandra.gms.GossiperMBean; +import org.apache.cassandra.hints.HintsService; +import org.apache.cassandra.hints.HintsServiceMBean; +import org.apache.cassandra.index.Index; +import org.apache.cassandra.index.SecondaryIndexManager; +import org.apache.cassandra.locator.DynamicEndpointSnitch; +import org.apache.cassandra.locator.DynamicEndpointSnitchMBean; +import org.apache.cassandra.locator.EndpointSnitchInfo; +import org.apache.cassandra.locator.EndpointSnitchInfoMBean; +import org.apache.cassandra.locator.LocationInfo; +import org.apache.cassandra.locator.LocationInfoMBean; +import org.apache.cassandra.locator.NodeProximity; +import org.apache.cassandra.metrics.CassandraMetricsRegistry; +import org.apache.cassandra.metrics.ThreadPoolMetrics; +import org.apache.cassandra.net.MessagingService; +import org.apache.cassandra.net.MessagingServiceMBean; +import org.apache.cassandra.profiler.AsyncProfilerMBean; +import org.apache.cassandra.schema.Schema; +import org.apache.cassandra.service.ActiveRepairService; +import org.apache.cassandra.service.ActiveRepairServiceMBean; +import org.apache.cassandra.service.AsyncProfilerService; +import org.apache.cassandra.service.AutoRepairService; +import org.apache.cassandra.service.AutoRepairServiceMBean; +import org.apache.cassandra.service.CacheService; +import org.apache.cassandra.service.CacheServiceMBean; +import org.apache.cassandra.service.GCInspector; +import org.apache.cassandra.service.GCInspectorMXBean; +import org.apache.cassandra.service.StorageProxy; +import org.apache.cassandra.service.StorageProxyMBean; +import org.apache.cassandra.service.StorageService; +import org.apache.cassandra.service.StorageServiceMBean; +import org.apache.cassandra.service.accord.AccordOperations; +import org.apache.cassandra.service.accord.AccordOperationsMBean; +import org.apache.cassandra.service.snapshot.SnapshotManager; +import org.apache.cassandra.service.snapshot.SnapshotManagerMBean; +import org.apache.cassandra.streaming.StreamManager; +import org.apache.cassandra.streaming.StreamManagerMBean; +import org.apache.cassandra.tcm.CMSOperations; +import org.apache.cassandra.tcm.CMSOperationsMBean; +import org.apache.cassandra.tools.RemoteJmxMBeanAccessor; +import org.apache.cassandra.utils.MBeanWrapper; + +import static org.apache.cassandra.metrics.CassandraMetricsRegistry.Metrics; +import static org.apache.cassandra.service.CassandraDaemon.SKIP_GC_INSPECTOR; + +/** + * Server-side implementation of {@link MBeanAccessor} for in-process execution. + * + *

+ * This implementation provides direct access to MBean instances without using JMX, + * eliminating the need for remote connections or JMX proxies. It is designed for + * server-side command execution as part of CEP-38 Management API, where commands run + * in the same JVM as the Cassandra daemon. + * + *

+ * Unlike {@link RemoteJmxMBeanAccessor}, this implementation: + *

    + *
  • Directly accesses singleton instances (e.g., {@code StorageService.instance})
  • + *
  • Does not require network connections or JMX connectors
  • + *
  • Provides better performance by avoiding serialization/deserialization
  • + *
  • Has no connection state to manage
  • + *
  • Works directly with {@link Keyspace} and {@link ColumnFamilyStore} instances
  • + *
+ * + *

+ * Lazy Initialization: MBean providers are initialized registered for known MBeans. + * + * @see MBeanAccessor + * @see RemoteJmxMBeanAccessor + * @since 5.1 + */ +public class InternalNodeMBeanAccessor implements MBeanAccessor +{ + private final Map, MBeanProvider> mBeanProviders = new ConcurrentHashMap<>(); + private final Map, Object> mBeanCache = new ConcurrentHashMap<>(); + private final Map metricCache = new ConcurrentHashMap<>(); + + /** + * Creates a new InternalNodeMBeanAccessor using direct instance access. + */ + public InternalNodeMBeanAccessor() + { + initializeMBeanProviders(); + } + + /** + * Initializes all statically known MBean instances. + */ + private void initializeMBeanProviders() + { + registerMBeanProvider(AccordOperationsMBean.class, () -> AccordOperations.instance); + registerMBeanProvider(ActiveRepairServiceMBean.class, ActiveRepairService::instance); + registerMBeanProvider(AuditLogManagerMBean.class, () -> AuditLogManager.instance); + registerMBeanProvider(AutoRepairServiceMBean.class, () -> AutoRepairService.instance); + registerMBeanProvider(BatchlogManagerMBean.class, () -> BatchlogManager.instance); + registerMBeanProvider(CMSOperationsMBean.class, () -> CMSOperations.instance); + registerMBeanProvider(CacheServiceMBean.class, () -> CacheService.instance); + registerMBeanProvider(CompactionManagerMBean.class, () -> CompactionManager.instance); + registerMBeanProvider(DynamicEndpointSnitchMBean.class, this::resolveDynamicEndpointSnitch); + registerMBeanProvider(FailureDetectorMBean.class, () -> (FailureDetectorMBean) FailureDetector.instance); + registerMBeanProvider(GCInspectorMXBean.class, this::resolveGCInspector); + registerMBeanProvider(GossiperMBean.class, () -> Gossiper.instance); + registerMBeanProvider(GuardrailsMBean.class, () -> Guardrails.instance); + registerMBeanProvider(HintsServiceMBean.class, () -> HintsService.instance); + registerMBeanProvider(MemoryMXBean.class, ManagementFactory::getMemoryMXBean); + registerMBeanProvider(MessagingServiceMBean.class, MessagingService::instance); + registerMBeanProvider(RuntimeMXBean.class, ManagementFactory::getRuntimeMXBean); + registerMBeanProvider(SnapshotManagerMBean.class, () -> SnapshotManager.instance); + registerMBeanProvider(StorageProxyMBean.class, () -> StorageProxy.instance); + registerMBeanProvider(StorageServiceMBean.class, () -> StorageService.instance); + registerMBeanProvider(StreamManagerMBean.class, () -> StreamManager.instance); + registerMBeanProvider(AsyncProfilerMBean.class, AsyncProfilerService::instance); + + // Utility MBeans are stateless and can be created on demand. + // They query DatabaseDescriptor for the current state, so new instances are fine + registerMBeanProvider(EndpointSnitchInfoMBean.class, EndpointSnitchInfo::new); + registerMBeanProvider(LocationInfoMBean.class, LocationInfo::new); + + // AuthCache MBeans + registerMBeanProvider(AuthorizationProxy.JmxPermissionsCacheMBean.class, + () -> findAuthCache(AuthorizationProxy.JmxPermissionsCacheMBean.class)); + registerMBeanProvider(NetworkPermissionsCacheMBean.class, + () -> findAuthCache(NetworkPermissionsCacheMBean.class)); + registerMBeanProvider(PasswordAuthenticator.CredentialsCacheMBean.class, + () -> findAuthCache(PasswordAuthenticator.CredentialsCacheMBean.class)); + registerMBeanProvider(PermissionsCacheMBean.class, + () -> findAuthCache(PermissionsCacheMBean.class)); + registerMBeanProvider(RolesCacheMBean.class, + () -> findAuthCache(RolesCacheMBean.class)); + + // CIDR Auth MBeans + registerMBeanProvider(CIDRFilteringMetricsTableMBean.class, () -> CIDRFilteringMetricsTable.instance); + registerMBeanProvider(CIDRGroupsMappingManagerMBean.class, () -> AbstractCIDRAuthorizer.cidrGroupsMappingManager); + registerMBeanProvider(CIDRPermissionsManagerMBean.class, () -> AbstractCIDRAuthorizer.cidrPermissionsManager); + } + + /** + * Gets DynamicEndpointSnitch from DatabaseDescriptor if it's a DynamicEndpointSnitch, + * otherwise returns null. + */ + private DynamicEndpointSnitchMBean resolveDynamicEndpointSnitch() + { + if (!DatabaseDescriptor.isDynamicEndpointSnitch()) + throw new IllegalStateException("DynamicEndpointSnitch has been requested but is not enabled"); + + NodeProximity proximity = DatabaseDescriptor.getNodeProximity(); + assert proximity instanceof DynamicEndpointSnitch; + + return (DynamicEndpointSnitchMBean) proximity; + } + + private GCInspectorMXBean resolveGCInspector() + { + if (SKIP_GC_INSPECTOR) + throw new IllegalStateException("GCInspector has been requested but is disabled via SKIP_GC_INSPECTOR flag"); + + try + { + MBeanServer mbs = MBeanWrapper.instance.getMBeanServer(); + if (mbs == null) + return null; + + ObjectName name = new ObjectName(GCInspector.MBEAN_NAME); + if (mbs.isRegistered(name)) + return JMX.newMBeanProxy(mbs, name, GCInspectorMXBean.class); + } + catch (Exception e) + { + // Fall through to create a new instance + } + + return null; + } + + /** Finds an auth cache MBean instance from AuthCacheService. */ + private T findAuthCache(Class clazz) + { + Set> caches = AuthCacheService.instance.getCaches(); + if (caches.isEmpty()) + return null; + + AuthCacheFinder visitor = new AuthCacheFinder(clazz); + for (AuthCache cache : caches) + { + cache.accept(visitor); + Object found = visitor.getCache(); + if (found == null) + continue; + return clazz.cast(found); + } + return null; + } + + private void registerMBeanProvider(Class clazz, MBeanProvider locator) + { + Object prev = mBeanProviders.putIfAbsent(clazz, locator); + assert prev == null : "MBean locator for " + clazz.getName() + " is already registered"; + } + + @Override + public T findMBean(Class clazz) + { + Object cached = mBeanCache.get(clazz); + if (cached != null) + return clazz.cast(cached); + + Object created = mBeanCache.computeIfAbsent(clazz, k -> { + MBeanProvider provider = mBeanProviders.get(k); + return provider == null ? null : provider.provide(); + }); + return created == null ? null : clazz.cast(created); + } + + private MBeanServer mbeanServer() + { + MBeanServer mbs = MBeanWrapper.instance.getMBeanServer(); + if (mbs == null) + throw new IllegalStateException("The MBean server is not available (MBean registration is disabled on this node)"); + return mbs; + } + + @Override + public T findMBeanMetric(Class clazz, Props props) + { + try + { + assert clazz.isInterface() && CassandraMetricsRegistry.MetricMBean.class.isAssignableFrom(clazz); + + ObjectName objectName = buildObjectNameFromProps(props); + String cacheKey = objectName.getCanonicalName(); + + @SuppressWarnings("unchecked") + T cached = (T) metricCache.get(cacheKey); + if (cached != null) + return cached; + + MBeanServer mbs = mbeanServer(); + + return clazz.cast(metricCache.computeIfAbsent(cacheKey, k -> JMX.newMBeanProxy(mbs, objectName, clazz))); + } + catch (Exception e) + { + throw new RuntimeException("Error accessing metric MBean: " + e.getMessage(), e); + } + } + + @Override + public boolean isMBeanMetricRegistered(Props props) + { + try + { + // Use the internal MBean server to look up MBean by ObjectName. This leverages existing + // JMX infrastructure and avoids the complexity of constructing metric names from Props. + // + // Alternatively, we could use CassandraMetricsRegistry to look up metrics by metric name + // (e.g., "org.apache.cassandra.metrics.Keyspace.ReadLatency.mykeyspace"), but this requires + // constructing the full metric name from Props, which is problematic. + // + // The reconstructing the scope problem: Different MetricNameFactory implementations + // construct scopes differently: + // - KeyspaceMetrics: scope = keyspace property + // - TableMetrics: scope = keyspace + '.' + scope property + // - DefaultNameFactory: scope = scope property + // - SAI AbstractMetrics: scope = keyspace.table.index.scope (all combined) + // + // To construct metric names from Props, we would need to duplicate scope construction logic + // from each factory or refactor to share it. Using ObjectName, in turn, avoids this. We query + // MBeanServer using the ObjectName pattern already constructed by factories during registration. + // + // However, it will be beneficial to revisit this down the line for performance optimizations and + // to avoid JMX entanglement, so we could switch it off for in-process access. + + ObjectName objectName = buildObjectNameFromProps(props); + return mbeanServer().isRegistered(objectName); + } + catch (Exception e) + { + throw new RuntimeException("Error checking metric MBean registration: " + e.getMessage(), e); + } + } + + private static ObjectName buildObjectNameFromProps(Props props) throws MalformedObjectNameException + { + return new ObjectName("org.apache.cassandra.metrics", new Hashtable<>(props.toMap())); + } + + @Override + public ColumnFamilyStoreMBean findColumnFamily(String type, String keyspace, String columnFamily) + { + Keyspace ks = Schema.instance.getKeyspaceInstance(keyspace); + if (ks == null) + throw new IllegalArgumentException("Keyspace not found: " + keyspace); + + if (!SecondaryIndexManager.isIndexColumnFamily(columnFamily)) + return ks.getColumnFamilyStore(columnFamily); + + // Secondary-index stores are registered under "base.index" names and are only + // reachable through the base table's index manager, not by keyspace lookup. + ColumnFamilyStore base = ks.getColumnFamilyStore(SecondaryIndexManager.getParentCfsName(columnFamily)); + Index index = base.indexManager.getIndexByName(SecondaryIndexManager.getIndexName(columnFamily)); + if (index == null) + throw new IllegalArgumentException(String.format("Index not found: %s.%s", keyspace, columnFamily)); + return index.getBackingTable() + .orElseThrow(() -> new IllegalArgumentException(String.format("Index %s.%s has no backing table", + keyspace, columnFamily))); + } + + @Override + public CompressionDictionaryManagerMBean findCompressionDictionary(String keyspace, String table) + { + Keyspace ks = Schema.instance.getKeyspaceInstance(keyspace); + if (ks == null || Schema.instance.getTableMetadata(keyspace, table) == null) + throw new IllegalArgumentException(String.format("Table %s.%s does not exist", keyspace, table)); + + CompressionDictionaryManager manager = ks.getColumnFamilyStore(table).compressionDictionaryManager(); + if (!manager.isEnabled()) + throw new IllegalStateException("The compression on table " + keyspace + '.' + table + + " is not enabled or SSTable compressor is not a dictionary compressor."); + return manager; + } + + @Override + public List threadPoolInfos() + { + List infos = new ArrayList<>(); + for (ThreadPoolMetrics metrics : Metrics.allThreadPoolMetrics()) + infos.add(new ThreadPoolInfo(metrics.path, metrics.poolName)); + return infos; + } + + @Override + public List> findColumnFamilies(String type) + { + try + { + assert type.equals("IndexColumnFamilies") || type.equals("ColumnFamilies"); + + List> mbeans = new ArrayList<>(); + + for (Keyspace keyspace : Keyspace.all()) + { + for (ColumnFamilyStore cfs : keyspace.getColumnFamilyStores()) + { + // Secondary-index backing stores are not registered in the keyspace, + // they are only reachable through the base table's index manager. + if (type.equals("IndexColumnFamilies")) + for (ColumnFamilyStore indexCfs : cfs.indexManager.getAllIndexColumnFamilyStores()) + mbeans.add(new AbstractMap.SimpleImmutableEntry<>(keyspace.getName(), indexCfs)); + else + mbeans.add(new AbstractMap.SimpleImmutableEntry<>(keyspace.getName(), cfs)); + } + } + + return mbeans; + } + catch (Exception e) + { + throw new RuntimeException("Error accessing column families", e); + } + } + + @Override + public void close() + { + metricCache.clear(); + mBeanCache.clear(); + } + + /** + * Functional interface for providing MBean instances lazily. + * Used to defer MBean initialization until the MBean is actually accessed. + * + * @param the MBean interface type + */ + @FunctionalInterface + public interface MBeanProvider + { + /** + * @return the MBean instance, or {@code null} if the MBean is not available + * @throws RuntimeException if the MBean cannot be provided (e.g., not initialized yet) + */ + T provide(); + } + + /** Visitor that finds a specific auth cache MBean type from AuthCacheService. */ + private static class AuthCacheFinder implements AuthCache.MBeanVisitor + { + private final Class targetType; + private Object foundCache; + + AuthCacheFinder(Class targetType) + { + this.targetType = targetType; + } + + @Override + public void visitCredentials(PasswordAuthenticator.CredentialsCacheMBean cache) + { + if (targetType.equals(PasswordAuthenticator.CredentialsCacheMBean.class)) + foundCache = cache; + } + + @Override + public void visitJmxPermissions(AuthorizationProxy.JmxPermissionsCacheMBean cache) + { + if (targetType.equals(AuthorizationProxy.JmxPermissionsCacheMBean.class)) + foundCache = cache; + } + + @Override + public void visitPermissions(PermissionsCacheMBean cache) + { + if (targetType.equals(PermissionsCacheMBean.class)) + foundCache = cache; + } + + @Override + public void visitNetwork(NetworkPermissionsCacheMBean cache) + { + if (targetType.equals(NetworkPermissionsCacheMBean.class)) + foundCache = cache; + } + + @Override + public void visitRoles(RolesCacheMBean cache) + { + if (targetType.equals(RolesCacheMBean.class)) + foundCache = cache; + } + + @Override + public void visit(AuthCacheMBean cache) + { + // No-op. Used for caches without specific MBean types. + } + + Object getCache() + { + return foundCache; + } + } +} \ No newline at end of file diff --git a/src/java/org/apache/cassandra/management/MBeanAccessor.java b/src/java/org/apache/cassandra/management/MBeanAccessor.java new file mode 100644 index 0000000000..4a3a8d34f3 --- /dev/null +++ b/src/java/org/apache/cassandra/management/MBeanAccessor.java @@ -0,0 +1,165 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management; + + +import java.util.List; +import java.util.Map; + +import javax.annotation.Nullable; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; + +import org.apache.cassandra.db.ColumnFamilyStoreMBean; +import org.apache.cassandra.db.compression.CompressionDictionaryManagerMBean; +import org.apache.cassandra.metrics.CassandraMetricsRegistry; + +public interface MBeanAccessor extends AutoCloseable +{ + /** + * Finds statically known MBeans by MBean class name. + * @param clazz the MBean class. + * @return MBean class or {@code null} if not found. + * @param the MBean type. + */ + @Nullable + T findMBean(Class clazz); + T findMBeanMetric(Class clazz, Props props); + boolean isMBeanMetricRegistered(Props props); + + ColumnFamilyStoreMBean findColumnFamily(String type, String keyspace, String columnFamily); + CompressionDictionaryManagerMBean findCompressionDictionary(String keyspace, String table); + + /** List of thread pool MBeans with associated path and pool name. */ + List threadPoolInfos(); + /** List of column family MBeans with associated keyspace name. */ + List> findColumnFamilies(String type); + + /** {@inheritDoc} */ + @Override + default void close() { } + + default CassandraMetricsRegistry.JmxCounterMBean findMBeanCounter(Props props) + { + return findMBeanMetric(CassandraMetricsRegistry.JmxCounterMBean.class, props); + } + + default CassandraMetricsRegistry.JmxGaugeMBean findMBeanGauge(Props props) + { + return findMBeanMetric(CassandraMetricsRegistry.JmxGaugeMBean.class, props); + } + + default CassandraMetricsRegistry.JmxMeterMBean findMBeanMeter(Props props) + { + return findMBeanMetric(CassandraMetricsRegistry.JmxMeterMBean.class, props); + } + + default CassandraMetricsRegistry.JmxTimerMBean findMBeanTimer(Props props) + { + return findMBeanMetric(CassandraMetricsRegistry.JmxTimerMBean.class, props); + } + + default CassandraMetricsRegistry.JmxHistogramMBean findMBeanHistogram(Props props) + { + return findMBeanMetric(CassandraMetricsRegistry.JmxHistogramMBean.class, props); + } + + class Props + { + private final Map values = Maps.newLinkedHashMap(); + + public Props(String type, String path, String keyspace, String table, String scope, String name) + { + if (type == null || type.isEmpty()) + throw new IllegalArgumentException("type is required"); + values.put("type", type); + if (path != null) + values.put("path", path); + if (keyspace != null) + values.put("keyspace", keyspace); + if (table != null) + values.put("table", table); + if (scope != null) + values.put("scope", scope); + if (name != null) + values.put("name", name); + } + + public static Props metric(String type, String name) + { + return new Props(type, null, null, null, null, name); + } + + public static Props scoped(String type, String scope, String name) + { + return new Props(type, null, null, null, scope, name); + } + + public static Props threadPool(String type, String path, String scope, String name) + { + return new Props(type, path, null, null, scope, name); + } + + public static Props sai(String type, String keyspace, String table, String scope, String name) + { + return new Props(type, null, keyspace, table, scope, name); + } + + public static Props columnFamily(String type, String keyspace, String scope, String name) + { + return new Props(type, null, keyspace, null, scope, name); + } + + public static Props keyspace(String type, String keyspace, String name) + { + return new Props(type, null, keyspace, null, null, name); + } + + public Map toMap() + { + return ImmutableMap.copyOf(values); + } + } + + class ThreadPoolInfo + { + private final String path; + private final String poolName; + + public ThreadPoolInfo(String path, String poolName) + { + this.path = path; + this.poolName = poolName; + } + + public String path() + { + return path; + } + public String poolName() + { + return poolName; + } + public String scope() + { + return path + '.' + poolName; + } + } +} diff --git a/src/java/org/apache/cassandra/management/ManagementUtils.java b/src/java/org/apache/cassandra/management/ManagementUtils.java new file mode 100644 index 0000000000..e8ecd95d7c --- /dev/null +++ b/src/java/org/apache/cassandra/management/ManagementUtils.java @@ -0,0 +1,157 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management; + +import java.security.AccessController; +import java.security.PrivilegedAction; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.Set; +import java.util.function.Function; + +import com.google.common.base.Strings; +import com.google.common.base.Throwables; + +import org.apache.cassandra.management.api.Command; +import org.apache.cassandra.management.api.CommandRegistry; + +import picocli.CommandLine; + +public final class ManagementUtils +{ + public static final String COMMAND_NAME_DELIMITER = "."; + + public static Iterable loadService(Class serviceClz) + { + return AccessController.doPrivileged((PrivilegedAction>) () -> ServiceLoader.load(serviceClz)); + } + + public static int countCommands(CommandRegistry registry) + { + int count = 0; + for (Map.Entry> entry : registry.commands()) + { + Command cmd = entry.getValue(); + if (cmd instanceof CommandRegistry) + count += countCommands((CommandRegistry) cmd); + else + count++; + } + return count; + } + + public static String stripAngleBrackets(String name) + { + if (name == null || name.isEmpty()) + return name; + + String trimmed = name.trim(); + + if (trimmed.length() >= 2 && + trimmed.charAt(0) == '<' && + trimmed.charAt(trimmed.length() - 1) == '>') + { + return trimmed.substring(1, trimmed.length() - 1).trim(); + } + + return name; + } + + /** Normalize the option name by stripping leading dashes. */ + public static String normalizeOptionName(String name) + { + if (name.startsWith("--")) + return name.substring(2); + else if (name.startsWith("-")) + return name.substring(1); + return name; + } + + /** + * Builds a concise, client-safe error string from a throwable's cause chain: the distinct, non-empty + * messages of the throwable and its causes, joined by {@code ": "}. Unlike a full stack trace, this + * exposes only the human-readable messages (which command validation/usage errors rely on) without + * leaking internal class names, file paths or line numbers. The full stack trace is logged separately + * server-side. + */ + public static String causeMessages(Throwable t) + { + Set messages = new LinkedHashSet<>(); + for (Throwable cause : Throwables.getCausalChain(t)) + { + String message = cause.getMessage(); + if (Strings.isNullOrEmpty(message)) + continue; + // Skip a message already represented by an ancestor. + if (messages.stream().anyMatch(existing -> existing.contains(message))) + continue; + messages.add(message); + } + return messages.isEmpty() ? t.getClass().getSimpleName() : String.join(": ", messages); + } + + public static String fullCommandName(String parent, String name) + { + return Strings.isNullOrEmpty(parent) ? name : String.join(COMMAND_NAME_DELIMITER, parent, name); + } + + public static String fullCommandName(List parentCommands, Function nameExtractor) + { + StringBuilder sb = new StringBuilder(); + for (T part : parentCommands) + { + String extracted = nameExtractor.apply(part); + if (Strings.isNullOrEmpty(extracted)) + continue; + if (sb.length() > 0) + sb.append(COMMAND_NAME_DELIMITER); + sb.append(extracted); + } + return sb.length() > 0 ? sb.toString() : null; + } + + @SuppressWarnings("unchecked") + public static T findRegistryCommand(String fullCommandName, CommandRegistry registry) + { + String[] parts = fullCommandName.split('\\' + COMMAND_NAME_DELIMITER, 2); + Command cmd = registry.command(parts[0]); + if (cmd == null) + return null; + + if (parts.length == 1) + return (T) cmd; + + if (cmd instanceof CommandRegistry) + return findRegistryCommand(parts[1], (CommandRegistry) cmd); + + return null; + } + + public static Class elementType(CommandLine.Model.ArgSpec spec) + { + Class type = spec.type(); + if (!type.isArray() && !java.util.Collection.class.isAssignableFrom(type)) + return null; + + Class[] auxiliaryTypes = spec.auxiliaryTypes(); + return auxiliaryTypes == null || auxiliaryTypes.length == 0 ? null : auxiliaryTypes[0]; + } +} diff --git a/src/java/org/apache/cassandra/management/SimpleCommandExecutionArgs.java b/src/java/org/apache/cassandra/management/SimpleCommandExecutionArgs.java new file mode 100644 index 0000000000..9d7a7e2ed0 --- /dev/null +++ b/src/java/org/apache/cassandra/management/SimpleCommandExecutionArgs.java @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.apache.cassandra.management.api.CommandExecutionArgs; +import org.apache.cassandra.management.api.OptionMetadata; +import org.apache.cassandra.management.api.ParameterMetadata; + +public class SimpleCommandExecutionArgs implements CommandExecutionArgs +{ + private final Map options; + private final Map parameters; + + public SimpleCommandExecutionArgs(Map options, + Map parameters) + { + this.options = new LinkedHashMap<>(options); + this.parameters = new LinkedHashMap<>(parameters); + } + + @Override + public Map parameters() + { + return parameters; + } + + @Override + public Map options() + { + return options; + } + + @Override + public boolean hasParameter(ParameterMetadata param) + { + return parameters.containsKey(param); + } + + @Override + public boolean hasOption(OptionMetadata option) + { + return options.containsKey(option); + } + + @Override + public String toString() + { + return "SimpleCommandExecutionArgs{" + + "options=" + options + + ", parameters=" + parameters + + '}'; + } +} diff --git a/src/java/org/apache/cassandra/management/api/ArgumentMetadata.java b/src/java/org/apache/cassandra/management/api/ArgumentMetadata.java new file mode 100644 index 0000000000..da7c6a8bdf --- /dev/null +++ b/src/java/org/apache/cassandra/management/api/ArgumentMetadata.java @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management.api; + +public interface ArgumentMetadata +{ + String[] names(); + String paramLabel(); + + String description(); + Class type(); + String defaultValue(); + boolean required(); + /** "0" (flag), "1", "0..1", "0..*" */ + String arity(); +} diff --git a/src/java/org/apache/cassandra/management/api/Command.java b/src/java/org/apache/cassandra/management/api/Command.java new file mode 100644 index 0000000000..e25a0b0d35 --- /dev/null +++ b/src/java/org/apache/cassandra/management/api/Command.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management.api; + +/** + * A management command following the Command java-pattern. Each command is a self-describing, + * executable unit that exposes its {@link CommandMetadata} and can be invoked uniformly by + * the {@link org.apache.cassandra.management.CommandInvokerService} regardless of the + * transport: JMX or CQL. + *

+ * The type parameter {@code R} is the structured result type returned by + * {@link #execute(CommandExecutionArgs, CommandExecutionContext)}. Existing picocli-based + * commands return {@code Void} and write output via services obtained from the context; + * new commands may return a typed result for protocol-level serialization. + * + * @param the command result type, or {@link Void} for commands that only produce + * side-effect output through the execution context + * @see CommandRegistry + */ +public interface Command +{ + /** Get command metadata - replaces argClass() from the cep-38 doc with richer information. */ + CommandMetadata metadata(); + + /** + * Execute the command. + * + * @param arguments command arguments. + * @param context execution context providing access to services via {@link CommandExecutionContext#service(Class)}. + * @return structured data result, or {@code null} for commands that only produce + * side effect output (e.g. existing picocli-based commands). + */ + R execute(CommandExecutionArgs arguments, CommandExecutionContext context); + + default String name() { return metadata().name(); } + default String description() { return metadata().description(); } +} diff --git a/src/java/org/apache/cassandra/management/api/CommandExecutionArgs.java b/src/java/org/apache/cassandra/management/api/CommandExecutionArgs.java new file mode 100644 index 0000000000..af11036640 --- /dev/null +++ b/src/java/org/apache/cassandra/management/api/CommandExecutionArgs.java @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management.api; + +import java.util.Map; + +public interface CommandExecutionArgs +{ + /** @return Map of parameter metadata to their values. */ + Map parameters(); + /** @return Map of option metadata to their values. */ + Map options(); + + boolean hasParameter(ParameterMetadata param); + boolean hasOption(OptionMetadata option); +} diff --git a/src/java/org/apache/cassandra/management/api/CommandExecutionContext.java b/src/java/org/apache/cassandra/management/api/CommandExecutionContext.java new file mode 100644 index 0000000000..766f5b9473 --- /dev/null +++ b/src/java/org/apache/cassandra/management/api/CommandExecutionContext.java @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management.api; + +import java.util.Set; + +/** + * Provides execution-time services to a {@link Command}. The context acts as a typed service + * locator so that the API package ({@code management.api}) carries no compile-time dependency + * on tool-specific classes such as {@code NodeProbe} or {@code Output}. + *

+ * Adapter layers retrieve the concrete services they need: + *

{@code
+ *     NodeProbe probe = context.service(NodeProbe.class);
+ *     Output    out   = context.service(Output.class);
+ * }
+ * New commands that do not require legacy tool types can request MBean interfaces directly: + *
{@code
+ *     StorageServiceMBean ss = context.service(StorageServiceMBean.class);
+ * }
+ */ +public interface CommandExecutionContext +{ + /** + * Returns the service instance of the requested type, or throws + * {@link IllegalArgumentException} if the service is not available in this context. + * + * @param serviceType the class of the desired service + * @param service type + * @return a non-null service instance + * @throws IllegalArgumentException if the service is not available + */ + T service(Class serviceType); + + /** + * Retrieves the set of all service types that are available within the current execution context. + * @return a set of classes representing the types of services accessible via this context. + */ + Set> services(); +} diff --git a/src/java/org/apache/cassandra/management/api/CommandMetadata.java b/src/java/org/apache/cassandra/management/api/CommandMetadata.java new file mode 100644 index 0000000000..0e2304b1db --- /dev/null +++ b/src/java/org/apache/cassandra/management/api/CommandMetadata.java @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management.api; + +import java.util.List; + +/** + * CommandMetadata is a protocol-agnostic descriptor of a management command's structure + * and metadata information. + *

+ * Each {@link Command} exposes a {@code CommandMetadata} that describes the command's name, + * human-readable description, optional aliases, accepted options (named flags/values), + * positional parameters, and subcommands. + *

+ * This metadata is used by: + *

    + *
  • {@link org.apache.cassandra.management.CommandExecutionArgsSerde} to serialize/deserialize + * command arguments to and from JSON or CQL row maps.
  • + *
  • {@link org.apache.cassandra.management.CommandInvokerService} to look up and invoke + * commands over existing MBean interfaces (only intefaces and corresponding implementaions + * are used, JMX might be disabled).;
  • + *
  • CQL syntax {@code COMMAND} to validate and convert user-supplied arguments.
  • + *
+ *

+ * The interface intentionally has no {@code parent()} accessor. Parent-child relationships are + * owned by the {@link org.apache.cassandra.management.CassandraCommandRegistry} (and its + * underlying {@link CommandRegistry} contract) rather than by individual commands, so that + * a command can be registered in different registries. + */ +public interface CommandMetadata +{ + String name(); + String description(); + + List options(); + List parameters(); + List subcommands(); +} diff --git a/src/java/org/apache/cassandra/management/api/CommandRegistry.java b/src/java/org/apache/cassandra/management/api/CommandRegistry.java new file mode 100644 index 0000000000..ab151ca4e6 --- /dev/null +++ b/src/java/org/apache/cassandra/management/api/CommandRegistry.java @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management.api; + +import java.util.Map; + +/** + * Registry that knows all of its subcommands and acts as a composite command for hierarchy. + * Allows registering and retrieving commands by name. + *

+ * For example, nodetool commands structure is like: + *

+ * NodetoolCommand (parent)
+ *   ├── setconcurrentcompactors (leaf command, implemented via Command interface)
+ *   ├── compressiondictionary (parent command, implemented via CommandRegistry interface)
+ *   │   ├── train (leaf)
+ *   │   ├── list (leaf)
+ *   │   ├── export (leaf)
+ *   │   └── import (leaf)
+ *   ├── decommission (parent)
+ *   │   └── abort (leaf)
+ *   └── ... 100+ more commands
+ * 
+ */ +public interface CommandRegistry extends Command +{ + Command command(String name); + Iterable>> commands(); +} diff --git a/src/java/org/apache/cassandra/management/api/CommandsProvider.java b/src/java/org/apache/cassandra/management/api/CommandsProvider.java new file mode 100644 index 0000000000..e9e2c51794 --- /dev/null +++ b/src/java/org/apache/cassandra/management/api/CommandsProvider.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management.api; + +import java.util.Collection; + +/** + * Pluggable component that is responsible for providing a list of commands for management API. + */ +public interface CommandsProvider +{ + /** Gets all supported by these provider commands. */ + Collection> commands(); +} diff --git a/src/java/org/apache/cassandra/management/api/OptionMetadata.java b/src/java/org/apache/cassandra/management/api/OptionMetadata.java new file mode 100644 index 0000000000..0809add926 --- /dev/null +++ b/src/java/org/apache/cassandra/management/api/OptionMetadata.java @@ -0,0 +1,23 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management.api; + +public interface OptionMetadata extends ArgumentMetadata +{ +} diff --git a/src/java/org/apache/cassandra/management/api/ParameterMetadata.java b/src/java/org/apache/cassandra/management/api/ParameterMetadata.java new file mode 100644 index 0000000000..a1a2a45df8 --- /dev/null +++ b/src/java/org/apache/cassandra/management/api/ParameterMetadata.java @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management.api; + +public interface ParameterMetadata extends ArgumentMetadata +{ + String COMMAND_POSITIONAL_PARAM_PREFIX = "param"; + int index(); +} diff --git a/src/java/org/apache/cassandra/management/picocli/PicocliCommandAdapter.java b/src/java/org/apache/cassandra/management/picocli/PicocliCommandAdapter.java new file mode 100644 index 0000000000..2ef1080004 --- /dev/null +++ b/src/java/org/apache/cassandra/management/picocli/PicocliCommandAdapter.java @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management.picocli; + +import java.lang.reflect.Field; + +import javax.inject.Inject; + +import org.apache.cassandra.management.api.Command; +import org.apache.cassandra.management.api.CommandExecutionArgs; +import org.apache.cassandra.management.api.CommandExecutionContext; +import org.apache.cassandra.management.api.CommandMetadata; +import org.apache.cassandra.tools.NodeProbe; +import org.apache.cassandra.tools.Output; +import org.apache.cassandra.tools.nodetool.AbstractCommand; + +import picocli.CommandLine; + +public class PicocliCommandAdapter implements Command +{ + private final Class commandClass; + private final CommandMetadata commandMetadata; + + public PicocliCommandAdapter(Class commandClass) + { + this.commandClass = commandClass; + this.commandMetadata = PicocliCommandMetadata.from(commandClass); + if (!(commandMetadata instanceof PicocliCommandMetadata)) + throw new IllegalStateException("CommandMetadata must be PicocliCommandMetadata for picocli commands"); + } + + @Override + public CommandMetadata metadata() + { + return commandMetadata; + } + + @Override + public Void execute(CommandExecutionArgs arguments, CommandExecutionContext context) + { + Output output = context.service(Output.class); + CommandLine commandLine = new CommandLine(commandClass, new InjectCassandraContext(output)); + Object userCommand = commandLine.getCommand(); + + if (userCommand instanceof AbstractCommand) + ((AbstractCommand) userCommand).probe(context.service(NodeProbe.class)); + + PicocliCommandArgsConverter.toCommand(arguments, userCommand); + + // Alternatively, we can convert CommandExecutionArgs to String[] and let picocli handle + // the parsing and execution; however, Cassandra validates a lot of arguments inside the + // command's execution body (historically this was done because of Airline limitations). + // + // Double parsing and validation of arguments is not ideal, and it doesn't make sense + // to rely on picocli either. Verdict: it doesn't make sense to rely on picocli argument + // validation and error handling here. + if (userCommand instanceof Runnable) + { + ((Runnable) userCommand).run(); + return null; + } + else + { + throw new RuntimeException(String.format("Unsupported command type: %s. " + + "Command class must implement Runnable to be executed.", + userCommand.getClass().getName())); + } + } + + private static class InjectCassandraContext implements CommandLine.IFactory + { + private final Output output; + private final CommandLine.IFactory fallback; + + public InjectCassandraContext(Output output) + { + this.fallback = CommandLine.defaultFactory(); + this.output = output; + } + + @Override + public K create(Class cls) + { + try + { + K bean = this.fallback.create(cls); + Class beanClass = bean.getClass(); + do + { + Field[] fields = beanClass.getDeclaredFields(); + for (Field field : fields) + { + if (!field.isAnnotationPresent(Inject.class)) + continue; + + field.setAccessible(true); + if (field.getType().equals(Output.class)) + { + field.set(bean, output); + } + else + { + throw new RuntimeException("Unsupported injectable field type: " + field.getType() + + " in class " + beanClass.getName() + ". " + + "Only Output is supported for injection."); + } + } + } + while ((beanClass = beanClass.getSuperclass()) != null); + return bean; + } + catch (Exception e) + { + throw new CommandLine.InitializationException("Failed to create instance of " + cls, e); + } + } + } +} diff --git a/src/java/org/apache/cassandra/management/picocli/PicocliCommandArgsConverter.java b/src/java/org/apache/cassandra/management/picocli/PicocliCommandArgsConverter.java new file mode 100644 index 0000000000..ee2206ebec --- /dev/null +++ b/src/java/org/apache/cassandra/management/picocli/PicocliCommandArgsConverter.java @@ -0,0 +1,199 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management.picocli; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.apache.cassandra.management.SimpleCommandExecutionArgs; +import org.apache.cassandra.management.api.CommandExecutionArgs; +import org.apache.cassandra.management.api.CommandMetadata; +import org.apache.cassandra.management.api.OptionMetadata; +import org.apache.cassandra.management.api.ParameterMetadata; + +import picocli.CommandLine; +import picocli.CommandLine.Model.CommandSpec; + +public class PicocliCommandArgsConverter +{ + /** + * Extract command arguments from an AbstractCommand instance using picocli reflection. + * + * @param command the command instance to extract arguments from + * @return CommandExecutionArgs containing all options and parameters from the command + */ + public static CommandExecutionArgs fromCommand(T command) + { + CommandMetadata metadata = PicocliCommandMetadata.from(command); + PicocliCommandMetadata picocliMetadata = (PicocliCommandMetadata) metadata; + CommandSpec spec = picocliMetadata.getCommandSpec(); + + Map options = new LinkedHashMap<>(); + Map parameters = new LinkedHashMap<>(); + + for (CommandLine.Model.OptionSpec optionSpec : spec.options()) + { + if (!optionSpec.isOption()) + continue; + + Object value = optionSpec.getValue(); + if (value == null) + continue; + + if (optionSpec.type() == boolean.class || optionSpec.type() == Boolean.class) + { + if (Boolean.TRUE.equals(value)) + options.put(new PicocliOptionMetadata(optionSpec), Boolean.TRUE); + } + else + { + options.put(new PicocliOptionMetadata(optionSpec), value); + } + } + + for (CommandLine.Model.PositionalParamSpec paramSpec : spec.positionalParameters()) + { + if (!paramSpec.isPositional()) + continue; + + Object value = paramSpec.getValue(); + if (value != null) + parameters.put(new PicocliParameterMetadata(paramSpec), value); + } + + return new SimpleCommandExecutionArgs(options, parameters); + } + + /** + * Populate an AbstractCommand instance with values from CommandExecutionArgs using picocli setters. + * + * @param args the CommandExecutionArgs containing values to set + * @param command the command instance to populate + */ + public static void toCommand(CommandExecutionArgs args, T command) + { + CommandMetadata metadata = PicocliCommandMetadata.from(command); + PicocliCommandMetadata picocliMetadata = (PicocliCommandMetadata) metadata; + CommandSpec spec = picocliMetadata.getCommandSpec(); + Map optionMap = buildOptionSpecMap(spec); + Map paramMap = buildParameterSpecMap(spec); + + for (Map.Entry entry : args.options().entrySet()) + { + OptionMetadata optionMetadata = entry.getKey(); + // This value is already converted to the correct type in CommandExecutionArgs. + Object convertedValue = entry.getValue(); + + if (convertedValue == null) + continue; + + CommandLine.Model.OptionSpec optionSpec = optionMap.get(optionMetadata); + if (optionSpec == null) + { + optionSpec = findOptionSpecByName(spec, optionMetadata.names()); + if (optionSpec == null) + throw new IllegalArgumentException("Option not found in command spec: " + Arrays.toString(optionMetadata.names())); + } + + try + { + optionSpec.setter().set(convertedValue); + } + catch (Exception e) + { + throw new RuntimeException("Failed to set option " + optionMetadata.names()[0] + + " with value " + convertedValue, e); + } + } + + for (Map.Entry entry : args.parameters().entrySet()) + { + ParameterMetadata paramMetadata = entry.getKey(); + Object value = entry.getValue(); + + if (value == null) + continue; + + CommandLine.Model.PositionalParamSpec paramSpec = paramMap.get(paramMetadata); + if (paramSpec == null) + { + paramSpec = findParameterSpecByIndex(spec, paramMetadata.index()); + if (paramSpec == null) + throw new IllegalArgumentException("Parameter not found in command spec at index: " + paramMetadata.index()); + } + + try + { + paramSpec.setter().set(value); + } + catch (Exception e) + { + throw new RuntimeException("Failed to set parameter at index " + paramMetadata.index() + + " with value " + value, e); + } + } + } + + private static Map buildOptionSpecMap(CommandSpec spec) + { + Map map = new LinkedHashMap<>(); + for (CommandLine.Model.OptionSpec optionSpec : spec.options()) + { + if (optionSpec.isOption()) + map.put(new PicocliOptionMetadata(optionSpec), optionSpec); + } + return map; + } + + private static Map buildParameterSpecMap(CommandSpec spec) + { + Map map = new LinkedHashMap<>(); + for (CommandLine.Model.PositionalParamSpec paramSpec : spec.positionalParameters()) + { + if (paramSpec.isPositional()) + map.put(new PicocliParameterMetadata(paramSpec), paramSpec); + } + return map; + } + + private static CommandLine.Model.OptionSpec findOptionSpecByName(CommandSpec spec, String[] names) + { + for (CommandLine.Model.OptionSpec optionSpec : spec.options()) + { + for (String name : names) + { + if (java.util.Arrays.asList(optionSpec.names()).contains(name)) + return optionSpec; + } + } + return null; + } + + private static CommandLine.Model.PositionalParamSpec findParameterSpecByIndex(CommandSpec spec, int index) + { + for (CommandLine.Model.PositionalParamSpec paramSpec : spec.positionalParameters()) + { + CommandLine.Range indexRange = paramSpec.index(); + if (index >= indexRange.min() && index <= indexRange.max()) + return paramSpec; + } + return null; + } +} diff --git a/src/java/org/apache/cassandra/management/picocli/PicocliCommandMetadata.java b/src/java/org/apache/cassandra/management/picocli/PicocliCommandMetadata.java new file mode 100644 index 0000000000..a6d2c22898 --- /dev/null +++ b/src/java/org/apache/cassandra/management/picocli/PicocliCommandMetadata.java @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management.picocli; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.apache.cassandra.management.api.CommandMetadata; +import org.apache.cassandra.management.api.OptionMetadata; +import org.apache.cassandra.management.api.ParameterMetadata; + +import picocli.CommandLine; +import picocli.CommandLine.Model.CommandSpec; + +/** + * Implementation of CommandMetadata that extracts metadata from picocli CommandSpec. + */ +public class PicocliCommandMetadata implements CommandMetadata +{ + private final CommandSpec commandSpec; + + public PicocliCommandMetadata(CommandSpec commandSpec) + { + this.commandSpec = commandSpec; + } + + /** + * Create CommandMetadata from a command class. + */ + public static CommandMetadata from(Class commandClass) + { + CommandSpec spec = CommandSpec.forAnnotatedObject(commandClass); + return new PicocliCommandMetadata(spec); + } + + /** + * Create CommandMetadata from a command instance. + */ + public static CommandMetadata from(Object commandInstance) + { + CommandSpec spec = CommandSpec.forAnnotatedObject(commandInstance); + return new PicocliCommandMetadata(spec); + } + + @Override + public String name() + { + return commandSpec.name(); + } + + @Override + public String description() + { + String[] description = commandSpec.usageMessage().description(); + if (description == null || description.length == 0) + return ""; + return String.join("\n", description); + } + + @Override + public List options() + { + List options = new ArrayList<>(); + for (CommandLine.Model.OptionSpec option : commandSpec.options()) + { + if (option.isOption()) + options.add(new PicocliOptionMetadata(option)); + } + return options; + } + + @Override + public List parameters() + { + List parameters = new ArrayList<>(); + for (CommandLine.Model.PositionalParamSpec positional : commandSpec.positionalParameters()) + { + if (positional.isPositional()) + parameters.add(new PicocliParameterMetadata(positional)); + } + return parameters; + } + + @Override + public List subcommands() + { + return commandSpec.subcommands().values().stream() + .map(subcommand -> new PicocliCommandMetadata(subcommand.getCommandSpec())) + .collect(Collectors.toList()); + } + + /** + * Get the underlying CommandSpec for advanced usage. + */ + public CommandSpec getCommandSpec() + { + return commandSpec; + } + + @Override + public boolean equals(Object o) + { + if (!(o instanceof PicocliCommandMetadata)) return false; + PicocliCommandMetadata that = (PicocliCommandMetadata) o; + return Objects.equals(commandSpec, that.commandSpec); + } + + @Override + public int hashCode() + { + return Objects.hashCode(commandSpec); + } +} + diff --git a/src/java/org/apache/cassandra/management/picocli/PicocliCommandRegistryAdapter.java b/src/java/org/apache/cassandra/management/picocli/PicocliCommandRegistryAdapter.java new file mode 100644 index 0000000000..f16bc9870c --- /dev/null +++ b/src/java/org/apache/cassandra/management/picocli/PicocliCommandRegistryAdapter.java @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management.picocli; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.cassandra.management.api.Command; +import org.apache.cassandra.management.api.CommandExecutionArgs; +import org.apache.cassandra.management.api.CommandExecutionContext; +import org.apache.cassandra.management.api.CommandMetadata; +import org.apache.cassandra.management.api.CommandRegistry; +import org.apache.cassandra.tools.nodetool.AbstractCommand; + +import picocli.CommandLine; +import picocli.CommandLine.Model.CommandSpec; + +/** + * Adapter that wraps a picocli command with subcommands and implements CommandRegistry + * using the Composite pattern. This allows hierarchical picocli commands to be used + * as CommandRegistry instances. + *

+ * For example, {@code CompressionDictionaryCommandGroup} has subcommands (train, list, export, import), + * so it would be wrapped by this adapter to provide a CommandRegistry interface. + *

+ * The adapter recursively adapts subcommands: + *

    + *
  • If a subcommand has subcommands -> creates another {@code PicocliCommandRegistryAdapter}
  • + *
  • If a subcommand is a leaf -> creates a {@code PicocliCommandAdapter}
  • + *
+ */ +public class PicocliCommandRegistryAdapter implements CommandRegistry +{ + private final CommandSpec commandSpec; + private final CommandMetadata commandMetadata; + private final Map> subcommandMap = new ConcurrentHashMap<>(); + + /** + * Create an adapter for a picocli command class that has subcommands. + * @param commandClass the picocli command class (must have subcommands) + */ + public PicocliCommandRegistryAdapter(Class commandClass) + { + this.commandSpec = CommandSpec.forAnnotatedObject(commandClass); + this.commandMetadata = new PicocliCommandMetadata(commandSpec); + + if (commandSpec.subcommands().isEmpty()) + { + throw new IllegalArgumentException( + String.format("Command class %s does not have subcommands. " + + "Use PicocliCommandAdapter for leaf commands.", + commandClass.getName())); + } + + for (Map.Entry e : commandSpec.subcommands().entrySet()) + adaptSubcommands(e.getKey(), e.getValue()); + } + + /** + * Recursively adapt all subcommands from the picocli command. Uses a Composite pattern: + * subcommands with subcommands become registries, leaf subcommands become command adapters. + */ + private void adaptSubcommands(String commandName, CommandLine command) + { + CommandSpec subcommandSpec = command.getCommandSpec(); + Command adaptedCommand; + + if (!subcommandSpec.subcommands().isEmpty()) + { + adaptedCommand = new PicocliCommandRegistryAdapter(command.getCommand()); + } + else + { + Class subcommandClass = command.getCommand().getClass(); + if (!AbstractCommand.class.isAssignableFrom(subcommandClass)) + { + throw new IllegalArgumentException( + String.format("Subcommand class %s is not an AbstractCommand and cannot be adapted. " + + "Only AbstractCommand subclasses are supported for leaf commands.", + subcommandClass.getName())); + } + + adaptedCommand = new PicocliCommandAdapter((Class) subcommandClass); + } + + subcommandMap.put(commandName, adaptedCommand); + + for (String alias : subcommandSpec.aliases()) + subcommandMap.putIfAbsent(alias, adaptedCommand); + } + + @Override + public CommandMetadata metadata() + { + return commandMetadata; + } + + @Override + public String execute(CommandExecutionArgs arguments, CommandExecutionContext context) + { + // CommandRegistry is not directly executable: routing happens at the invoker/CQL layer. + throw new UnsupportedOperationException( + String.format("CommandRegistry '%s' is not directly executable. " + + "Specify a subcommand. Available commands: %s", + name(), + String.join(", ", subcommandMap.keySet()))); + } + + @Override + public Command command(String name) + { + return subcommandMap.get(name); + } + + @Override + public Iterable>> commands() + { + return subcommandMap.entrySet(); + } +} + diff --git a/src/java/org/apache/cassandra/management/picocli/PicocliCommandsProvider.java b/src/java/org/apache/cassandra/management/picocli/PicocliCommandsProvider.java new file mode 100644 index 0000000000..129a684c2a --- /dev/null +++ b/src/java/org/apache/cassandra/management/picocli/PicocliCommandsProvider.java @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management.picocli; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.apache.cassandra.management.api.Command; +import org.apache.cassandra.management.api.CommandsProvider; +import org.apache.cassandra.tools.nodetool.AbstractCommand; +import org.apache.cassandra.tools.nodetool.NodetoolCommand; + +import picocli.CommandLine; + +public class PicocliCommandsProvider implements CommandsProvider +{ + @Override + public Collection> commands() + { + CommandLine commandLine = new CommandLine(NodetoolCommand.class); + List> commands = new ArrayList<>(); + + commandLine.getSubcommands().forEach((name, subcommandLine) -> { + if (!subcommandLine.getCommandSpec().subcommands().isEmpty()) + { + @SuppressWarnings("unchecked") + Class abstractCommandClass = + (Class) subcommandLine.getCommand().getClass(); + commands.add(new PicocliCommandRegistryAdapter(abstractCommandClass)); + } + else + { + Class commandClass = subcommandLine.getCommand().getClass(); + if (AbstractCommand.class.isAssignableFrom(commandClass)) + { + @SuppressWarnings("unchecked") + Class abstractCommandClass = + (Class) commandClass; + commands.add(new PicocliCommandAdapter(abstractCommandClass)); + } + } + }); + + return commands; + } +} diff --git a/src/java/org/apache/cassandra/management/picocli/PicocliMetadataExtractor.java b/src/java/org/apache/cassandra/management/picocli/PicocliMetadataExtractor.java new file mode 100644 index 0000000000..0a5c71bf51 --- /dev/null +++ b/src/java/org/apache/cassandra/management/picocli/PicocliMetadataExtractor.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management.picocli; + +import org.apache.cassandra.management.api.CommandMetadata; +import org.apache.cassandra.tools.nodetool.AbstractCommand; + +/** + * Utility class for extracting command metadata from picocli-annotated command classes. + */ +public class PicocliMetadataExtractor +{ + /** + * Extract CommandMetadata from an AbstractCommand class. + * + * @param commandClass The command class annotated with picocli @Command + * @return CommandMetadata extracted from the command class + */ + public static CommandMetadata extract(Class commandClass) + { + return PicocliCommandMetadata.from(commandClass); + } + + /** + * Extract CommandMetadata from a command instance. + * + * @param commandInstance The command instance + * @return CommandMetadata extracted from the command instance + */ + public static CommandMetadata extract(Object commandInstance) + { + if (commandInstance instanceof AbstractCommand) + return PicocliCommandMetadata.from(commandInstance); + throw new IllegalArgumentException("Unsupported command instance type: " + commandInstance.getClass().getName()); + } +} + diff --git a/src/java/org/apache/cassandra/management/picocli/PicocliOptionMetadata.java b/src/java/org/apache/cassandra/management/picocli/PicocliOptionMetadata.java new file mode 100644 index 0000000000..c97131934c --- /dev/null +++ b/src/java/org/apache/cassandra/management/picocli/PicocliOptionMetadata.java @@ -0,0 +1,167 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management.picocli; + +import java.util.Arrays; +import java.util.Objects; + +import com.google.common.base.Strings; + +import org.apache.cassandra.management.api.OptionMetadata; + +import picocli.CommandLine; +import picocli.CommandLine.Model.OptionSpec; + +import static org.apache.cassandra.management.ManagementUtils.elementType; +import static org.apache.cassandra.management.ManagementUtils.stripAngleBrackets; + +/** + * Implementation of OptionMetadata that extracts metadata from picocli OptionSpec. + */ +public class PicocliOptionMetadata implements OptionMetadata +{ + private final OptionSpec optionSpec; + + public PicocliOptionMetadata(OptionSpec optionSpec) + { + this.optionSpec = optionSpec; + } + + @Override + public String[] names() + { + return optionSpec.names(); + } + + @Override + public String description() + { + String[] description = optionSpec.description(); + if (description == null || description.length == 0) + return ""; + return String.join("\n", description); + } + + @Override + public Class type() + { + return optionSpec.type(); + } + + @Override + public String defaultValue() + { + Object defaultValue = optionSpec.defaultValue(); + if (defaultValue == null) + { + if (optionSpec.type() == boolean.class || optionSpec.type() == Boolean.class) + return "false"; + return ""; + } + + return defaultValue.toString(); + } + + @Override + public boolean required() + { + return optionSpec.required(); + } + + @Override + public String arity() + { + CommandLine.Range arity = optionSpec.arity(); + int min = arity.min(); + int max = arity.max(); + + if (min == 0 && max == 0) + return "0"; + + if (min == max) + return String.valueOf(min); + else if (max == Integer.MAX_VALUE) + return min + "..*"; + else + return min + ".." + max; + } + + @Override + public String paramLabel() + { + String paramLabel = optionSpec.paramLabel(); + Object userObject = optionSpec.userObject(); + + return Strings.isNullOrEmpty(paramLabel) ? + ((java.lang.reflect.Field) userObject).getName() : + stripAngleBrackets(paramLabel); + } + + /** + * Check if this is a boolean flag option. + */ + public boolean isFlag() + { + return optionSpec.arity().max() == 0; + } + + /** + * Get the underlying OptionSpec for advanced usage. + */ + public OptionSpec getOptionSpec() + { + return optionSpec; + } + + public Object convertValue(Object value) throws Exception + { + TypeConverter customConverter = typeConverter() == null ? null : typeConverter()[0]; + return customConverter == null ? + TypeConverterRegistry.convertValueBasic(value, type(), elementType(optionSpec)) : + customConverter.convert(value.toString()); + } + + private TypeConverter[] typeConverter() + { + return TypeConverter.createFrom(optionSpec); + } + + @Override + public boolean equals(Object o) + { + if (!(o instanceof PicocliOptionMetadata)) return false; + PicocliOptionMetadata that = (PicocliOptionMetadata) o; + return Objects.equals(optionSpec, that.optionSpec); + } + + @Override + public int hashCode() + { + return Objects.hashCode(optionSpec); + } + + @Override + public String toString() + { + return "PicocliOptionMetadata{" + + "names=" + Arrays.toString(names()) + + '}'; + } +} + diff --git a/src/java/org/apache/cassandra/management/picocli/PicocliParameterMetadata.java b/src/java/org/apache/cassandra/management/picocli/PicocliParameterMetadata.java new file mode 100644 index 0000000000..a2d957a9a9 --- /dev/null +++ b/src/java/org/apache/cassandra/management/picocli/PicocliParameterMetadata.java @@ -0,0 +1,158 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management.picocli; + +import java.util.Arrays; +import java.util.Objects; + +import org.apache.cassandra.management.api.ParameterMetadata; + +import picocli.CommandLine; +import picocli.CommandLine.Model.PositionalParamSpec; + +import static org.apache.cassandra.management.ManagementUtils.elementType; +import static org.apache.cassandra.management.ManagementUtils.stripAngleBrackets; + +/** + * Implementation of ParameterMetadata that extracts metadata from picocli PositionalParamSpec. + */ +public class PicocliParameterMetadata implements ParameterMetadata +{ + private final PositionalParamSpec positionalParamSpec; + + public PicocliParameterMetadata(PositionalParamSpec positionalParamSpec) + { + this.positionalParamSpec = positionalParamSpec; + } + + @Override + public String[] names() + { + String name; + String paramLabel = positionalParamSpec.paramLabel(); + Object userObject = positionalParamSpec.userObject(); + + if (paramLabel != null && !paramLabel.isEmpty()) + name = stripAngleBrackets(paramLabel); + else if (userObject instanceof java.lang.reflect.Field) + name = ((java.lang.reflect.Field) userObject).getName(); + else + name = COMMAND_POSITIONAL_PARAM_PREFIX + index(); + + return new String[] { name }; + } + + @Override + public String paramLabel() + { + return stripAngleBrackets(positionalParamSpec.paramLabel()); + } + + @Override + public String description() + { + String[] description = positionalParamSpec.description(); + if (description == null || description.length == 0) + return ""; + return String.join("\n", description); + } + + @Override + public Class type() + { + return positionalParamSpec.type(); + } + + @Override + public String defaultValue() + { + return ""; + } + + @Override + public int index() + { + return positionalParamSpec.index().min(); + } + + @Override + public boolean required() + { + return positionalParamSpec.arity().min() > 0; + } + + @Override + public String arity() + { + CommandLine.Range arity = positionalParamSpec.arity(); + int min = arity.min(); + int max = arity.max(); + + if (min == max) + return String.valueOf(min); + else if (max == Integer.MAX_VALUE) + return min + "..*"; + else + return min + ".." + max; + } + + /** + * Get the underlying PositionalParamSpec for advanced usage. + */ + public PositionalParamSpec getPositionalParamSpec() + { + return positionalParamSpec; + } + + public Object convertValue(Object value) throws Exception + { + TypeConverter customConverter = typeConverter() == null ? null : typeConverter()[0]; + return customConverter == null ? + TypeConverterRegistry.convertValueBasic(value, type(), elementType(positionalParamSpec)) : + customConverter.convert(value.toString()); + } + + private TypeConverter[] typeConverter() + { + return TypeConverter.createFrom(positionalParamSpec); + } + + @Override + public boolean equals(Object o) + { + if (!(o instanceof PicocliParameterMetadata)) return false; + PicocliParameterMetadata that = (PicocliParameterMetadata) o; + return Objects.equals(positionalParamSpec, that.positionalParamSpec); + } + + @Override + public int hashCode() + { + return Objects.hashCode(positionalParamSpec); + } + + @Override + public String toString() + { + return "PicocliParameterMetadata{" + + "names=" + Arrays.toString(names()) + + '}'; + } +} + diff --git a/src/java/org/apache/cassandra/management/picocli/TypeConverter.java b/src/java/org/apache/cassandra/management/picocli/TypeConverter.java new file mode 100644 index 0000000000..5132591b0e --- /dev/null +++ b/src/java/org/apache/cassandra/management/picocli/TypeConverter.java @@ -0,0 +1,38 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management.picocli; + +import java.util.Arrays; + +import picocli.CommandLine; + +public interface TypeConverter +{ + R convert(String value) throws Exception; + + static TypeConverter[] createFrom(CommandLine.Model.ArgSpec argSpec) + { + CommandLine.ITypeConverter[] converters = argSpec.converters(); + return converters == null || converters.length == 0 ? + null : + Arrays.stream(converters) + .map(c -> (TypeConverter) c::convert) + .toArray(TypeConverter[]::new); + } +} diff --git a/src/java/org/apache/cassandra/management/picocli/TypeConverterRegistry.java b/src/java/org/apache/cassandra/management/picocli/TypeConverterRegistry.java new file mode 100644 index 0000000000..89cd8f7bb0 --- /dev/null +++ b/src/java/org/apache/cassandra/management/picocli/TypeConverterRegistry.java @@ -0,0 +1,177 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management.picocli; + +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; + +public class TypeConverterRegistry +{ + private static final Map, TypeConverter> converters = new LinkedHashMap<>(); + + static + { + register(boolean.class, (TypeConverter) Boolean::parseBoolean); + register(int.class, (TypeConverter) Integer::parseInt); + register(long.class, (TypeConverter) Long::parseLong); + register(double.class, (TypeConverter) Double::parseDouble); + register(float.class, (TypeConverter) Float::parseFloat); + register(byte.class, (TypeConverter) Byte::parseByte); + register(short.class, (TypeConverter) Short::parseShort); + register(char.class, value -> { + if (value.length() != 1) + throw new IllegalArgumentException("Cannot convert to char: " + value); + return value.charAt(0); + }); + register(Boolean.class, (TypeConverter) Boolean::parseBoolean); + register(Integer.class, (TypeConverter) Integer::parseInt); + register(Long.class, (TypeConverter) Long::parseLong); + register(Double.class, (TypeConverter) Double::parseDouble); + register(Float.class, (TypeConverter) Float::parseFloat); + register(Byte.class, (TypeConverter) Byte::parseByte); + register(Short.class, (TypeConverter) Short::parseShort); + register(String.class, (TypeConverter) value -> value); + register(Character.class, value -> { + if (value.length() != 1) + throw new IllegalArgumentException("Cannot convert to Character: " + value); + return value.charAt(0); + }); + register(Object.class, value -> value); + } + + private static void register(Class type, TypeConverter converter) + { + converters.put(type, converter); + } + + public static Object convertValueBasic(Object value, Class targetType) throws Exception + { + return convertValueBasic(value, targetType, null); + } + + public static Object convertValueBasic(Object value, Class targetType, Class elementType) throws Exception + { + if (value == null) + return null; + + // Arrays and collections must be handled before the assignable short-circuit below: a + // List is already assignable to List, but its elements still need per-element + // conversion when the declared element type is not String. + if (targetType.isArray() || Collection.class.isAssignableFrom(targetType)) + return convertToArrayOrCollection(value, targetType, elementType); + + if (targetType.isAssignableFrom(value.getClass())) + return value; + + String stringValue = value.toString(); + if (isBasicType(targetType)) + { + TypeConverter converter = converters.get(targetType); + if (converter == null) + throw new IllegalStateException(String.format("No converter registered for basic type: %s", + targetType.getName())); + return converter.convert(stringValue); + } + + if (targetType.isEnum()) + return getEnumTypeConverter(targetType).convert(stringValue); + + throw new IllegalArgumentException(String.format("No converter available for type: %s.", targetType.getName())); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static TypeConverter getEnumTypeConverter(Class type) + { + return value -> { + for (T constant : type.getEnumConstants()) + if (((Enum) constant).name().equalsIgnoreCase(value)) + return constant; + throw new IllegalArgumentException("No enum constant " + type.getCanonicalName() + '.' + value); + }; + } + + private static Object convertToArrayOrCollection(Object value, Class targetType, Class elementType) throws Exception + { + Collection sourceCollection; + if (value instanceof Collection) + sourceCollection = (Collection) value; + else if (value.getClass().isArray()) + { + int length = Array.getLength(value); + List list = new ArrayList<>(length); + for (int i = 0; i < length; i++) + list.add(Array.get(value, i)); + sourceCollection = list; + } + else + throw new IllegalArgumentException("Cannot convert " + value.getClass() + " to " + targetType); + + if (targetType.isArray()) + { + Class componentType = targetType.getComponentType(); + Object targetArray = Array.newInstance(componentType, sourceCollection.size()); + int index = 0; + for (Object item : sourceCollection) + { + Object convertedItem = convertValueBasic(item, componentType); + Array.set(targetArray, index++, convertedItem); + } + return targetArray; + } + + Collection targetCollection; + if (targetType == List.class || List.class.isAssignableFrom(targetType)) + targetCollection = new ArrayList<>(); + else if (SortedSet.class.isAssignableFrom(targetType)) + targetCollection = new TreeSet<>(); + else if (targetType == Set.class || Set.class.isAssignableFrom(targetType)) + targetCollection = new LinkedHashSet<>(); + else + targetCollection = new ArrayList<>(); + + if (elementType == null) + targetCollection.addAll(sourceCollection); + else + for (Object item : sourceCollection) + targetCollection.add(convertValueBasic(item, elementType)); + + return targetCollection; + } + + private static boolean isBasicType(Class type) + { + return type == String.class || + type == boolean.class || type == Boolean.class || + type == int.class || type == Integer.class || + type == long.class || type == Long.class || + type == double.class || type == Double.class || + type == float.class || type == Float.class || + type == byte.class || type == Byte.class || + type == short.class || type == Short.class || + type == char.class || type == Character.class; + } +} diff --git a/src/java/org/apache/cassandra/service/CassandraDaemon.java b/src/java/org/apache/cassandra/service/CassandraDaemon.java index 7cf1494bb1..a629fbe0f9 100644 --- a/src/java/org/apache/cassandra/service/CassandraDaemon.java +++ b/src/java/org/apache/cassandra/service/CassandraDaemon.java @@ -80,6 +80,7 @@ import org.apache.cassandra.io.util.File; import org.apache.cassandra.io.util.FileUtils; import org.apache.cassandra.locator.InetAddressAndPort; import org.apache.cassandra.locator.Locator; +import org.apache.cassandra.management.CommandInvokerService; import org.apache.cassandra.metrics.CassandraMetricsRegistry; import org.apache.cassandra.metrics.DefaultNameFactory; import org.apache.cassandra.net.StartupClusterConnectivityChecker; @@ -99,6 +100,7 @@ import org.apache.cassandra.tcm.RegistrationStatus; import org.apache.cassandra.tcm.Startup; import org.apache.cassandra.tcm.membership.NodeId; import org.apache.cassandra.tcm.membership.NodeState; +import org.apache.cassandra.transport.Dispatcher; import org.apache.cassandra.utils.FBUtilities; import org.apache.cassandra.utils.JMXServerUtils; import org.apache.cassandra.utils.JVMStabilityInspector; @@ -218,6 +220,7 @@ public class CassandraDaemon static final CassandraDaemon instance = new CassandraDaemon(); + private volatile NativeTransportManagementService nativeTransportManagementService; private volatile NativeTransportService nativeTransportService; private JMXConnectorServer jmxServer; @@ -397,6 +400,9 @@ public class CassandraDaemon // re-enable auto-compaction after replay, so correct disk boundaries are used enableAutoCompaction(Schema.instance.getKeyspaces()); + // Initialize command service (after JMX, before StorageService.initServer) + CommandInvokerService.instance.start(); + // start server internals StorageService.instance.registerDaemon(this); try @@ -668,6 +674,9 @@ public class CassandraDaemon public synchronized void initializeClientTransports() { + if (nativeTransportManagementService == null) + nativeTransportManagementService = new NativeTransportManagementService(); + // Native transport if (nativeTransportService == null) nativeTransportService = new NativeTransportService(); @@ -755,6 +764,9 @@ public class CassandraDaemon Set peers = new HashSet<>(ClusterMetadata.current().directory.allJoinedEndpoints()); connectivityChecker.execute(peers, ep -> locator.location(ep).datacenter); + // start management transports first. + startManagementTransport(); + // check to see if transports may start else return without starting. This is needed when in survey mode or // when bootstrap has not completed. try @@ -783,6 +795,16 @@ public class CassandraDaemon logger.info("Not starting native transport as requested. Use JMX (StorageService->startNativeTransport()) or nodetool (enablebinary) to start it"); } + private void startManagementTransport() + { + if (NativeTransportManagementService.enabled()) + { + if (nativeTransportManagementService == null) + throw new IllegalStateException("setup() must be called first for CassandraDaemon"); + nativeTransportManagementService.start(); + } + } + /** * Stop the daemon, ideally in an idempotent manner. * @@ -794,6 +816,7 @@ public class CassandraDaemon // jsvc takes care of taking the rest down logger.info("Cassandra shutting down..."); destroyClientTransports(); + CommandInvokerService.instance.stop(); StorageService.instance.setRpcReady(false); if (jmxServer != null) @@ -815,6 +838,9 @@ public class CassandraDaemon stopNativeTransport(); if (nativeTransportService != null) nativeTransportService.destroy(); + if (nativeTransportManagementService != null) + nativeTransportManagementService.destroy(); + Dispatcher.shutdown(); } /** @@ -995,12 +1021,18 @@ public class CassandraDaemon public void clearConnectionHistory() { - nativeTransportService.clearConnectionHistory(); + if (nativeTransportService != null) + nativeTransportService.clearConnectionHistory(); + if (nativeTransportManagementService != null) + nativeTransportManagementService.clearConnectionHistory(); } public void disconnectUser(Predicate userPredicate) { - nativeTransportService.disconnect(userPredicate); + if (nativeTransportService != null) + nativeTransportService.disconnect(userPredicate); + if (nativeTransportManagementService != null) + nativeTransportManagementService.disconnect(userPredicate); } private void exitOrFail(int code, String message) diff --git a/src/java/org/apache/cassandra/service/ClientState.java b/src/java/org/apache/cassandra/service/ClientState.java index a17688d116..9f3638af43 100644 --- a/src/java/org/apache/cassandra/service/ClientState.java +++ b/src/java/org/apache/cassandra/service/ClientState.java @@ -147,6 +147,9 @@ public class ClientState // Driver String for the client private volatile String driverName; private volatile String driverVersion; + + // Whether this client is connected via the management native transport port + private volatile boolean isManagement; // Options provided by the client private volatile Map clientOptions; @@ -212,6 +215,7 @@ public class ClientState this.driverName = source.driverName; this.driverVersion = source.driverVersion; this.clientOptions = source.clientOptions; + this.isManagement = source.isManagement; } /** @@ -356,7 +360,17 @@ public class ClientState { this.driverVersion = driverVersion; } - + + public boolean isManagement() + { + return isManagement; + } + + public void setManagement(boolean isManagement) + { + this.isManagement = isManagement; + } + public void setClientOptions(Map clientOptions) { this.clientOptions = ImmutableMap.copyOf(clientOptions); diff --git a/src/java/org/apache/cassandra/service/NativeTransportManagementService.java b/src/java/org/apache/cassandra/service/NativeTransportManagementService.java new file mode 100644 index 0000000000..98a723fbf9 --- /dev/null +++ b/src/java/org/apache/cassandra/service/NativeTransportManagementService.java @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.service; + +import java.net.InetAddress; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; + +import com.google.common.annotations.VisibleForTesting; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.cassandra.auth.AuthenticatedUser; +import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.config.EncryptionOptions; +import org.apache.cassandra.transport.Server; + +import io.netty.channel.EventLoopGroup; +import io.netty.channel.epoll.EpollEventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.util.Version; + +import static org.apache.cassandra.config.CassandraRelevantProperties.START_NATIVE_MANAGEMENT_TRANSPORT; +import static org.apache.cassandra.service.NativeTransportService.useEpoll; + +/** + * Manages the native management transport server on port {@code 11211}. + * This service is independent of NativeTransportService and can only be + * enabled/disabled via configuration or system property (not at runtime). + */ +public class NativeTransportManagementService implements CassandraDaemon.Server +{ + private static final Logger logger = LoggerFactory.getLogger(NativeTransportManagementService.class); + + private Server server = null; + private EventLoopGroup workerGroup; + + private volatile boolean initialized = false; + + @VisibleForTesting + synchronized void initialize() + { + if (initialized) + return; + + if (useEpoll()) + { + workerGroup = new EpollEventLoopGroup(); + logger.info("Management transport Netty using native Epoll event loop"); + } + else + { + workerGroup = new NioEventLoopGroup(); + logger.info("Management transport Netty using Java NIO event loop"); + } + + logger.info("Management transport Netty Version: {}", Version.identify().entrySet()); + + int managementPort = DatabaseDescriptor.getNativeTransportManagementPort(); + InetAddress addr = DatabaseDescriptor.getRpcManagementAddress(); + + EncryptionOptions.TlsEncryptionPolicy encryptionPolicy = DatabaseDescriptor.getNativeProtocolEncryptionOptions() + .tlsEncryptionPolicy(); + + server = new Server.Builder() + .withEventLoopGroup(workerGroup) + .withHost(addr) + .withTlsEncryptionPolicy(encryptionPolicy) + .withPort(managementPort) + .withManagementConnectionFlag(true) + .build(); + + initialized = true; + } + + /** + * @return whether the management transport should start: the + * {@code cassandra.start_native_management_transport} system property wins when set, + * otherwise the {@code start_native_transport_management} yaml setting decides. + */ + public static boolean enabled() + { + if (START_NATIVE_MANAGEMENT_TRANSPORT.getString() != null) + return START_NATIVE_MANAGEMENT_TRANSPORT.getBoolean(); + return DatabaseDescriptor.startNativeTransportManagement(); + } + + public void start() + { + if (!enabled()) + { + logger.info("Management transport is disabled via configuration and will not start."); + return; + } + + initialize(); + server.start(); + logger.info("Management transport started on port: {}", server.socket.getPort()); + } + + public void stop() + { + if (server != null) + server.stop(false); + } + + public void destroy() + { + stop(); + if (server == null) + return; + + server = null; + if (workerGroup != null) + workerGroup.shutdownGracefully(3, 5, TimeUnit.SECONDS).awaitUninterruptibly(); + + initialized = false; + } + + /** @return true if the management transport server is running. */ + public boolean isRunning() + { + return server != null && server.isRunning(); + } + + /** Clears connection history for this service's server. */ + public void clearConnectionHistory() + { + if (server != null) + server.clearConnectionHistory(); + } + + /** Disconnects users matching the predicate from this service's server. */ + public void disconnect(Predicate userPredicate) + { + if (server != null) + server.disconnect(userPredicate); + } + + @VisibleForTesting + public EventLoopGroup getWorkerGroup() + { + return workerGroup; + } +} diff --git a/src/java/org/apache/cassandra/service/NativeTransportService.java b/src/java/org/apache/cassandra/service/NativeTransportService.java index 28817df396..c23917478e 100644 --- a/src/java/org/apache/cassandra/service/NativeTransportService.java +++ b/src/java/org/apache/cassandra/service/NativeTransportService.java @@ -30,7 +30,6 @@ import org.apache.cassandra.auth.AuthenticatedUser; import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.config.EncryptionOptions; import org.apache.cassandra.metrics.ClientMetrics; -import org.apache.cassandra.transport.Dispatcher; import org.apache.cassandra.transport.Server; import org.apache.cassandra.utils.NativeLibrary; @@ -45,7 +44,7 @@ import static org.apache.cassandra.config.CassandraRelevantProperties.NATIVE_EPO /** * Handles native transport server lifecycle and associated resources. Lazily initialized. */ -public class NativeTransportService +public class NativeTransportService implements CassandraDaemon.Server { private static final Logger logger = LoggerFactory.getLogger(NativeTransportService.class); @@ -126,8 +125,6 @@ public class NativeTransportService // shutdown executors used by netty for native transport server if (workerGroup != null) workerGroup.shutdownGracefully(3, 5, TimeUnit.SECONDS).awaitUninterruptibly(); - - Dispatcher.shutdown(); } /** diff --git a/src/java/org/apache/cassandra/tools/INodeProbeFactory.java b/src/java/org/apache/cassandra/tools/INodeProbeFactory.java index fec4a2b9bf..a5bf732495 100644 --- a/src/java/org/apache/cassandra/tools/INodeProbeFactory.java +++ b/src/java/org/apache/cassandra/tools/INodeProbeFactory.java @@ -32,11 +32,11 @@ class NodeProbeFactory implements INodeProbeFactory public NodeProbe create(String host, int port) throws IOException { - return new NodeProbe(host, port); + return new NodeProbe(new RemoteJmxMBeanAccessor(host, port)); } public NodeProbe create(String host, int port, String username, String password) throws IOException { - return new NodeProbe(host, port, username, password); + return new NodeProbe(new RemoteJmxMBeanAccessor(host, port, username, password)); } } diff --git a/src/java/org/apache/cassandra/tools/NodeProbe.java b/src/java/org/apache/cassandra/tools/NodeProbe.java index c60e0427e5..1f18dd1dd2 100644 --- a/src/java/org/apache/cassandra/tools/NodeProbe.java +++ b/src/java/org/apache/cassandra/tools/NodeProbe.java @@ -19,21 +19,17 @@ package org.apache.cassandra.tools; import java.io.IOException; import java.io.PrintStream; -import java.lang.management.ManagementFactory; import java.lang.management.MemoryMXBean; import java.lang.management.MemoryUsage; import java.lang.management.RuntimeMXBean; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; import java.net.InetAddress; import java.net.UnknownHostException; -import java.rmi.ConnectException; -import java.rmi.server.RMIClientSocketFactory; -import java.rmi.server.RMISocketFactory; -import java.util.AbstractMap; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -45,20 +41,14 @@ import java.util.concurrent.TimeoutException; import javax.annotation.Nullable; import javax.management.InstanceNotFoundException; -import javax.management.JMX; -import javax.management.MBeanServerConnection; import javax.management.MalformedObjectNameException; -import javax.management.ObjectName; import javax.management.openmbean.CompositeData; import javax.management.openmbean.OpenDataException; import javax.management.openmbean.TabularData; -import javax.management.remote.JMXConnector; -import javax.management.remote.JMXConnectorFactory; -import javax.management.remote.JMXServiceURL; -import javax.rmi.ssl.SslRMIClientSocketFactory; import com.google.common.base.Function; import com.google.common.base.Strings; +import com.google.common.base.Throwables; import com.google.common.collect.HashMultimap; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterables; @@ -67,43 +57,29 @@ import com.google.common.collect.Multimap; import com.google.common.collect.Sets; import com.google.common.util.concurrent.Uninterruptibles; -import org.apache.cassandra.audit.AuditLogManager; import org.apache.cassandra.audit.AuditLogManagerMBean; import org.apache.cassandra.audit.AuditLogOptions; import org.apache.cassandra.audit.AuditLogOptionsCompositeData; -import org.apache.cassandra.auth.AuthCache; import org.apache.cassandra.auth.AuthCacheMBean; -import org.apache.cassandra.auth.CIDRGroupsMappingManager; import org.apache.cassandra.auth.CIDRGroupsMappingManagerMBean; -import org.apache.cassandra.auth.CIDRPermissionsManager; import org.apache.cassandra.auth.CIDRPermissionsManagerMBean; -import org.apache.cassandra.auth.NetworkPermissionsCache; import org.apache.cassandra.auth.NetworkPermissionsCacheMBean; import org.apache.cassandra.auth.PasswordAuthenticator; -import org.apache.cassandra.auth.PermissionsCache; import org.apache.cassandra.auth.PermissionsCacheMBean; -import org.apache.cassandra.auth.RolesCache; import org.apache.cassandra.auth.RolesCacheMBean; import org.apache.cassandra.auth.jmx.AuthorizationProxy; -import org.apache.cassandra.batchlog.BatchlogManager; import org.apache.cassandra.batchlog.BatchlogManagerMBean; import org.apache.cassandra.db.ColumnFamilyStoreMBean; -import org.apache.cassandra.db.compaction.CompactionManager; import org.apache.cassandra.db.compaction.CompactionManagerMBean; import org.apache.cassandra.db.compression.CompressionDictionaryDetailsTabularData; import org.apache.cassandra.db.compression.CompressionDictionaryManagerMBean; import org.apache.cassandra.db.compression.TrainingState; -import org.apache.cassandra.db.guardrails.Guardrails; import org.apache.cassandra.db.guardrails.GuardrailsMBean; -import org.apache.cassandra.db.virtual.CIDRFilteringMetricsTable; import org.apache.cassandra.db.virtual.CIDRFilteringMetricsTableMBean; import org.apache.cassandra.fql.FullQueryLoggerOptions; import org.apache.cassandra.fql.FullQueryLoggerOptionsCompositeData; -import org.apache.cassandra.gms.FailureDetector; import org.apache.cassandra.gms.FailureDetectorMBean; -import org.apache.cassandra.gms.Gossiper; import org.apache.cassandra.gms.GossiperMBean; -import org.apache.cassandra.hints.HintsService; import org.apache.cassandra.hints.HintsServiceMBean; import org.apache.cassandra.index.sai.metrics.IndexGroupMetrics; import org.apache.cassandra.index.sai.metrics.TableQueryMetrics; @@ -111,59 +87,84 @@ import org.apache.cassandra.index.sai.metrics.TableStateMetrics; import org.apache.cassandra.locator.DynamicEndpointSnitchMBean; import org.apache.cassandra.locator.EndpointSnitchInfoMBean; import org.apache.cassandra.locator.LocationInfoMBean; -import org.apache.cassandra.metrics.CIDRAuthorizerMetrics; +import org.apache.cassandra.management.CommandInvokerService; +import org.apache.cassandra.management.MBeanAccessor; import org.apache.cassandra.metrics.CassandraMetricsRegistry; import org.apache.cassandra.metrics.StorageMetrics; import org.apache.cassandra.metrics.TableMetrics; import org.apache.cassandra.metrics.ThreadPoolMetrics; -import org.apache.cassandra.net.MessagingService; import org.apache.cassandra.net.MessagingServiceMBean; import org.apache.cassandra.profiler.AsyncProfilerMBean; import org.apache.cassandra.service.ActiveRepairServiceMBean; -import org.apache.cassandra.service.AsyncProfilerService; -import org.apache.cassandra.service.AutoRepairService; import org.apache.cassandra.service.AutoRepairServiceMBean; -import org.apache.cassandra.service.CacheService; import org.apache.cassandra.service.CacheServiceMBean; -import org.apache.cassandra.service.GCInspector; import org.apache.cassandra.service.GCInspectorMXBean; -import org.apache.cassandra.service.StorageProxy; import org.apache.cassandra.service.StorageProxyMBean; import org.apache.cassandra.service.StorageServiceMBean; -import org.apache.cassandra.service.accord.AccordOperations; import org.apache.cassandra.service.accord.AccordOperationsMBean; import org.apache.cassandra.service.snapshot.SnapshotManagerMBean; import org.apache.cassandra.streaming.StreamManagerMBean; import org.apache.cassandra.streaming.StreamState; import org.apache.cassandra.streaming.management.StreamStateCompositeData; -import org.apache.cassandra.tcm.CMSOperations; import org.apache.cassandra.tcm.CMSOperationsMBean; import org.apache.cassandra.tools.RepairRunner.RepairCmd; import org.apache.cassandra.tools.nodetool.GetTimeout; import org.apache.cassandra.utils.NativeLibrary; -import static org.apache.cassandra.config.CassandraRelevantProperties.NODETOOL_JMX_NOTIFICATION_POLL_INTERVAL_SECONDS; -import static org.apache.cassandra.config.CassandraRelevantProperties.SSL_ENABLE; - /** - * JMX client operations for Cassandra. + * A client wrapper (or Receiver in the command pattern) for performing administrative + * and monitoring operations on a Cassandra node. + *

+ * NodeProbe provides a high-level interface to interact with a Cassandra node through JMX. + * It abstracts the complexity of JMX connections and MBean lookups, providing convenient methods for common + * administrative tasks such as compaction, repair, snapshots, and cluster management. + *

+ * This class is primarily used by command-line tools like {@code nodetool} to execute administrative + * commands against remote or local Cassandra nodes. It can work with both remote JMX connections (via + * {@link RemoteJmxMBeanAccessor}) and local in-process accessors for server-side execution. + * + *

Execution Modes

+ *

Client-Side Execution (up to 5.x)

+ *

+ * When used with {@link RemoteJmxMBeanAccessor}, NodeProbe connects to a remote Cassandra node via JMX: + *

{@code
+ * MBeanAccessor accessor = new RemoteJmxMBeanAccessor("localhost", 7199);
+ * try (NodeProbe probe = new NodeProbe(accessor)) {
+ *     probe.forceKeyspaceCleanup(System.out, 1, "mykeyspace");
+ * }
+ * }
+ * + *

Server-Side Execution (CEP-38)

+ *

+ * As part of CEP-38, NodeProbe can be + * used for server-side command execution through the management API. In this mode, NodeProbe operates in-process + * using a local {@link MBeanAccessor} that directly accesses the platform MBean server, eliminating the need for + * remote JMX connections. + * + *

Server-side execution provides several advantages: + *

    + *
  • No Network Overhead: Direct in-process access to MBeans without JMX/RMI overhead
  • + *
  • Better Performance: No serialization/deserialization of JMX calls
  • + *
  • Unified API: Same NodeProbe interface works for both client and server-side execution
  • + *
  • Management API Integration: Commands can be executed via {@link CommandInvokerService} and exposed + * through various protocols (native protocol, REST, etc.)
  • + *
+ * + *

Thread Safety

+ *

NodeProbe instances are not thread-safe. Each thread should use its own instance, or external + * synchronization must be provided when sharing instances across threads. + * + *

Lazy Initialization

+ *

MBean proxies are initialized lazily on first access to reduce overhead for operations that + * don't require all MBeans. This is particularly beneficial for testing scenarios where only a + * subset of functionality is needed. + * + * @see MBeanAccessor + * @see RemoteJmxMBeanAccessor + * @see CommandInvokerService */ public class NodeProbe implements AutoCloseable { - private static final String fmtUrl = "service:jmx:rmi:///jndi/rmi://%s:%d/jmxrmi"; - private static final String ssObjName = "org.apache.cassandra.db:type=StorageService"; - public static final int defaultPort = 7199; - - static long JMX_NOTIFICATION_POLL_INTERVAL_SECONDS = NODETOOL_JMX_NOTIFICATION_POLL_INTERVAL_SECONDS.getLong(); - - final String host; - final int port; - private String username; - private String password; - - - protected JMXConnector jmxc; - protected MBeanServerConnection mbeanServerConn; protected CompactionManagerMBean compactionProxy; protected StorageServiceMBean ssProxy; protected SnapshotManagerMBean snapshotProxy; @@ -192,190 +193,67 @@ public class NodeProbe implements AutoCloseable protected AutoRepairServiceMBean autoRepairProxy; protected AsyncProfilerMBean asyncProfilerProxy; protected GuardrailsMBean grProxy; - protected volatile Output output; - protected CIDRFilteringMetricsTableMBean cfmProxy; - /** - * Creates a NodeProbe using the specified JMX host, port, username, and password. - * - * @param host hostname or IP address of the JMX agent - * @param port TCP port of the remote JMX agent - * @throws IOException on connection failures - */ - public NodeProbe(String host, int port, String username, String password) throws IOException - { - assert username != null && !username.isEmpty() && password != null && !password.isEmpty() - : "neither username nor password can be blank"; + private final MBeanAccessor mBeanAccessor; + protected volatile Output output; - this.host = host; - this.port = port; - this.username = username; - this.password = password; - this.output = Output.CONSOLE; - connect(); + /** + * Creates a NodeProbe using the specified MBeanAccessor. + * @param mBeanAccessor the provider to use for obtaining MBeans. + */ + public NodeProbe(MBeanAccessor mBeanAccessor) + { + this(mBeanAccessor, Output.CONSOLE); + } + + public NodeProbe(MBeanAccessor mBeanAccessor, Output output) + { + this.mBeanAccessor = mBeanAccessor; + this.output = output; + lazyInitMBeans(); + } + + public void close() throws Exception + { + mBeanAccessor.close(); } /** - * Creates a NodeProbe using the specified JMX host and port. - * - * @param host hostname or IP address of the JMX agent - * @param port TCP port of the remote JMX agent - * @throws IOException on connection failures + * Initialize all MBeans lazily, so that tests that don't need them + * don't pay the cost of creating all the proxies. */ - public NodeProbe(String host, int port) throws IOException + private void lazyInitMBeans() { - this.host = host; - this.port = port; - this.output = Output.CONSOLE; - connect(); - } - - /** - * Creates a NodeProbe using the specified JMX host and default port. - * - * @param host hostname or IP address of the JMX agent - * @throws IOException on connection failures - */ - public NodeProbe(String host) throws IOException - { - this.host = host; - this.port = defaultPort; - this.output = Output.CONSOLE; - connect(); - } - - protected NodeProbe() - { - // this constructor is only used for extensions to rewrite their own connect method - this.host = ""; - this.port = 0; - this.output = Output.CONSOLE; - } - - /** - * Create a connection to the JMX agent and setup the M[X]Bean proxies. - * - * @throws IOException on connection failures - */ - protected void connect() throws IOException - { - String host = this.host; - if (host.contains(":")) - { - // Use square brackets to surround IPv6 addresses to fix CASSANDRA-7669 and CASSANDRA-17581 - host = "[" + host + "]"; - } - JMXServiceURL jmxUrl = new JMXServiceURL(String.format(fmtUrl, host, port)); - Map env = new HashMap(); - if (username != null) - { - String[] creds = { username, password }; - env.put(JMXConnector.CREDENTIALS, creds); - } - - env.put("com.sun.jndi.rmi.factory.socket", getRMIClientSocketFactory()); - - jmxc = JMXConnectorFactory.connect(jmxUrl, env); - mbeanServerConn = jmxc.getMBeanServerConnection(); - - try - { - ObjectName name = new ObjectName(ssObjName); - ssProxy = JMX.newMBeanProxy(mbeanServerConn, name, StorageServiceMBean.class); - name = new ObjectName(SnapshotManagerMBean.MBEAN_NAME); - snapshotProxy = JMX.newMBeanProxy(mbeanServerConn, name, SnapshotManagerMBean.class); - name = new ObjectName(CMSOperations.MBEAN_OBJECT_NAME); - cmsProxy = JMX.newMBeanProxy(mbeanServerConn, name, CMSOperationsMBean.class); - name = new ObjectName(AccordOperations.MBEAN_OBJECT_NAME); - accordProxy = JMX.newMBeanProxy(mbeanServerConn, name, AccordOperationsMBean.class); - name = new ObjectName(MessagingService.MBEAN_NAME); - msProxy = JMX.newMBeanProxy(mbeanServerConn, name, MessagingServiceMBean.class); - name = new ObjectName(StreamManagerMBean.OBJECT_NAME); - streamProxy = JMX.newMBeanProxy(mbeanServerConn, name, StreamManagerMBean.class); - name = new ObjectName(CompactionManager.MBEAN_OBJECT_NAME); - compactionProxy = JMX.newMBeanProxy(mbeanServerConn, name, CompactionManagerMBean.class); - name = new ObjectName(FailureDetector.MBEAN_NAME); - fdProxy = JMX.newMBeanProxy(mbeanServerConn, name, FailureDetectorMBean.class); - name = new ObjectName(CacheService.MBEAN_NAME); - cacheService = JMX.newMBeanProxy(mbeanServerConn, name, CacheServiceMBean.class); - name = new ObjectName(StorageProxy.MBEAN_NAME); - spProxy = JMX.newMBeanProxy(mbeanServerConn, name, StorageProxyMBean.class); - name = new ObjectName(HintsService.MBEAN_NAME); - hsProxy = JMX.newMBeanProxy(mbeanServerConn, name, HintsServiceMBean.class); - name = new ObjectName(GCInspector.MBEAN_NAME); - gcProxy = JMX.newMBeanProxy(mbeanServerConn, name, GCInspectorMXBean.class); - name = new ObjectName(Gossiper.MBEAN_NAME); - gossProxy = JMX.newMBeanProxy(mbeanServerConn, name, GossiperMBean.class); - name = new ObjectName(BatchlogManager.MBEAN_NAME); - bmProxy = JMX.newMBeanProxy(mbeanServerConn, name, BatchlogManagerMBean.class); - name = new ObjectName(ActiveRepairServiceMBean.MBEAN_NAME); - arsProxy = JMX.newMBeanProxy(mbeanServerConn, name, ActiveRepairServiceMBean.class); - name = new ObjectName(AuditLogManager.MBEAN_NAME); - almProxy = JMX.newMBeanProxy(mbeanServerConn, name, AuditLogManagerMBean.class); - name = new ObjectName(AuthCache.MBEAN_NAME_BASE + PasswordAuthenticator.CredentialsCacheMBean.CACHE_NAME); - ccProxy = JMX.newMBeanProxy(mbeanServerConn, name, PasswordAuthenticator.CredentialsCacheMBean.class); - name = new ObjectName(AuthCache.MBEAN_NAME_BASE + AuthorizationProxy.JmxPermissionsCacheMBean.CACHE_NAME); - jpcProxy = JMX.newMBeanProxy(mbeanServerConn, name, AuthorizationProxy.JmxPermissionsCacheMBean.class); - - name = new ObjectName(AuthCache.MBEAN_NAME_BASE + NetworkPermissionsCache.CACHE_NAME); - npcProxy = JMX.newMBeanProxy(mbeanServerConn, name, NetworkPermissionsCacheMBean.class); - - name = new ObjectName(AuthCache.MBEAN_NAME_BASE + PermissionsCache.CACHE_NAME); - pcProxy = JMX.newMBeanProxy(mbeanServerConn, name, PermissionsCacheMBean.class); - - name = new ObjectName(AuthCache.MBEAN_NAME_BASE + RolesCache.CACHE_NAME); - rcProxy = JMX.newMBeanProxy(mbeanServerConn, name, RolesCacheMBean.class); - - name = new ObjectName(CIDRPermissionsManager.MBEAN_NAME); - cpbProxy = JMX.newMBeanProxy(mbeanServerConn, name, CIDRPermissionsManagerMBean.class); - - name = new ObjectName(CIDRGroupsMappingManager.MBEAN_NAME); - cmbProxy = JMX.newMBeanProxy(mbeanServerConn, name, CIDRGroupsMappingManagerMBean.class); - - name = new ObjectName(CIDRFilteringMetricsTable.MBEAN_NAME); - cfmProxy = JMX.newMBeanProxy(mbeanServerConn, name, CIDRFilteringMetricsTableMBean.class); - - name = new ObjectName(AutoRepairService.MBEAN_NAME); - autoRepairProxy = JMX.newMBeanProxy(mbeanServerConn, name, AutoRepairServiceMBean.class); - - name = new ObjectName(AsyncProfilerService.MBEAN_NAME); - asyncProfilerProxy = JMX.newMBeanProxy(mbeanServerConn, name, AsyncProfilerMBean.class); - - name = new ObjectName(Guardrails.MBEAN_NAME); - grProxy = JMX.newMBeanProxy(mbeanServerConn, name, GuardrailsMBean.class); - } - catch (MalformedObjectNameException e) - { - throw new RuntimeException( - "Invalid ObjectName? Please report this as a bug.", e); - } - - memProxy = ManagementFactory.newPlatformMXBeanProxy(mbeanServerConn, - ManagementFactory.MEMORY_MXBEAN_NAME, MemoryMXBean.class); - runtimeProxy = ManagementFactory.newPlatformMXBeanProxy( - mbeanServerConn, ManagementFactory.RUNTIME_MXBEAN_NAME, RuntimeMXBean.class); - } - - private RMIClientSocketFactory getRMIClientSocketFactory() - { - if (SSL_ENABLE.getBoolean()) - return new SslRMIClientSocketFactory(); - else - return RMISocketFactory.getDefaultSocketFactory(); - } - - public void close() throws IOException - { - try - { - jmxc.close(); - } - catch (ConnectException e) - { - // result of 'stopdaemon' command - i.e. if close() call fails, the daemon is shutdown - System.out.println("Cassandra has shutdown."); - } + ssProxy = LazyMBeanProxy.create(mBeanAccessor, StorageServiceMBean.class); + compactionProxy = LazyMBeanProxy.create(mBeanAccessor, CompactionManagerMBean.class); + snapshotProxy = LazyMBeanProxy.create(mBeanAccessor, SnapshotManagerMBean.class); + cmsProxy = LazyMBeanProxy.create(mBeanAccessor, CMSOperationsMBean.class); + accordProxy = LazyMBeanProxy.create(mBeanAccessor, AccordOperationsMBean.class); + gossProxy = LazyMBeanProxy.create(mBeanAccessor, GossiperMBean.class); + memProxy = LazyMBeanProxy.create(mBeanAccessor, MemoryMXBean.class); + gcProxy = LazyMBeanProxy.create(mBeanAccessor, GCInspectorMXBean.class); + runtimeProxy = LazyMBeanProxy.create(mBeanAccessor, RuntimeMXBean.class); + streamProxy = LazyMBeanProxy.create(mBeanAccessor, StreamManagerMBean.class); + msProxy = LazyMBeanProxy.create(mBeanAccessor, MessagingServiceMBean.class); + fdProxy = LazyMBeanProxy.create(mBeanAccessor, FailureDetectorMBean.class); + cacheService = LazyMBeanProxy.create(mBeanAccessor, CacheServiceMBean.class); + spProxy = LazyMBeanProxy.create(mBeanAccessor, StorageProxyMBean.class); + hsProxy = LazyMBeanProxy.create(mBeanAccessor, HintsServiceMBean.class); + bmProxy = LazyMBeanProxy.create(mBeanAccessor, BatchlogManagerMBean.class); + arsProxy = LazyMBeanProxy.create(mBeanAccessor, ActiveRepairServiceMBean.class); + almProxy = LazyMBeanProxy.create(mBeanAccessor, AuditLogManagerMBean.class); + ccProxy = LazyMBeanProxy.create(mBeanAccessor, PasswordAuthenticator.CredentialsCacheMBean.class); + jpcProxy = LazyMBeanProxy.create(mBeanAccessor, AuthorizationProxy.JmxPermissionsCacheMBean.class); + npcProxy = LazyMBeanProxy.create(mBeanAccessor, NetworkPermissionsCacheMBean.class); + cpbProxy = LazyMBeanProxy.create(mBeanAccessor, CIDRPermissionsManagerMBean.class); + cmbProxy = LazyMBeanProxy.create(mBeanAccessor, CIDRGroupsMappingManagerMBean.class); + pcProxy = LazyMBeanProxy.create(mBeanAccessor, PermissionsCacheMBean.class); + rcProxy = LazyMBeanProxy.create(mBeanAccessor, RolesCacheMBean.class); + autoRepairProxy = LazyMBeanProxy.create(mBeanAccessor, AutoRepairServiceMBean.class); + grProxy = LazyMBeanProxy.create(mBeanAccessor, GuardrailsMBean.class); + cfmProxy = LazyMBeanProxy.create(mBeanAccessor, CIDRFilteringMetricsTableMBean.class); + asyncProfilerProxy = LazyMBeanProxy.create(mBeanAccessor, AsyncProfilerMBean.class); } public void setOutput(Output output) @@ -575,7 +453,7 @@ public class NodeProbe implements AutoCloseable { List runners = new ArrayList<>(cmds.size()); for (RepairCmd cmd : cmds) - runners.add(new RepairRunner(out, jmxc, ssProxy, cmd)); + runners.add(new RepairRunner(out, mBeanAccessor, ssProxy, cmd)); try { @@ -825,23 +703,14 @@ public class NodeProbe implements AutoCloseable return ssProxy.effectiveOwnershipWithPort(keyspace); } - public MBeanServerConnection getMbeanServerConn() + public MBeanAccessor getMBeanAccessor() { - return mbeanServerConn; + return mBeanAccessor; } public CacheServiceMBean getCacheServiceMBean() { - String cachePath = "org.apache.cassandra.db:type=Caches"; - - try - { - return JMX.newMBeanProxy(mbeanServerConn, new ObjectName(cachePath), CacheServiceMBean.class); - } - catch (MalformedObjectNameException e) - { - throw new RuntimeException(e); - } + return mBeanAccessor.findMBean(CacheServiceMBean.class); } public double[] getAndResetGCStats() @@ -853,7 +722,7 @@ public class NodeProbe implements AutoCloseable { try { - return new ColumnFamilyStoreMBeanIterator(mbeanServerConn); + return new ColumnFamilyStoreMBeanIterator(mBeanAccessor); } catch (MalformedObjectNameException e) { @@ -1267,67 +1136,30 @@ public class NodeProbe implements AutoCloseable public EndpointSnitchInfoMBean getEndpointSnitchInfoProxy() { - try - { - return JMX.newMBeanProxy(mbeanServerConn, new ObjectName("org.apache.cassandra.db:type=EndpointSnitchInfo"), EndpointSnitchInfoMBean.class); - } - catch (MalformedObjectNameException e) - { - throw new RuntimeException(e); - } + return mBeanAccessor.findMBean(EndpointSnitchInfoMBean.class); } public DynamicEndpointSnitchMBean getDynamicEndpointSnitchInfoProxy() { - try - { - return JMX.newMBeanProxy(mbeanServerConn, new ObjectName("org.apache.cassandra.db:type=DynamicEndpointSnitch"), DynamicEndpointSnitchMBean.class); - } - catch (MalformedObjectNameException e) - { - throw new RuntimeException(e); - } + return mBeanAccessor.findMBean(DynamicEndpointSnitchMBean.class); } public LocationInfoMBean getLocationInfoProxy() { - try - { - return JMX.newMBeanProxy(mbeanServerConn, new ObjectName("org.apache.cassandra.db:type=LocationInfo"), LocationInfoMBean.class); - } - catch (MalformedObjectNameException e) - { - throw new RuntimeException(e); - } + return mBeanAccessor.findMBean(LocationInfoMBean.class); } public ColumnFamilyStoreMBean getCfsProxy(String ks, String cf) { - ColumnFamilyStoreMBean cfsProxy = null; try { String type = cf.contains(".") ? "IndexColumnFamilies" : "ColumnFamilies"; - Set beans = mbeanServerConn.queryNames( - new ObjectName("org.apache.cassandra.db:type=*" + type +",keyspace=" + ks + ",columnfamily=" + cf), null); - - if (beans.isEmpty()) - throw new MalformedObjectNameException("couldn't find that bean"); - assert beans.size() == 1; - for (ObjectName bean : beans) - cfsProxy = JMX.newMBeanProxy(mbeanServerConn, bean, ColumnFamilyStoreMBean.class); + return mBeanAccessor.findColumnFamily(type, ks, cf); } - catch (MalformedObjectNameException mone) + catch (Exception e) { - System.err.println("ColumnFamilyStore for " + ks + "/" + cf + " not found."); - System.exit(1); + throw new IllegalArgumentException("ColumnFamilyStore for " + ks + '/' + cf + " not found: " + e.getMessage(), e); } - catch (IOException e) - { - System.err.println("ColumnFamilyStore for " + ks + "/" + cf + " not found: " + e); - System.exit(1); - } - - return cfsProxy; } public StorageProxyMBean getSpProxy() @@ -1869,39 +1701,23 @@ public class NodeProbe implements AutoCloseable */ public Object getCacheMetric(String cacheType, String metricName) { - try + switch (metricName) { - switch(metricName) - { - case "Capacity": - case "Entries": - case "HitRate": - case "Size": - return JMX.newMBeanProxy(mbeanServerConn, - new ObjectName("org.apache.cassandra.metrics:type=Cache,scope=" + cacheType + ",name=" + metricName), - CassandraMetricsRegistry.JmxGaugeMBean.class).getValue(); - case "Requests": - case "Hits": - case "Misses": - return JMX.newMBeanProxy(mbeanServerConn, - new ObjectName("org.apache.cassandra.metrics:type=Cache,scope=" + cacheType + ",name=" + metricName), - CassandraMetricsRegistry.JmxMeterMBean.class).getCount(); - case "MissLatency": - return JMX.newMBeanProxy(mbeanServerConn, - new ObjectName("org.apache.cassandra.metrics:type=Cache,scope=" + cacheType + ",name=" + metricName), - CassandraMetricsRegistry.JmxTimerMBean.class).getMean(); - case "MissLatencyUnit": - return JMX.newMBeanProxy(mbeanServerConn, - new ObjectName("org.apache.cassandra.metrics:type=Cache,scope=" + cacheType + ",name=MissLatency"), - CassandraMetricsRegistry.JmxTimerMBean.class).getDurationUnit(); - default: - throw new RuntimeException("Unknown Cache metric name " + metricName); - - } - } - catch (MalformedObjectNameException e) - { - throw new RuntimeException(e); + case "Capacity": + case "Entries": + case "HitRate": + case "Size": + return mBeanAccessor.findMBeanGauge(MBeanAccessor.Props.scoped("Cache", cacheType, metricName)).getValue(); + case "Requests": + case "Hits": + case "Misses": + return mBeanAccessor.findMBeanMeter(MBeanAccessor.Props.scoped("Cache", cacheType, metricName)).getCount(); + case "MissLatency": + return mBeanAccessor.findMBeanTimer(MBeanAccessor.Props.scoped("Cache", cacheType, metricName)).getMean(); + case "MissLatencyUnit": + return mBeanAccessor.findMBeanTimer(MBeanAccessor.Props.scoped("Cache", cacheType, "MissLatency")).getDurationUnit(); + default: + throw new RuntimeException("Unknown Cache metric name " + metricName); } } @@ -1913,137 +1729,69 @@ public class NodeProbe implements AutoCloseable */ public Object getBufferPoolMetric(String poolType, String metricName) { - try + switch (metricName) { - switch (metricName) - { - case "UsedSize": - case "OverflowSize": - case "Capacity": - case "Size": - return JMX.newMBeanProxy(mbeanServerConn, - new ObjectName("org.apache.cassandra.metrics:type=BufferPool,scope=" + poolType + ",name=" + metricName), - CassandraMetricsRegistry.JmxGaugeMBean.class).getValue(); - case "Hits": - case "Misses": - return JMX.newMBeanProxy(mbeanServerConn, - new ObjectName("org.apache.cassandra.metrics:type=BufferPool,scope=" + poolType + ",name=" + metricName), - CassandraMetricsRegistry.JmxMeterMBean.class).getCount(); - default: - throw new RuntimeException("Unknown BufferPool metric name " + metricName); - } - } - catch (MalformedObjectNameException e) - { - throw new RuntimeException(e); - } - } - - private static Multimap getJmxThreadPools(MBeanServerConnection mbeanServerConn) - { - try - { - Multimap threadPools = HashMultimap.create(); - - Set threadPoolObjectNames = mbeanServerConn.queryNames( - new ObjectName("org.apache.cassandra.metrics:type=ThreadPools,*"), - null); - - for (ObjectName oName : threadPoolObjectNames) - { - threadPools.put(oName.getKeyProperty("path"), oName.getKeyProperty("scope")); - } - - return threadPools; - } - catch (MalformedObjectNameException e) - { - throw new RuntimeException("Bad query to JMX server: ", e); - } - catch (IOException e) - { - throw new RuntimeException("Error getting threadpool names from JMX", e); + case "UsedSize": + case "OverflowSize": + case "Capacity": + case "Size": + return mBeanAccessor.findMBeanGauge(MBeanAccessor.Props.scoped("BufferPool", poolType, metricName)).getValue(); + case "Hits": + case "Misses": + return mBeanAccessor.findMBeanMeter(MBeanAccessor.Props.scoped("BufferPool", poolType, metricName)).getCount(); + default: + throw new RuntimeException("Unknown BufferPool metric name " + metricName); } } public Object getThreadPoolMetric(String pathName, String poolName, String metricName) { - String name = String.format("org.apache.cassandra.metrics:type=ThreadPools,path=%s,scope=%s,name=%s", - pathName, poolName, metricName); + if (!mBeanAccessor.isMBeanMetricRegistered(MBeanAccessor.Props.threadPool("ThreadPools", pathName, poolName, metricName))) + { + return "N/A"; + } - try - { - ObjectName oName = new ObjectName(name); - if (!mbeanServerConn.isRegistered(oName)) - { - return "N/A"; - } - - switch (metricName) - { - case ThreadPoolMetrics.ACTIVE_TASKS: - case ThreadPoolMetrics.PENDING_TASKS: - case ThreadPoolMetrics.COMPLETED_TASKS: - case ThreadPoolMetrics.CORE_POOL_SIZE: - case ThreadPoolMetrics.MAX_POOL_SIZE: - case ThreadPoolMetrics.MAX_TASKS_QUEUED: - return JMX.newMBeanProxy(mbeanServerConn, oName, CassandraMetricsRegistry.JmxGaugeMBean.class).getValue(); - case ThreadPoolMetrics.TOTAL_BLOCKED_TASKS: - case ThreadPoolMetrics.CURRENTLY_BLOCKED_TASKS: - return JMX.newMBeanProxy(mbeanServerConn, oName, CassandraMetricsRegistry.JmxCounterMBean.class).getCount(); - default: - throw new AssertionError("Unknown ThreadPools metric name " + metricName); - } - } - catch (Exception e) - { - throw new RuntimeException("Error reading: " + name, e); - } + switch (metricName) + { + case ThreadPoolMetrics.ACTIVE_TASKS: + case ThreadPoolMetrics.PENDING_TASKS: + case ThreadPoolMetrics.COMPLETED_TASKS: + case ThreadPoolMetrics.CORE_POOL_SIZE: + case ThreadPoolMetrics.MAX_POOL_SIZE: + case ThreadPoolMetrics.MAX_TASKS_QUEUED: + return mBeanAccessor.findMBeanGauge(MBeanAccessor.Props.threadPool("ThreadPools", pathName, poolName, metricName)).getValue(); + case ThreadPoolMetrics.TOTAL_BLOCKED_TASKS: + case ThreadPoolMetrics.CURRENTLY_BLOCKED_TASKS: + return mBeanAccessor.findMBeanCounter(MBeanAccessor.Props.threadPool("ThreadPools", pathName, poolName, metricName)).getCount(); + default: + throw new RuntimeException("Unknown ThreadPools metric name " + metricName); + } } public Object getSaiMetric(String ks, String cf, String metricName) { - try - { - String scope = getSaiMetricScope(metricName); - String objectNameStr = String.format("org.apache.cassandra.metrics:type=StorageAttachedIndex,keyspace=%s,table=%s,scope=%s,name=%s",ks, cf, scope, metricName); - ObjectName oName = new ObjectName(objectNameStr); + String scope = getSaiMetricScope(metricName); + MBeanAccessor.Props props = MBeanAccessor.Props.sai("StorageAttachedIndex", ks, cf, scope, metricName); + if (!mBeanAccessor.isMBeanMetricRegistered(props)) + return null; - Set matchingMBeans = mbeanServerConn.queryNames(oName, null); - if (matchingMBeans.isEmpty()) - return null; - - return getSaiMetricValue(metricName, oName); - } - catch (MalformedObjectNameException e) - { - throw new RuntimeException("Invalid ObjectName format: " + e.getMessage(), e); - } - catch (IOException e) - { - throw new RuntimeException("Error accessing MBean server: " + e.getMessage(), e); - } - } - - private Object getSaiMetricValue(String metricName, ObjectName oName) throws IOException - { switch (metricName) { case "QueryLatency": - return JMX.newMBeanProxy(mbeanServerConn, oName, CassandraMetricsRegistry.JmxTimerMBean.class); + return mBeanAccessor.findMBeanTimer(props); case "PostFilteringReadLatency": case "SSTableIndexesHit": case "IndexSegmentsHit": case "RowsFiltered": - return JMX.newMBeanProxy(mbeanServerConn, oName, CassandraMetricsRegistry.JmxHistogramMBean.class); + return mBeanAccessor.findMBeanHistogram(props); case "DiskUsedBytes": case "TotalIndexCount": case "TotalQueryableIndexCount": - return JMX.newMBeanProxy(mbeanServerConn, oName, CassandraMetricsRegistry.JmxGaugeMBean.class).getValue(); + return mBeanAccessor.findMBeanGauge(props).getValue(); case "TotalQueryTimeouts": - return JMX.newMBeanProxy(mbeanServerConn, oName, CassandraMetricsRegistry.JmxCounterMBean.class).getCount(); + return mBeanAccessor.findMBeanCounter(props).getCount(); default: - throw new IllegalArgumentException("Unknown metric name: " + metricName); + throw new RuntimeException("Unknown metric name: " + metricName); } } @@ -2065,7 +1813,7 @@ public class NodeProbe implements AutoCloseable case "TotalQueryableIndexCount": return TableStateMetrics.TABLE_STATE_METRIC_TYPE; default: - throw new IllegalArgumentException("Unknown metric name: " + metricName); + throw new RuntimeException("Unknown metric name: " + metricName); } } @@ -2073,10 +1821,13 @@ public class NodeProbe implements AutoCloseable * Retrieve threadpool paths and names for threadpools with metrics. * @return Multimap from path (internal, request, etc.) to name */ - public Multimap getThreadPools() - { - return getJmxThreadPools(mbeanServerConn); - } + public Multimap getThreadPools() + { + Multimap threadPools = HashMultimap.create(); + for (MBeanAccessor.ThreadPoolInfo info : mBeanAccessor.threadPoolInfos()) + threadPools.put(info.path(), info.poolName()); + return threadPools; + } public int getNumberOfTables() { @@ -2091,92 +1842,85 @@ public class NodeProbe implements AutoCloseable */ public Object getColumnFamilyMetric(String ks, String cf, String metricName) { - try + MBeanAccessor.Props props; + if (!Strings.isNullOrEmpty(ks) && !Strings.isNullOrEmpty(cf)) { - ObjectName oName = null; - if (!Strings.isNullOrEmpty(ks) && !Strings.isNullOrEmpty(cf)) - { - String type = cf.contains(".") ? "IndexTable" : "Table"; - oName = new ObjectName(String.format("org.apache.cassandra.metrics:type=%s,keyspace=%s,scope=%s,name=%s", type, ks, cf, metricName)); - } - else if (!Strings.isNullOrEmpty(ks)) - { - oName = new ObjectName(String.format("org.apache.cassandra.metrics:type=Keyspace,keyspace=%s,name=%s", ks, metricName)); - } - else - { - oName = new ObjectName(String.format("org.apache.cassandra.metrics:type=Table,name=%s", metricName)); - } - switch(metricName) - { - case "BloomFilterDiskSpaceUsed": - case "BloomFilterFalsePositives": - case "BloomFilterFalseRatio": - case "BloomFilterOffHeapMemoryUsed": - case "IndexSummaryOffHeapMemoryUsed": - case "CompressionDictionariesMemoryUsed": - case "CompressionMetadataOffHeapMemoryUsed": - case "CompressionRatio": - case "EstimatedColumnCountHistogram": - case "EstimatedPartitionSizeHistogram": - case "EstimatedPartitionCount": - case "KeyCacheHitRate": - case "LiveSSTableCount": - case "MaxSSTableDuration": - case "MaxSSTableSize": - case "OldVersionSSTableCount": - case "MaxPartitionSize": - case "MeanPartitionSize": - case "MemtableColumnsCount": - case "MemtableLiveDataSize": - case "MemtableOffHeapSize": - case "MinPartitionSize": - case "PercentRepaired": - case "BytesRepaired": - case "BytesUnrepaired": - case "BytesPendingRepair": - case "RecentBloomFilterFalsePositives": - case "RecentBloomFilterFalseRatio": - case "SnapshotsSize": - return JMX.newMBeanProxy(mbeanServerConn, oName, CassandraMetricsRegistry.JmxGaugeMBean.class).getValue(); - case "LiveDiskSpaceUsed": - case "MemtableSwitchCount": - case "SpeculativeRetries": - case "TotalDiskSpaceUsed": - case "WriteTotalLatency": - case "ReadTotalLatency": - case "PendingFlushes": - { - // these are gauges for keyspace metrics, not counters - if (!Strings.isNullOrEmpty(ks) && - Strings.isNullOrEmpty(cf) && - (metricName.equals("TotalDiskSpaceUsed") || - metricName.equals("LiveDiskSpaceUsed") || - metricName.equals("MemtableSwitchCount"))) - { - return JMX.newMBeanProxy(mbeanServerConn, oName, CassandraMetricsRegistry.JmxGaugeMBean.class).getValue(); - } - else - { - return JMX.newMBeanProxy(mbeanServerConn, oName, CassandraMetricsRegistry.JmxCounterMBean.class).getCount(); - } - } - case "CoordinatorReadLatency": - case "CoordinatorScanLatency": - case "ReadLatency": - case "WriteLatency": - return JMX.newMBeanProxy(mbeanServerConn, oName, CassandraMetricsRegistry.JmxTimerMBean.class); - case "LiveScannedHistogram": - case "SSTablesPerReadHistogram": - case "TombstoneScannedHistogram": - return JMX.newMBeanProxy(mbeanServerConn, oName, CassandraMetricsRegistry.JmxHistogramMBean.class); - default: - throw new RuntimeException("Unknown table metric " + metricName); - } + String type = cf.contains(".") ? "IndexTable" : "Table"; + props = MBeanAccessor.Props.columnFamily(type, ks, cf, metricName); } - catch (MalformedObjectNameException e) + else if (!Strings.isNullOrEmpty(ks)) { - throw new RuntimeException(e); + props = MBeanAccessor.Props.keyspace("Keyspace", ks, metricName); + } + else + { + props = MBeanAccessor.Props.metric("Table", metricName); + } + switch (metricName) + { + case "BloomFilterDiskSpaceUsed": + case "BloomFilterFalsePositives": + case "BloomFilterFalseRatio": + case "BloomFilterOffHeapMemoryUsed": + case "IndexSummaryOffHeapMemoryUsed": + case "CompressionDictionariesMemoryUsed": + case "CompressionMetadataOffHeapMemoryUsed": + case "CompressionRatio": + case "EstimatedColumnCountHistogram": + case "EstimatedPartitionSizeHistogram": + case "EstimatedPartitionCount": + case "KeyCacheHitRate": + case "LiveSSTableCount": + case "MaxSSTableDuration": + case "MaxSSTableSize": + case "OldVersionSSTableCount": + case "MaxPartitionSize": + case "MeanPartitionSize": + case "MemtableColumnsCount": + case "MemtableLiveDataSize": + case "MemtableOffHeapSize": + case "MinPartitionSize": + case "PercentRepaired": + case "BytesRepaired": + case "BytesUnrepaired": + case "BytesPendingRepair": + case "RecentBloomFilterFalsePositives": + case "RecentBloomFilterFalseRatio": + case "SnapshotsSize": + return mBeanAccessor.findMBeanGauge(props).getValue(); + case "LiveDiskSpaceUsed": + case "MemtableSwitchCount": + case "SpeculativeRetries": + case "TotalDiskSpaceUsed": + case "WriteTotalLatency": + case "ReadTotalLatency": + case "PendingFlushes": + { + // these are gauges for keyspace metrics, not counters + if (!Strings.isNullOrEmpty(ks) && + Strings.isNullOrEmpty(cf) && + (metricName.equals("TotalDiskSpaceUsed") || + metricName.equals("LiveDiskSpaceUsed") || + metricName.equals("MemtableSwitchCount"))) + { + return mBeanAccessor.findMBeanGauge(props).getValue(); + } + else + { + return mBeanAccessor.findMBeanCounter(props).getCount(); + } + } + case "CoordinatorReadLatency": + case "CoordinatorScanLatency": + case "ReadLatency": + case "WriteLatency": + return mBeanAccessor.findMBeanTimer(props); + case "LiveScannedHistogram": + case "SSTablesPerReadHistogram": + case "TombstoneScannedHistogram": + return mBeanAccessor.findMBeanHistogram(props); + default: + throw new RuntimeException("Unknown table metric " + metricName); } } @@ -2186,30 +1930,12 @@ public class NodeProbe implements AutoCloseable */ public CassandraMetricsRegistry.JmxTimerMBean getProxyMetric(String scope) { - try - { - return JMX.newMBeanProxy(mbeanServerConn, - new ObjectName("org.apache.cassandra.metrics:type=ClientRequest,scope=" + scope + ",name=Latency"), - CassandraMetricsRegistry.JmxTimerMBean.class); - } - catch (MalformedObjectNameException e) - { - throw new RuntimeException(e); - } + return mBeanAccessor.findMBeanTimer(MBeanAccessor.Props.scoped("ClientRequest", scope, "Latency")); } public CassandraMetricsRegistry.JmxTimerMBean getMessagingQueueWaitMetrics(String verb) { - try - { - return JMX.newMBeanProxy(mbeanServerConn, - new ObjectName("org.apache.cassandra.metrics:name=" + verb + "-WaitLatency,type=Messaging"), - CassandraMetricsRegistry.JmxTimerMBean.class); - } - catch (MalformedObjectNameException e) - { - throw new RuntimeException(e); - } + return mBeanAccessor.findMBeanTimer(MBeanAccessor.Props.metric("Messaging", verb + "-WaitLatency")); } /** @@ -2220,35 +1946,22 @@ public class NodeProbe implements AutoCloseable */ public Object getCompactionMetric(String metricName) { - try + switch (metricName) { - switch(metricName) - { - case "BytesCompacted": - case "CompressedBytesCompacted": - case "CompactionsAborted": - case "CompactionsReduced": - case "SSTablesDroppedFromCompaction": - return JMX.newMBeanProxy(mbeanServerConn, - new ObjectName("org.apache.cassandra.metrics:type=Compaction,name=" + metricName), - CassandraMetricsRegistry.JmxCounterMBean.class); - case "CompletedTasks": - case "PendingTasks": - case "PendingTasksByTableName": - return JMX.newMBeanProxy(mbeanServerConn, - new ObjectName("org.apache.cassandra.metrics:type=Compaction,name=" + metricName), - CassandraMetricsRegistry.JmxGaugeMBean.class).getValue(); - case "TotalCompactionsCompleted": - return JMX.newMBeanProxy(mbeanServerConn, - new ObjectName("org.apache.cassandra.metrics:type=Compaction,name=" + metricName), - CassandraMetricsRegistry.JmxMeterMBean.class); - default: - throw new RuntimeException("Unknown compaction metric " + metricName); - } - } - catch (MalformedObjectNameException e) - { - throw new RuntimeException(e); + case "BytesCompacted": + case "CompressedBytesCompacted": + case "CompactionsAborted": + case "CompactionsReduced": + case "SSTablesDroppedFromCompaction": + return mBeanAccessor.findMBeanCounter(MBeanAccessor.Props.metric("Compaction", metricName)); + case "CompletedTasks": + case "PendingTasks": + case "PendingTasksByTableName": + return mBeanAccessor.findMBeanGauge(MBeanAccessor.Props.metric("Compaction", metricName)).getValue(); + case "TotalCompactionsCompleted": + return mBeanAccessor.findMBeanMeter(MBeanAccessor.Props.metric("Compaction", metricName)); + default: + throw new RuntimeException("Unknown compaction metric " + metricName); } } @@ -2258,65 +1971,15 @@ public class NodeProbe implements AutoCloseable */ public Object getClientMetric(String metricName) { - try + switch (metricName) { - switch(metricName) - { - case "connections": // List> - list of all native connections and their properties - case "connectedNativeClients": // number of connected native clients - case "connectedNativeClientsByUser": // number of native clients by username - case "clientsByProtocolVersion": // number of native clients by protocol version - return JMX.newMBeanProxy(mbeanServerConn, - new ObjectName("org.apache.cassandra.metrics:type=Client,name=" + metricName), - CassandraMetricsRegistry.JmxGaugeMBean.class).getValue(); - default: - throw new RuntimeException("Unknown client metric " + metricName); - } - } - catch (MalformedObjectNameException e) - { - throw new RuntimeException(e); - } - } - - public Object getCidrFilteringMetric(String metricName) - { - try - { - switch(metricName) - { - case CIDRAuthorizerMetrics.CIDR_CHECKS_LATENCY: - return JMX.newMBeanProxy(mbeanServerConn, - new ObjectName("org.apache.cassandra.metrics:type=CIDRAuthorization,name=" - + metricName), - CassandraMetricsRegistry.JmxTimerMBean.class).getMean(); - case CIDRAuthorizerMetrics.CIDR_GROUPS_CACHE_RELOAD_COUNT: - return JMX.newMBeanProxy( - mbeanServerConn, - new ObjectName("org.apache.cassandra.metrics:type=CIDRGroupsMappingCache,name=" + metricName), - CassandraMetricsRegistry.JmxCounterMBean.class).getCount(); - case CIDRAuthorizerMetrics.CIDR_GROUPS_CACHE_RELOAD_LATENCY: - case CIDRAuthorizerMetrics.LOOKUP_CIDR_GROUPS_FOR_IP_LATENCY: - return JMX.newMBeanProxy( - mbeanServerConn, - new ObjectName("org.apache.cassandra.metrics:type=CIDRGroupsMappingCache,name=" + metricName), - CassandraMetricsRegistry.JmxTimerMBean.class).getMean(); - default: - if (metricName.contains(CIDRAuthorizerMetrics.CIDR_ACCESSES_REJECTED_COUNT_PREFIX) || - metricName.contains(CIDRAuthorizerMetrics.CIDR_ACCESSES_ACCEPTED_COUNT_PREFIX)) - { - return JMX.newMBeanProxy( - mbeanServerConn, - new ObjectName("org.apache.cassandra.metrics:type=mymetricname,name=" + metricName), - CassandraMetricsRegistry.JmxCounterMBean.class).getCount(); - } - - throw new RuntimeException("Unknown metric " + metricName); - } - } - catch (MalformedObjectNameException e) - { - throw new RuntimeException(e); + case "connections": // List> - list of all native connections and their properties + case "connectedNativeClients": // number of connected native clients + case "connectedNativeClientsByUser": // number of native clients by username + case "clientsByProtocolVersion": // number of native clients by protocol version + return mBeanAccessor.findMBeanGauge(MBeanAccessor.Props.metric("Client", metricName)).getValue(); + default: + throw new RuntimeException("Unknown client metric " + metricName); } } @@ -2336,16 +1999,7 @@ public class NodeProbe implements AutoCloseable */ public long getStorageMetric(String metricName) { - try - { - return JMX.newMBeanProxy(mbeanServerConn, - new ObjectName("org.apache.cassandra.metrics:type=Storage,name=" + metricName), - CassandraMetricsRegistry.JmxCounterMBean.class).getCount(); - } - catch (MalformedObjectNameException e) - { - throw new RuntimeException(e); - } + return mBeanAccessor.findMBeanCounter(MBeanAccessor.Props.metric("Storage", metricName)).getCount(); } public Double[] metricPercentilesAsArray(CassandraMetricsRegistry.JmxHistogramMBean metric) @@ -2407,8 +2061,8 @@ public class NodeProbe implements AutoCloseable BootstrapMonitor monitor = new BootstrapMonitor(out); try { - if (jmxc != null) - jmxc.addConnectionNotificationListener(monitor, null, null); + if (mBeanAccessor instanceof RemoteJmxMBeanAccessor) + ((RemoteJmxMBeanAccessor) mBeanAccessor).getJmxConnector().addConnectionNotificationListener(monitor, null, null); ssProxy.addNotificationListener(monitor, null, null); if (ssProxy.resumeBootstrap()) { @@ -2431,8 +2085,8 @@ public class NodeProbe implements AutoCloseable try { ssProxy.removeNotificationListener(monitor); - if (jmxc != null) - jmxc.removeConnectionNotificationListener(monitor); + if (mBeanAccessor instanceof RemoteJmxMBeanAccessor) + ((RemoteJmxMBeanAccessor) mBeanAccessor).getJmxConnector().removeConnectionNotificationListener(monitor); } catch (Throwable e) { @@ -2806,15 +2460,15 @@ public class NodeProbe implements AutoCloseable } catch (Exception e) { - if (e.getCause() instanceof InstanceNotFoundException) + if (Throwables.getRootCause(e) instanceof InstanceNotFoundException) { String message = String.format("Table %s.%s does not exist or does not support dictionary compression", keyspace, table); - throw new IllegalArgumentException(message); + throw new RuntimeException(message); } else { - throw new IOException(e.getMessage()); + throw e; } } } @@ -2850,80 +2504,102 @@ public class NodeProbe implements AutoCloseable private CompressionDictionaryManagerMBean getDictionaryManagerProxy(String keyspace, String table) throws IOException { - // Construct table-specific MBean name - String mbeanName = CompressionDictionaryManagerMBean.MBEAN_NAME + ",keyspace=" + keyspace + ",table=" + table; - try + return mBeanAccessor.findCompressionDictionary(keyspace, table); + } + + /** + * A dynamic proxy that lazily looks up the MBean the first time a method is invoked. + * @param the MBean interface type. + */ + private static class LazyMBeanProxy implements java.lang.reflect.InvocationHandler + { + private final MBeanAccessor provider; + private final Class mbeanClass; + private volatile T delegate; + + LazyMBeanProxy(MBeanAccessor provider, Class mbeanClass) { - ObjectName objectName = new ObjectName(mbeanName); - return JMX.newMBeanProxy(mbeanServerConn, objectName, CompressionDictionaryManagerMBean.class); + this.provider = provider; + this.mbeanClass = mbeanClass; } - catch (MalformedObjectNameException e) + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { - throw new IOException("Invalid keyspace or table name", e); + if (delegate == null) + { + synchronized (this) + { + if (delegate == null) + delegate = provider.findMBean(mbeanClass); + } + } + + if (delegate == null) + throw new RuntimeException(new InstanceNotFoundException(mbeanClass.getSimpleName() + " is not available on this node")); + + try + { + return method.invoke(delegate, args); + } + catch (InvocationTargetException e) + { + Throwable cause = e.getCause(); + if (cause == null) + throw e; + throw cause; + } + } + + @SuppressWarnings("unchecked") + static T create(MBeanAccessor provider, Class mbeanClass) + { + return (T) Proxy.newProxyInstance(mbeanClass.getClassLoader(), + new Class[]{ mbeanClass }, + new LazyMBeanProxy<>(provider, mbeanClass)); } } } class ColumnFamilyStoreMBeanIterator implements Iterator> { - private MBeanServerConnection mbeanServerConn; Iterator> mbeans; - public ColumnFamilyStoreMBeanIterator(MBeanServerConnection mbeanServerConn) + public ColumnFamilyStoreMBeanIterator(MBeanAccessor accessor) throws MalformedObjectNameException, NullPointerException, IOException { - this.mbeanServerConn = mbeanServerConn; - List> cfMbeans = getCFSMBeans(mbeanServerConn, "ColumnFamilies"); - cfMbeans.addAll(getCFSMBeans(mbeanServerConn, "IndexColumnFamilies")); - Collections.sort(cfMbeans, new Comparator>() - { - public int compare(Entry e1, Entry e2) - { - //compare keyspace, then CF name, then normal vs. index - int keyspaceNameCmp = e1.getKey().compareTo(e2.getKey()); - if(keyspaceNameCmp != 0) - return keyspaceNameCmp; + List> cfMbeans = accessor.findColumnFamilies("ColumnFamilies"); + cfMbeans.addAll(accessor.findColumnFamilies("IndexColumnFamilies")); + cfMbeans.sort((e1, e2) -> { + //compare keyspace, then CF name, then normal vs. index + int keyspaceNameCmp = e1.getKey().compareTo(e2.getKey()); + if (keyspaceNameCmp != 0) + return keyspaceNameCmp; - // get CF name and split it for index name - String e1CF[] = e1.getValue().getTableName().split("\\."); - String e2CF[] = e2.getValue().getTableName().split("\\."); - assert e1CF.length <= 2 && e2CF.length <= 2 : "unexpected split count for table name"; + // get CF name and split it for index name + String e1CF[] = e1.getValue().getTableName().split("\\."); + String e2CF[] = e2.getValue().getTableName().split("\\."); + assert e1CF.length <= 2 && e2CF.length <= 2 : "unexpected split count for table name"; - //if neither are indexes, just compare CF names - if(e1CF.length == 1 && e2CF.length == 1) - return e1CF[0].compareTo(e2CF[0]); + //if neither are indexes, just compare CF names + if (e1CF.length == 1 && e2CF.length == 1) + return e1CF[0].compareTo(e2CF[0]); - //check if it's the same CF - int cfNameCmp = e1CF[0].compareTo(e2CF[0]); - if(cfNameCmp != 0) - return cfNameCmp; + //check if it's the same CF + int cfNameCmp = e1CF[0].compareTo(e2CF[0]); + if (cfNameCmp != 0) + return cfNameCmp; - // if both are indexes (for the same CF), compare them - if(e1CF.length == 2 && e2CF.length == 2) - return e1CF[1].compareTo(e2CF[1]); + // if both are indexes (for the same CF), compare them + if (e1CF.length == 2 && e2CF.length == 2) + return e1CF[1].compareTo(e2CF[1]); - //if length of e1CF is 1, it's not an index, so sort it higher - return e1CF.length == 1 ? 1 : -1; - } + //if length of e1CF is 1, it's not an index, so sort it higher + return e1CF.length == 1 ? 1 : -1; }); mbeans = cfMbeans.iterator(); } - private List> getCFSMBeans(MBeanServerConnection mbeanServerConn, String type) - throws MalformedObjectNameException, IOException - { - ObjectName query = new ObjectName("org.apache.cassandra.db:type=" + type +",*"); - Set cfObjects = mbeanServerConn.queryNames(query, null); - List> mbeans = new ArrayList>(cfObjects.size()); - for(ObjectName n : cfObjects) - { - String keyspaceName = n.getKeyProperty("keyspace"); - ColumnFamilyStoreMBean cfsProxy = JMX.newMBeanProxy(mbeanServerConn, n, ColumnFamilyStoreMBean.class); - mbeans.add(new AbstractMap.SimpleImmutableEntry(keyspaceName, cfsProxy)); - } - return mbeans; - } - public boolean hasNext() { return mbeans.hasNext(); diff --git a/src/java/org/apache/cassandra/tools/NodeTool.java b/src/java/org/apache/cassandra/tools/NodeTool.java index 7f538d57d7..884e783a6b 100644 --- a/src/java/org/apache/cassandra/tools/NodeTool.java +++ b/src/java/org/apache/cassandra/tools/NodeTool.java @@ -39,9 +39,13 @@ import org.apache.cassandra.config.CassandraRelevantProperties; import org.apache.cassandra.exceptions.ConfigurationException; import org.apache.cassandra.io.util.File; import org.apache.cassandra.io.util.FileWriter; +import org.apache.cassandra.tools.nodetool.CqlConnect; import org.apache.cassandra.tools.nodetool.JmxConnect; import org.apache.cassandra.tools.nodetool.NodetoolCommand; import org.apache.cassandra.tools.nodetool.layout.CassandraCliHelpLayout; +import org.apache.cassandra.tools.nodetool.strategy.CommandExecutionStrategy; +import org.apache.cassandra.tools.nodetool.strategy.NodetoolConnectionException; +import org.apache.cassandra.tools.nodetool.strategy.ProtocolAwareExecutionStrategy; import org.apache.cassandra.utils.FBUtilities; import picocli.CommandLine; @@ -104,7 +108,7 @@ public class NodeTool commandLine.setErr(new PrintWriter(output.err, true)); configureCliLayout(commandLine); - commandLine.setExecutionStrategy(JmxConnect::executionStrategy) + commandLine.setExecutionStrategy(ProtocolAwareExecutionStrategy::executionStrategy) .setExecutionExceptionHandler((ex, c, arg) -> { // Used for backward compatibility, some commands are validated when a command is run. if (ex instanceof IllegalArgumentException | @@ -114,7 +118,25 @@ public class NodeTool return 1; } - err(Throwables.getRootCause(ex)); + NodetoolConnectionException connectFailure = Throwables.getCausalChain(ex).stream() + .filter(NodetoolConnectionException.class::isInstance) + .map(NodetoolConnectionException.class::cast) + .findFirst().orElse(null); + if (connectFailure != null) + { + output.err.println("nodetool: " + connectFailure.getMessage()); + return 1; + } + + // CASSANDRA-11537 friendly error message when server is not ready + Throwable root = Throwables.getRootCause(ex); + if (root instanceof InstanceNotFoundException) + { + badUse(new IllegalArgumentException("Server is not initialized yet, cannot run nodetool.")); + return 1; + } + + err(root); return 2; }) .setParameterExceptionHandler((ex, arg) -> { @@ -195,8 +217,18 @@ public class NodeTool public static CommandLine createCommandLine(CommandLine.IFactory factory) throws Exception { - return new CommandLine(new NodetoolCommand(), factory) - .addMixin(JmxConnect.MIXIN_KEY, factory.create(JmxConnect.class)); + CommandLine commandLine = new CommandLine(new NodetoolCommand(), factory); + CommandExecutionStrategy.Type strategyType = ProtocolAwareExecutionStrategy.getExecutionStrategyTypeFromEnvAndSys(); + switch (strategyType) + { + case CQL: + return commandLine.addMixin(strategyType.toString(), factory.create(CqlConnect.class)); + case STATIC_MBEAN: + case COMMAND_MBEAN: + return commandLine.addMixin(strategyType.toString(), factory.create(JmxConnect.class)); + default: + throw new IllegalStateException("Unknown execution strategy: " + strategyType); + } } private static void configureCliLayout(CommandLine commandLine) @@ -229,10 +261,6 @@ public class NodeTool protected void err(Throwable e) { - // CASSANDRA-11537: friendly error message when server is not ready - if (e instanceof InstanceNotFoundException) - throw new IllegalArgumentException("Server is not initialized yet, cannot run nodetool."); - output.err.println("error: " + e.getMessage()); output.err.println("-- StackTrace --"); output.err.println(getStackTraceAsString(e)); diff --git a/src/java/org/apache/cassandra/tools/Output.java b/src/java/org/apache/cassandra/tools/Output.java index eef75766b5..6b521c8a4f 100644 --- a/src/java/org/apache/cassandra/tools/Output.java +++ b/src/java/org/apache/cassandra/tools/Output.java @@ -33,11 +33,21 @@ public class Output this.err = err; } + public void printInfo(String msg) + { + out.println(msg); + } + public void printInfo(String msg, Object... args) { out.printf(msg, args); } + public void printError(String msg) + { + err.println(msg); + } + public void printError(String msg, Object... args) { err.printf(msg, args); diff --git a/src/java/org/apache/cassandra/tools/RemoteJmxMBeanAccessor.java b/src/java/org/apache/cassandra/tools/RemoteJmxMBeanAccessor.java new file mode 100644 index 0000000000..00f9c2e796 --- /dev/null +++ b/src/java/org/apache/cassandra/tools/RemoteJmxMBeanAccessor.java @@ -0,0 +1,479 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.tools; + +import java.io.IOException; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.lang.management.RuntimeMXBean; +import java.rmi.ConnectException; +import java.rmi.server.RMIClientSocketFactory; +import java.rmi.server.RMISocketFactory; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Hashtable; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import javax.management.JMX; +import javax.management.MBeanServerConnection; +import javax.management.MalformedObjectNameException; +import javax.management.ObjectName; +import javax.management.remote.JMXConnector; +import javax.management.remote.JMXConnectorFactory; +import javax.management.remote.JMXServiceURL; +import javax.rmi.ssl.SslRMIClientSocketFactory; + +import com.google.common.base.Throwables; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.cassandra.audit.AuditLogManager; +import org.apache.cassandra.audit.AuditLogManagerMBean; +import org.apache.cassandra.auth.AuthCache; +import org.apache.cassandra.auth.CIDRGroupsMappingManager; +import org.apache.cassandra.auth.CIDRGroupsMappingManagerMBean; +import org.apache.cassandra.auth.CIDRPermissionsManager; +import org.apache.cassandra.auth.CIDRPermissionsManagerMBean; +import org.apache.cassandra.auth.NetworkPermissionsCache; +import org.apache.cassandra.auth.NetworkPermissionsCacheMBean; +import org.apache.cassandra.auth.PasswordAuthenticator; +import org.apache.cassandra.auth.PermissionsCache; +import org.apache.cassandra.auth.PermissionsCacheMBean; +import org.apache.cassandra.auth.RolesCache; +import org.apache.cassandra.auth.RolesCacheMBean; +import org.apache.cassandra.auth.jmx.AuthorizationProxy; +import org.apache.cassandra.batchlog.BatchlogManager; +import org.apache.cassandra.batchlog.BatchlogManagerMBean; +import org.apache.cassandra.db.ColumnFamilyStoreMBean; +import org.apache.cassandra.db.compaction.CompactionManager; +import org.apache.cassandra.db.compaction.CompactionManagerMBean; +import org.apache.cassandra.db.compression.CompressionDictionaryManagerMBean; +import org.apache.cassandra.db.guardrails.Guardrails; +import org.apache.cassandra.db.guardrails.GuardrailsMBean; +import org.apache.cassandra.db.virtual.CIDRFilteringMetricsTable; +import org.apache.cassandra.db.virtual.CIDRFilteringMetricsTableMBean; +import org.apache.cassandra.gms.FailureDetector; +import org.apache.cassandra.gms.FailureDetectorMBean; +import org.apache.cassandra.gms.Gossiper; +import org.apache.cassandra.gms.GossiperMBean; +import org.apache.cassandra.hints.HintsService; +import org.apache.cassandra.hints.HintsServiceMBean; +import org.apache.cassandra.locator.DynamicEndpointSnitchMBean; +import org.apache.cassandra.locator.EndpointSnitchInfoMBean; +import org.apache.cassandra.locator.LocationInfoMBean; +import org.apache.cassandra.management.MBeanAccessor; +import org.apache.cassandra.metrics.CassandraMetricsRegistry; +import org.apache.cassandra.net.MessagingService; +import org.apache.cassandra.net.MessagingServiceMBean; +import org.apache.cassandra.profiler.AsyncProfilerMBean; +import org.apache.cassandra.service.ActiveRepairServiceMBean; +import org.apache.cassandra.service.AutoRepairService; +import org.apache.cassandra.service.AutoRepairServiceMBean; +import org.apache.cassandra.service.CacheService; +import org.apache.cassandra.service.CacheServiceMBean; +import org.apache.cassandra.service.GCInspector; +import org.apache.cassandra.service.GCInspectorMXBean; +import org.apache.cassandra.service.StorageProxy; +import org.apache.cassandra.service.StorageProxyMBean; +import org.apache.cassandra.service.StorageServiceMBean; +import org.apache.cassandra.service.accord.AccordOperations; +import org.apache.cassandra.service.accord.AccordOperationsMBean; +import org.apache.cassandra.service.snapshot.SnapshotManagerMBean; +import org.apache.cassandra.streaming.StreamManagerMBean; +import org.apache.cassandra.tcm.CMSOperations; +import org.apache.cassandra.tcm.CMSOperationsMBean; +import org.apache.cassandra.tools.nodetool.strategy.NodetoolConnectionException; + +import static org.apache.cassandra.config.CassandraRelevantProperties.SSL_ENABLE; + +public class RemoteJmxMBeanAccessor implements MBeanAccessor +{ + private final Map, Object> clazzMBanRegistry = new HashMap<>(); + private final Map namedMBeanRegistry = new ConcurrentHashMap<>(); + + public static final int defaultPort = 7199; + + private static final Logger logger = LoggerFactory.getLogger(RemoteJmxMBeanAccessor.class); + private static final String fmtUrl = "service:jmx:rmi:///jndi/rmi://%s:%d/jmxrmi"; + + final String host; + final int port; + private String username; + private String password; + + protected JMXConnector jmxc; + protected MBeanServerConnection mbeanServerConn; + + private volatile boolean connected = false; + + /** + * Creates a NodeProbe using the specified JMX host, port, username, and password. + * + * @param host hostname or IP address of the JMX agent + * @param port TCP port of the remote JMX agent + */ + public RemoteJmxMBeanAccessor(String host, int port, String username, String password) + { + assert username != null && !username.isEmpty() && password != null && !password.isEmpty() + : "neither username nor password can be blank"; + + this.host = host; + this.port = port; + this.username = username; + this.password = password; + } + + /** + * Creates a NodeProbe using the specified JMX host and port. + * + * @param host hostname or IP address of the JMX agent + * @param port TCP port of the remote JMX agent + */ + public RemoteJmxMBeanAccessor(String host, int port) + { + this.host = host; + this.port = port; + } + + /** + * Creates a NodeProbe using the specified JMX host and default port. + * + * @param host hostname or IP address of the JMX agent + */ + public RemoteJmxMBeanAccessor(String host) + { + this(host, defaultPort); + } + + /** + * Create a connection to the JMX agent and set up the M[X]Bean proxies. + */ + protected void connect() + { + if (connected) + return; + + synchronized (this) + { + if (connected) + return; + + try + { + String host = this.host; + if (host.contains(":")) + { + // Use square brackets to surround IPv6 addresses to fix CASSANDRA-7669 and CASSANDRA-17581 + host = '[' + host + ']'; + } + JMXServiceURL jmxUrl = new JMXServiceURL(String.format(fmtUrl, host, port)); + Map env = new HashMap<>(); + if (username != null) + { + String[] creds = { username, password }; + env.put(JMXConnector.CREDENTIALS, creds); + } + + env.put("com.sun.jndi.rmi.factory.socket", getRMIClientSocketFactory()); + + jmxc = JMXConnectorFactory.connect(jmxUrl, env); + mbeanServerConn = jmxc.getMBeanServerConnection(); + + registerMBeanProxy(StorageServiceMBean.class, "org.apache.cassandra.db:type=StorageService"); + registerMBeanProxy(SnapshotManagerMBean.class, SnapshotManagerMBean.MBEAN_NAME); + registerMBeanProxy(CMSOperationsMBean.class, CMSOperations.MBEAN_OBJECT_NAME); + registerMBeanProxy(AccordOperationsMBean.class, AccordOperations.MBEAN_OBJECT_NAME); + registerMBeanProxy(MessagingServiceMBean.class, MessagingService.MBEAN_NAME); + registerMBeanProxy(StreamManagerMBean.class, StreamManagerMBean.OBJECT_NAME); + registerMBeanProxy(CompactionManagerMBean.class, CompactionManager.MBEAN_OBJECT_NAME); + registerMBeanProxy(FailureDetectorMBean.class, FailureDetector.MBEAN_NAME); + registerMBeanProxy(CacheServiceMBean.class, CacheService.MBEAN_NAME); + registerMBeanProxy(StorageProxyMBean.class, StorageProxy.MBEAN_NAME); + registerMBeanProxy(HintsServiceMBean.class, HintsService.MBEAN_NAME); + registerMBeanProxy(GCInspectorMXBean.class, GCInspector.MBEAN_NAME); + registerMBeanProxy(GossiperMBean.class, Gossiper.MBEAN_NAME); + registerMBeanProxy(BatchlogManagerMBean.class, BatchlogManager.MBEAN_NAME); + registerMBeanProxy(ActiveRepairServiceMBean.class, ActiveRepairServiceMBean.MBEAN_NAME); + registerMBeanProxy(AuditLogManagerMBean.class, AuditLogManager.MBEAN_NAME); + registerMBeanProxy(PasswordAuthenticator.CredentialsCacheMBean.class, + AuthCache.MBEAN_NAME_BASE + PasswordAuthenticator.CredentialsCacheMBean.CACHE_NAME); + registerMBeanProxy(AuthorizationProxy.JmxPermissionsCacheMBean.class, + AuthCache.MBEAN_NAME_BASE + AuthorizationProxy.JmxPermissionsCacheMBean.CACHE_NAME); + registerMBeanProxy(NetworkPermissionsCacheMBean.class, + AuthCache.MBEAN_NAME_BASE + NetworkPermissionsCache.CACHE_NAME); + registerMBeanProxy(PermissionsCacheMBean.class, + AuthCache.MBEAN_NAME_BASE + PermissionsCache.CACHE_NAME); + registerMBeanProxy(RolesCacheMBean.class, + AuthCache.MBEAN_NAME_BASE + RolesCache.CACHE_NAME); + registerMBeanProxy(CIDRPermissionsManagerMBean.class, CIDRPermissionsManager.MBEAN_NAME); + registerMBeanProxy(CIDRGroupsMappingManagerMBean.class, CIDRGroupsMappingManager.MBEAN_NAME); + registerMBeanProxy(CIDRFilteringMetricsTableMBean.class, CIDRFilteringMetricsTable.MBEAN_NAME); + registerMBeanProxy(AutoRepairServiceMBean.class, AutoRepairService.MBEAN_NAME); + registerMBeanProxy(GuardrailsMBean.class, Guardrails.MBEAN_NAME); + + registerPlatformMBeanProxy(MemoryMXBean.class, ManagementFactory.MEMORY_MXBEAN_NAME); + registerPlatformMBeanProxy(RuntimeMXBean.class, ManagementFactory.RUNTIME_MXBEAN_NAME); + + registerMBeanProxy(EndpointSnitchInfoMBean.class, "org.apache.cassandra.db:type=EndpointSnitchInfo"); + registerMBeanProxy(DynamicEndpointSnitchMBean.class, "org.apache.cassandra.db:type=DynamicEndpointSnitch"); + registerMBeanProxy(LocationInfoMBean.class, "org.apache.cassandra.db:type=LocationInfo"); + registerMBeanProxy(AsyncProfilerMBean.class, AsyncProfilerMBean.MBEAN_NAME); + } + catch (MalformedObjectNameException e) + { + close(); + throw new RuntimeException("Invalid ObjectName? Please report this as a bug.", e); + } + catch (IOException | SecurityException e) + { + close(); + Throwable rootCause = Throwables.getRootCause(e); + throw new NodetoolConnectionException(String.format("Failed to connect to '%s:%s' - %s: '%s'.", + host, port, + rootCause.getClass().getSimpleName(), + rootCause.getMessage()), + e); + } + + connected = true; + } + } + + protected void registerMBean(Class clazz, T mbean) + { + clazzMBanRegistry.put(clazz, mbean); + } + + @Override + public T findMBean(Class clazz) + { + connect(); + return clazzMBanRegistry.get(clazz) == null ? null : clazz.cast(clazzMBanRegistry.get(clazz)); + } + + @SuppressWarnings("unchecked") + public T findMBeanMetric(Class clazz, Props props) + { + return withExceptionHandling(() -> { + connect(); + ObjectName objectName = new ObjectName("org.apache.cassandra.metrics", new Hashtable<>(props.toMap())); + T result = (T) namedMBeanRegistry.computeIfAbsent(objectName.getCanonicalName(), + ignore -> JMX.newMBeanProxy(mbeanServerConn, objectName, clazz)); + return clazz.cast(result); + }); + } + + @Override + public boolean isMBeanMetricRegistered(Props props) + { + return withExceptionHandling(() -> { + connect(); + ObjectName objectName = new ObjectName("org.apache.cassandra.metrics", new Hashtable<>(props.toMap())); + return mbeanServerConn.isRegistered(objectName); + }); + } + + @Override + public CassandraMetricsRegistry.JmxCounterMBean findMBeanCounter(Props props) + { + return findMBeanMetric(CassandraMetricsRegistry.JmxCounterMBean.class, props); + } + + @Override + public CassandraMetricsRegistry.JmxGaugeMBean findMBeanGauge(Props props) + { + return findMBeanMetric(CassandraMetricsRegistry.JmxGaugeMBean.class, props); + } + + @Override + public CassandraMetricsRegistry.JmxMeterMBean findMBeanMeter(Props props) + { + return findMBeanMetric(CassandraMetricsRegistry.JmxMeterMBean.class, props); + } + + @Override + public CassandraMetricsRegistry.JmxTimerMBean findMBeanTimer(Props props) + { + return findMBeanMetric(CassandraMetricsRegistry.JmxTimerMBean.class, props); + } + + @Override + public CassandraMetricsRegistry.JmxHistogramMBean findMBeanHistogram(Props props) + { + return findMBeanMetric(CassandraMetricsRegistry.JmxHistogramMBean.class, props); + } + + @Override + public ColumnFamilyStoreMBean findColumnFamily(String type, String keyspace, String columnFamily) + { + return withExceptionHandling(() -> { + connect(); + Set beans = mbeanServerConn.queryNames(new ObjectName("org.apache.cassandra.db:type=*" + type + + ",keyspace=" + keyspace + + ",columnfamily=" + columnFamily), null); + if (beans.isEmpty()) + throw new MalformedObjectNameException("couldn't find that bean"); + + assert beans.size() == 1; + return JMX.newMBeanProxy(mbeanServerConn, beans.iterator().next(), ColumnFamilyStoreMBean.class); + }); + } + + @Override + public CompressionDictionaryManagerMBean findCompressionDictionary(String keyspace, String table) + { + List> keyspaces = findColumnFamilies("ColumnFamilies"); + Optional cfsMBean = keyspaces.stream() + .filter(e -> e.getKey().equals(keyspace)) + .map(Map.Entry::getValue) + .filter(mbean -> mbean.getTableName().equals(table)) + .findAny(); + if (keyspaces.isEmpty() || cfsMBean.isEmpty()) + throw new IllegalArgumentException(String.format("Table %s.%s does not exist", keyspace, table)); + + return withExceptionHandling(() -> { + connect(); + String mbeanName = CompressionDictionaryManagerMBean.MBEAN_NAME + ",keyspace=" + keyspace + ",table=" + table; + if (!mbeanServerConn.isRegistered(new ObjectName(mbeanName))) + throw new IllegalStateException("The compression on table " + keyspace + '.' + table + " is not enabled or SSTable compressor is not a dictionary compressor."); + return JMX.newMBeanProxy(mbeanServerConn, new ObjectName(mbeanName), CompressionDictionaryManagerMBean.class); + }); + } + + @Override + public List threadPoolInfos() + { + return withExceptionHandling(() -> { + connect(); + Set threadPoolObjectNames = mbeanServerConn.queryNames(new ObjectName("org.apache.cassandra.metrics:type=ThreadPools,*"), null); + return threadPoolObjectNames.stream() + .map(oName -> new ThreadPoolInfo(oName.getKeyProperty("path"), oName.getKeyProperty("scope"))) + .collect(Collectors.toList()); + }); + } + + @Override + public List> findColumnFamilies(String type) + { + return withExceptionHandling(() -> { + assert type.equals("IndexColumnFamilies") || type.equals("ColumnFamilies"); + connect(); + + ObjectName query = new ObjectName("org.apache.cassandra.db:type=" + type + ",*"); + Set cfObjects = mbeanServerConn.queryNames(query, null); + + List> mbeans = new ArrayList<>(cfObjects.size()); + for (ObjectName objectName : cfObjects) + { + ColumnFamilyStoreMBean cfsProxy = JMX.newMBeanProxy(mbeanServerConn, objectName, ColumnFamilyStoreMBean.class); + mbeans.add(new AbstractMap.SimpleImmutableEntry<>(objectName.getKeyProperty("keyspace"), cfsProxy)); + } + return mbeans; + }); + } + + public JMXConnector getJmxConnector() + { + connect(); + return jmxc; + } + + public MBeanServerConnection getMBeanServerConnection() + { + connect(); + return mbeanServerConn; + } + + @Override + public void close() + { + if (jmxc == null) + return; + + try + { + jmxc.close(); + } + catch (ConnectException e) + { + // result of stopdaemon command, if close() call fails, the daemon is shutdown + logger.error("Cassandra has shutdown."); + } + catch (IOException e) + { + logger.error("Failed to close connection to '{}:{}'.", host, port, e); + } + finally + { + jmxc = null; + mbeanServerConn = null; + connected = false; + clazzMBanRegistry.clear(); + namedMBeanRegistry.clear(); + } + } + + private void registerMBeanProxy(Class clazz, String objectName) throws MalformedObjectNameException + { + registerMBean(clazz, JMX.newMBeanProxy(mbeanServerConn, new ObjectName(objectName), clazz)); + } + + private void registerPlatformMBeanProxy(Class clazz, String objectName) throws IOException + { + registerMBean(clazz, ManagementFactory.newPlatformMXBeanProxy(mbeanServerConn, objectName, clazz)); + } + + private RMIClientSocketFactory getRMIClientSocketFactory() + { + if (SSL_ENABLE.getBoolean()) + return new SslRMIClientSocketFactory(); + else + return RMISocketFactory.getDefaultSocketFactory(); + } + + private static T withExceptionHandling(MBeanSupplier op) + { + try + { + return op.get(); + } + catch (MalformedObjectNameException e) + { + throw new RuntimeException("Invalid ObjectName? Requested MBean may not exist. " + + "Please check the parameters e.g. keyspace name, table name and try again.", e); + } + catch (IOException e) + { + throw new RuntimeException("Could not connect to MBean server. Please check that the JMX port is correct and open.", e); + } + } + + @FunctionalInterface + private interface MBeanSupplier + { + T get() throws MalformedObjectNameException, IOException; + } +} diff --git a/src/java/org/apache/cassandra/tools/RepairRunner.java b/src/java/org/apache/cassandra/tools/RepairRunner.java index acd784ac60..147888ad53 100644 --- a/src/java/org/apache/cassandra/tools/RepairRunner.java +++ b/src/java/org/apache/cassandra/tools/RepairRunner.java @@ -25,6 +25,7 @@ import java.util.List; import javax.management.ListenerNotFoundException; import javax.management.remote.JMXConnector; +import org.apache.cassandra.management.MBeanAccessor; import org.apache.cassandra.service.ActiveRepairService.ParentRepairStatus; import org.apache.cassandra.service.StorageServiceMBean; import org.apache.cassandra.utils.Closeable; @@ -34,9 +35,9 @@ import org.apache.cassandra.utils.progress.ProgressEventType; import org.apache.cassandra.utils.progress.jmx.JMXNotificationProgressListener; import static java.util.concurrent.TimeUnit.SECONDS; +import static org.apache.cassandra.config.CassandraRelevantProperties.NODETOOL_JMX_NOTIFICATION_POLL_INTERVAL_SECONDS; import static org.apache.cassandra.service.ActiveRepairService.ParentRepairStatus.FAILED; import static org.apache.cassandra.service.ActiveRepairService.ParentRepairStatus.valueOf; -import static org.apache.cassandra.tools.NodeProbe.JMX_NOTIFICATION_POLL_INTERVAL_SECONDS; import static org.apache.cassandra.utils.Clock.Global.currentTimeMillis; import static org.apache.cassandra.utils.LocalizeString.toLowerCaseLocalized; import static org.apache.cassandra.utils.concurrent.Condition.newOneTimeCondition; @@ -46,6 +47,8 @@ import static org.apache.cassandra.utils.progress.ProgressEventType.PROGRESS; public class RepairRunner extends JMXNotificationProgressListener implements Closeable { + private final long JMX_NOTIFICATION_POLL_INTERVAL_SECONDS = NODETOOL_JMX_NOTIFICATION_POLL_INTERVAL_SECONDS.getLong(); + public static abstract class RepairCmd { private final String keyspace; @@ -60,6 +63,7 @@ public class RepairRunner extends JMXNotificationProgressListener implements Clo private final SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss,SSS"); private final PrintStream out; + /** The connector to monitor the JMX connection state, so it can be {@code null} for server-side tools. */ private final JMXConnector jmxc; private final StorageServiceMBean ssProxy; private final Condition condition = newOneTimeCondition(); @@ -68,10 +72,10 @@ public class RepairRunner extends JMXNotificationProgressListener implements Clo private Integer cmd; private volatile Exception error; - public RepairRunner(PrintStream out, JMXConnector jmxc, StorageServiceMBean ssProxy, RepairCmd repairCmd) + public RepairRunner(PrintStream out, MBeanAccessor accessor, StorageServiceMBean ssProxy, RepairCmd repairCmd) { this.out = out; - this.jmxc = jmxc; + this.jmxc = accessor instanceof RemoteJmxMBeanAccessor ? ((RemoteJmxMBeanAccessor) accessor).getJmxConnector() : null; this.ssProxy = ssProxy; this.repairCmd = repairCmd; } diff --git a/src/java/org/apache/cassandra/tools/nodetool/AbstractCommand.java b/src/java/org/apache/cassandra/tools/nodetool/AbstractCommand.java index 5c25307563..3fb6c6b7b5 100644 --- a/src/java/org/apache/cassandra/tools/nodetool/AbstractCommand.java +++ b/src/java/org/apache/cassandra/tools/nodetool/AbstractCommand.java @@ -73,7 +73,7 @@ public abstract class AbstractCommand implements Runnable * @return {@code true} if the command is required to connect to the node, {@code false} otherwise. * @throws ExecutionException if an error occurs during preparation and execution must be aborted. */ - protected boolean shouldConnect() throws ExecutionException + public boolean shouldConnect() throws ExecutionException { return true; } diff --git a/src/java/org/apache/cassandra/tools/nodetool/CMSAdmin.java b/src/java/org/apache/cassandra/tools/nodetool/CMSAdmin.java index 364e5873d4..74d78018d8 100644 --- a/src/java/org/apache/cassandra/tools/nodetool/CMSAdmin.java +++ b/src/java/org/apache/cassandra/tools/nodetool/CMSAdmin.java @@ -303,6 +303,8 @@ public class CMSAdmin extends AbstractCommand @Command(name = "dump", description = "Dumps cluster metadata into a file") public static class DumpClusterMetadata extends AbstractCommand { + // TODO CASSANDRA-XXXXX @ArgGroup cannot be populated by the remote (CQL/MBean) execution path, + // this should be avoided to keep the command hierarchy flat for all interfaces @ArgGroup(exclusive = false, multiplicity = "0..1") DumpOptions dumpOptions; diff --git a/src/java/org/apache/cassandra/tools/nodetool/CompressionDictionaryCommandGroup.java b/src/java/org/apache/cassandra/tools/nodetool/CompressionDictionaryCommandGroup.java index 822663d9f7..beac18adc8 100644 --- a/src/java/org/apache/cassandra/tools/nodetool/CompressionDictionaryCommandGroup.java +++ b/src/java/org/apache/cassandra/tools/nodetool/CompressionDictionaryCommandGroup.java @@ -136,9 +136,10 @@ public class CompressionDictionaryCommandGroup else if (TrainingStatus.FAILED == status) { err.printf("%nTraining failed for %s.%s%n", keyspace, table); + String failureMessage = null; try { - String failureMessage = trainingState.getFailureMessage(); + failureMessage = trainingState.getFailureMessage(); if (failureMessage != null && !failureMessage.isEmpty()) { err.printf("Reason: %s%n", failureMessage); @@ -148,19 +149,23 @@ public class CompressionDictionaryCommandGroup { // If we can't get the failure message, just continue without it } - System.exit(1); + throw new RuntimeException(String.format("Training failed for %s.%s. Reason: %s", + keyspace, table, failureMessage == null ? + "undefined" : failureMessage)); } Uninterruptibles.sleepUninterruptibly(1, TimeUnit.SECONDS); } - err.printf("%nTraining did not complete within expected timeframe (10 minutes).%n"); - System.exit(1); + throw new RuntimeException("Training did not complete within expected timeframe (10 minutes)."); + } + catch (IllegalArgumentException | IllegalStateException e) + { + throw new IllegalArgumentException("Failed to trigger training: " + e.getMessage(), e); } catch (Exception e) { - err.printf("Failed to trigger training: %s%n", e.getMessage()); - System.exit(1); + throw new RuntimeException("Failed to trigger training: " + e.getMessage(), e); } } @@ -187,7 +192,7 @@ public class CompressionDictionaryCommandGroup catch (Throwable t) { err.println("Invalid value for " + MAX_DICT_SIZE_PARAM_NAME + ": " + t.getMessage()); - System.exit(1); + throw t; } } @@ -200,7 +205,7 @@ public class CompressionDictionaryCommandGroup catch (Throwable t) { err.println("Invalid value for " + MAX_TOTAL_SAMPLE_SIZE_PARAM_NAME + ": " + t.getMessage()); - System.exit(1); + throw t; } } } @@ -283,7 +288,7 @@ public class CompressionDictionaryCommandGroup if (dictId <= 0 && dictId != -1) { probe.output().err.printf("Dictionary id has to be strictly positive number.%n"); - System.exit(1); + throw new IllegalArgumentException("Dictionary id has to be strictly positive number."); } try @@ -304,7 +309,9 @@ public class CompressionDictionaryCommandGroup probe.output().err.printf("Dictionary%s does not exist for %s.%s.%n", dictId == -1 ? "" : " with id " + dictId, keyspace, table); - System.exit(1); + throw new IllegalArgumentException(String.format("Dictionary%s does not exist for %s.%s.", + dictId == -1 ? "" : " with id " + dictId, + keyspace, table)); } CompressionDictionaryDataObject dataObject = CompressionDictionaryDetailsTabularData.fromCompositeData(compressionDictionary); @@ -314,7 +321,7 @@ public class CompressionDictionaryCommandGroup catch (Throwable e) { probe.output().err.printf("Failed to export dictionary: %s%n", e.getMessage()); - System.exit(1); + throw new RuntimeException(e); } } } @@ -339,19 +346,22 @@ public class CompressionDictionaryCommandGroup CompositeData compositeData = CompressionDictionaryDetailsTabularData.fromCompressionDictionaryDataObject(dictionaryDataObject); probe.importCompressionDictionary(compositeData); } + catch (IllegalStateException | IllegalArgumentException ex) + { + // These exceptions are threated as the command input was invalid, so we don't need to wrap them. + throw ex; + } catch (ValueInstantiationException ex) { // we catch this when validation of data object fails - that will happen when // JSON is invalid, and we attempt to deserialize it to data object by Jackson // We can fail fast on the client, so we will never reach Cassandra node with a payload // which would fail there too, so we do not contact a node unnecessarily. - probe.output().err.printf("Unable to import dictionary JSON: %s%n", ex.getCause().getMessage()); - System.exit(1); + throw new RuntimeException(String.format("Unable to import dictionary JSON: %s%n", ex.getCause().getMessage()), ex); } catch (Throwable t) { - probe.output().err.printf("Unable to import dictionary JSON: %s%n", t.getMessage()); - System.exit(1); + throw new RuntimeException(String.format("Unable to import dictionary JSON: %s%n", t.getMessage()), t); } } @@ -360,17 +370,17 @@ public class CompressionDictionaryCommandGroup if (!dictionaryFile.exists()) { probe.output().err.printf("Path %s does not exist.%n", dictionaryPath); - System.exit(1); + throw new IllegalArgumentException("Path " + dictionaryPath + " does not exist."); } if (!dictionaryFile.isFile()) { probe.output().err.printf("Path %s is not a file.%n", dictionaryPath); - System.exit(1); + throw new IllegalArgumentException("Path " + dictionaryPath + " is not a file."); } if (!dictionaryFile.isReadable()) { probe.output().err.printf("Path %s is not readable.%n", dictionaryPath); - System.exit(1); + throw new IllegalArgumentException("Path " + dictionaryPath + " is not readable."); } } } @@ -431,8 +441,7 @@ public class CompressionDictionaryCommandGroup } catch (Exception e) { - probe.output().err.printf("Failed to list dictionaries: %s%n", e.getMessage()); - System.exit(1); + throw new RuntimeException("Failed to list dictionaries: " + e.getMessage(), e); } } } diff --git a/src/java/org/apache/cassandra/tools/nodetool/CqlConnect.java b/src/java/org/apache/cassandra/tools/nodetool/CqlConnect.java new file mode 100644 index 0000000000..af01de59dc --- /dev/null +++ b/src/java/org/apache/cassandra/tools/nodetool/CqlConnect.java @@ -0,0 +1,141 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.tools.nodetool; + +import java.io.IOException; + +import com.google.common.base.Throwables; + +import org.apache.cassandra.config.CassandraRelevantEnv; +import org.apache.cassandra.config.CassandraRelevantProperties; +import org.apache.cassandra.tools.NodeProbe; +import org.apache.cassandra.tools.nodetool.strategy.NodetoolConnectionException; +import org.apache.cassandra.transport.ProtocolVersion; +import org.apache.cassandra.transport.SimpleClient; + +import picocli.CommandLine.Command; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.Option; +import picocli.CommandLine.Spec; + +/** + * Command options for NodeTool commands that are executed via CQL. + */ +@Command(name = "cqlconnect", description = "Connect to a Cassandra node via CQL") +public class CqlConnect extends AbstractCommand implements AutoCloseable +{ + private static final int DEFAULT_CQL_PORT = 11211; + + /** The command specification, used to access command-specific properties. */ + @Spec + protected CommandSpec spec; // injected by picocli + + @Option(names = { "-h", "--host" }, description = "Node hostname or ip address", arity = "0..1") + private String host = "127.0.0.1"; + + @Option(names = { "-p", "--port" }, description = "Remote CQL native transport port number", arity = "0..1") + private int port = DEFAULT_CQL_PORT; + + @Option(names = { "--diagnostic" }, description = "Enable diagnostic output for troubleshooting connection issues") + private boolean diagnostic = false; + + private volatile SimpleClient client; + + /** + * Initialize the CQL connection to the Cassandra node using the provided options. + */ + public void run() + { + if (client != null) + return; + + try + { + if (diagnostic) + output.printInfo("Connecting to %s:%s via CQL...%n", host, port); + + SimpleClient.Builder builder = SimpleClient.builder(host, port) + .protocolVersion(ProtocolVersion.V5) + .requestTimeoutSeconds(requestTimeoutSeconds()); + client = builder.build(); + client.connect(false); + } + catch (IOException e) + { + Throwable rootCause = Throwables.getRootCause(e); + throw new NodetoolConnectionException(String.format("Failed to connect to '%s:%s' via CQL - %s: '%s'.", + host, port, + rootCause.getClass().getSimpleName(), + rootCause.getMessage()), + e); + } + } + + public SimpleClient client() + { + return client; + } + + /** + * Long-running commands are executed asynchronously by default, but some commands (e.g. info) + * are still synchronous and may need longer than {@link SimpleClient#TIMEOUT_SECONDS} to + * respond; {@code 0} waits indefinitely. + */ + private static long requestTimeoutSeconds() + { + String env = CassandraRelevantEnv.CASSANDRA_CLI_EXECUTION_TIMEOUT_SECONDS.getString(); + if (env == null) + return CassandraRelevantProperties.CASSANDRA_CLI_EXECUTION_TIMEOUT_SECONDS.getLong(); + + try + { + return Long.parseLong(env); + } + catch (NumberFormatException e) + { + throw new IllegalArgumentException(String.format("Invalid value for environment variable '%s': " + + "expected a number of seconds but was '%s'", + CassandraRelevantEnv.CASSANDRA_CLI_EXECUTION_TIMEOUT_SECONDS.getKey(), + env)); + } + } + + @Override + protected void execute(NodeProbe probe) + { + assert probe == null; + run(); + } + + @Override + public void close() throws Exception + { + if (client != null) + { + try + { + client.close(); + } + finally + { + client = null; + } + } + } +} diff --git a/src/java/org/apache/cassandra/tools/nodetool/ForceCompact.java b/src/java/org/apache/cassandra/tools/nodetool/ForceCompact.java index 3e83ff2e06..431bb94266 100644 --- a/src/java/org/apache/cassandra/tools/nodetool/ForceCompact.java +++ b/src/java/org/apache/cassandra/tools/nodetool/ForceCompact.java @@ -61,6 +61,7 @@ public class ForceCompact extends AbstractCommand try { probe.forceCompactionKeysIgnoringGcGrace(keyspaceName, tableName, partitionKeysIgnoreGcGrace); + probe.output().printInfo("Force compaction performed for keyspace '%s', table '%s'", keyspaceName, tableName); } catch (Exception e) { diff --git a/src/java/org/apache/cassandra/tools/nodetool/History.java b/src/java/org/apache/cassandra/tools/nodetool/History.java index 938eb8fb17..9ff69d5f88 100644 --- a/src/java/org/apache/cassandra/tools/nodetool/History.java +++ b/src/java/org/apache/cassandra/tools/nodetool/History.java @@ -51,7 +51,7 @@ public class History extends AbstractCommand implements LocalCommand } @Override - protected boolean shouldConnect() throws CommandLine.ExecutionException + public boolean shouldConnect() throws CommandLine.ExecutionException { return false; } diff --git a/src/java/org/apache/cassandra/tools/nodetool/Info.java b/src/java/org/apache/cassandra/tools/nodetool/Info.java index b0b2dff019..72d14bdbf8 100644 --- a/src/java/org/apache/cassandra/tools/nodetool/Info.java +++ b/src/java/org/apache/cassandra/tools/nodetool/Info.java @@ -26,6 +26,8 @@ import java.util.Map.Entry; import javax.management.InstanceNotFoundException; +import com.google.common.base.Throwables; + import org.apache.cassandra.db.ColumnFamilyStoreMBean; import org.apache.cassandra.io.util.FileUtils; import org.apache.cassandra.service.CacheServiceMBean; @@ -73,7 +75,7 @@ public class Info extends AbstractCommand catch (RuntimeException e) { // offheap-metrics introduced in 2.1.3 - older versions do not have the appropriate mbeans - if (!(e.getCause() instanceof InstanceNotFoundException)) + if (!(Throwables.getRootCause(e) instanceof InstanceNotFoundException)) throw e; } @@ -135,7 +137,7 @@ public class Info extends AbstractCommand } catch (RuntimeException e) { - if (!(e.getCause() instanceof InstanceNotFoundException)) + if (!(Throwables.getRootCause(e) instanceof InstanceNotFoundException)) throw e; // Chunk cache is not on. @@ -151,7 +153,7 @@ public class Info extends AbstractCommand } catch (RuntimeException e) { - if (!(e.getCause() instanceof InstanceNotFoundException)) + if (!(Throwables.getRootCause(e) instanceof InstanceNotFoundException)) throw e; // network cache is not on. diff --git a/src/java/org/apache/cassandra/tools/nodetool/JmxConnect.java b/src/java/org/apache/cassandra/tools/nodetool/JmxConnect.java index 4fec76e348..e010a61719 100644 --- a/src/java/org/apache/cassandra/tools/nodetool/JmxConnect.java +++ b/src/java/org/apache/cassandra/tools/nodetool/JmxConnect.java @@ -22,7 +22,6 @@ import java.io.Console; import java.io.FileNotFoundException; import java.io.IOException; import java.io.UncheckedIOException; -import java.util.List; import java.util.Scanner; import javax.inject.Inject; @@ -32,21 +31,15 @@ import com.google.common.base.Throwables; import org.apache.cassandra.io.util.File; import org.apache.cassandra.tools.INodeProbeFactory; import org.apache.cassandra.tools.NodeProbe; +import org.apache.cassandra.tools.nodetool.strategy.NodetoolConnectionException; -import picocli.CommandLine; import picocli.CommandLine.Command; -import picocli.CommandLine.ExecutionException; -import picocli.CommandLine.IExecutionStrategy; -import picocli.CommandLine.InitializationException; import picocli.CommandLine.Model.CommandSpec; import picocli.CommandLine.Option; -import picocli.CommandLine.ParameterException; -import picocli.CommandLine.ParseResult; -import picocli.CommandLine.RunLast; import picocli.CommandLine.Spec; import static java.lang.Integer.parseInt; -import static org.apache.cassandra.tools.NodeProbe.defaultPort; +import static org.apache.cassandra.tools.RemoteJmxMBeanAccessor.defaultPort; import static org.apache.commons.lang3.StringUtils.EMPTY; import static org.apache.commons.lang3.StringUtils.isEmpty; import static org.apache.commons.lang3.StringUtils.isNotEmpty; @@ -57,8 +50,6 @@ import static org.apache.commons.lang3.StringUtils.isNotEmpty; @Command(name = "connect", description = "Connect to a Cassandra node via JMX") public class JmxConnect extends AbstractCommand implements AutoCloseable { - public static final String MIXIN_KEY = "jmx"; - /** The command specification, used to access command-specific properties. */ @Spec protected CommandSpec spec; // injected by picocli @@ -81,30 +72,6 @@ public class JmxConnect extends AbstractCommand implements AutoCloseable @Inject private INodeProbeFactory nodeProbeFactory; - /** - * This method is called by picocli and used depending on the execution strategy. - * @param parseResult The parsed command line. - * @return The exit code. - */ - public static int executionStrategy(ParseResult parseResult) - { - CommandSpec jmx = parseResult.commandSpec().mixins().get(MIXIN_KEY); - if (jmx == null) - throw new InitializationException("No JmxConnect command found in the top-level hierarchy"); - - try (JmxConnectionCommandInvoker invoker = new JmxConnectionCommandInvoker((JmxConnect) jmx.userObject())) - { - return invoker.execute(parseResult); - } - catch (JmxConnectionCommandInvoker.CloseException e) - { - jmx.commandLine() - .getErr() - .println("Failed to connect to JMX: " + e.getMessage()); - return jmx.commandLine().getExitCodeExceptionMapper().getExitCode(e); - } - } - /** * Initialize the JMX connection to the Cassandra node using the provided options. */ @@ -129,9 +96,11 @@ public class JmxConnect extends AbstractCommand implements AutoCloseable catch (IOException | SecurityException e) { Throwable rootCause = Throwables.getRootCause(e); - output.printError("nodetool: Failed to connect to '%s:%s' - %s: '%s'.%n", host, port, - rootCause.getClass().getSimpleName(), rootCause.getMessage()); - throw new InitializationException("Failed to connect to JMX", e); + throw new NodetoolConnectionException(String.format("Failed to connect to '%s:%s' - %s: '%s'.", + host, port, + rootCause.getClass().getSimpleName(), + rootCause.getMessage()), + e); } } @@ -183,61 +152,13 @@ public class JmxConnect extends AbstractCommand implements AutoCloseable return password; } - private static class JmxConnectionCommandInvoker implements IExecutionStrategy, AutoCloseable + public String getHost() { - private final JmxConnect connect; + return host; + } - public JmxConnectionCommandInvoker(JmxConnect connect) - { - this.connect = connect; - } - - @Override - public int execute(ParseResult parseResult) throws ExecutionException, ParameterException - { - CommandSpec lastParent = lastExecutableSubcommandWithSameParent(parseResult.asCommandLineList()); - if (lastParent.userObject() instanceof AbstractCommand) - { - AbstractCommand command = (AbstractCommand) lastParent.userObject(); - if (command.shouldConnect()) - connect.run(); - command.probe(connect.probe()); - } - return new RunLast().execute(parseResult); - } - - @Override - public void close() throws CloseException - { - try - { - if (connect.probe() != null) - ((AutoCloseable) connect.probe()).close(); - } - catch (Exception e) - { - throw new CloseException("Failed to close JMX connection", e); - } - } - - private static CommandLine.Model.CommandSpec lastExecutableSubcommandWithSameParent(List parsedCommands) - { - int start = parsedCommands.size() - 1; - for (int i = parsedCommands.size() - 2; i >= 0; i--) - { - if (parsedCommands.get(i).getParent() != parsedCommands.get(i + 1).getParent()) - break; - start = i; - } - return parsedCommands.get(start).getCommandSpec(); - } - - private static class CloseException extends RuntimeException - { - public CloseException(String message, Throwable cause) - { - super(message, cause); - } - } + public String getPort() + { + return port; } } diff --git a/src/java/org/apache/cassandra/tools/nodetool/Sjk.java b/src/java/org/apache/cassandra/tools/nodetool/Sjk.java index f9d19eabd2..4df981ea9d 100644 --- a/src/java/org/apache/cassandra/tools/nodetool/Sjk.java +++ b/src/java/org/apache/cassandra/tools/nodetool/Sjk.java @@ -19,6 +19,7 @@ package org.apache.cassandra.tools.nodetool; import java.io.IOException; import java.io.PrintStream; +import java.lang.management.ManagementFactory; import java.lang.reflect.Field; import java.net.URL; import java.net.URLDecoder; @@ -47,6 +48,7 @@ import org.gridkit.jvmtool.cli.CommandLauncher; import org.apache.cassandra.io.util.File; import org.apache.cassandra.tools.NodeProbe; import org.apache.cassandra.tools.Output; +import org.apache.cassandra.tools.RemoteJmxMBeanAccessor; import picocli.CommandLine; import picocli.CommandLine.Command; @@ -64,7 +66,7 @@ public class Sjk extends AbstractCommand private final Wrapper wrapper = new Wrapper(); @Override - protected boolean shouldConnect() throws ExecutionException + public boolean shouldConnect() throws ExecutionException { // We want to parse the given arguments in advance to determine if the SJK command requires an MBeanServerConnection or not. wrapper.prepare(args.isEmpty() ? new String[]{ "--help" } : args.toArray(new String[0]), output.out, output.err); @@ -243,7 +245,9 @@ public class Sjk extends AbstractCommand { public MBeanServerConnection getMServer() { - return probe.getMbeanServerConn(); + return probe.getMBeanAccessor() instanceof RemoteJmxMBeanAccessor ? + ((RemoteJmxMBeanAccessor) probe.getMBeanAccessor()).getMBeanServerConnection() : + ManagementFactory.getPlatformMBeanServer(); } }); } diff --git a/src/java/org/apache/cassandra/tools/nodetool/StopDaemon.java b/src/java/org/apache/cassandra/tools/nodetool/StopDaemon.java index f86b3f7505..1b60d6ee4c 100644 --- a/src/java/org/apache/cassandra/tools/nodetool/StopDaemon.java +++ b/src/java/org/apache/cassandra/tools/nodetool/StopDaemon.java @@ -18,8 +18,11 @@ package org.apache.cassandra.tools.nodetool; +import com.google.common.base.Throwables; + import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.tools.NodeProbe; +import org.apache.cassandra.tools.nodetool.strategy.NodetoolConnectionException; import org.apache.cassandra.utils.JVMStabilityInspector; import picocli.CommandLine.Command; @@ -37,6 +40,12 @@ public class StopDaemon extends AbstractCommand } catch (Exception e) { JVMStabilityInspector.inspectThrowable(e); + // The connection dropping while the daemon stops is expected and ignored. + if (Throwables.getCausalChain(e).stream().anyMatch(NodetoolConnectionException.class::isInstance)) + { + Throwables.throwIfUnchecked(e); + throw new RuntimeException(e); + } // ignored } } diff --git a/src/java/org/apache/cassandra/tools/nodetool/stats/TableStatsHolder.java b/src/java/org/apache/cassandra/tools/nodetool/stats/TableStatsHolder.java index ab9fa5b1fa..5ec76583a6 100644 --- a/src/java/org/apache/cassandra/tools/nodetool/stats/TableStatsHolder.java +++ b/src/java/org/apache/cassandra/tools/nodetool/stats/TableStatsHolder.java @@ -33,6 +33,7 @@ import java.util.TimeZone; import javax.management.InstanceNotFoundException; +import com.google.common.base.Throwables; import com.google.common.collect.ArrayListMultimap; import org.apache.commons.lang3.time.DurationFormatUtils; @@ -341,7 +342,7 @@ public class TableStatsHolder implements StatsHolder catch (RuntimeException e) { // offheap-metrics introduced in 2.1.3 - older versions do not have the appropriate mbeans - if (!(e.getCause() instanceof InstanceNotFoundException)) + if (!(Throwables.getRootCause(e) instanceof InstanceNotFoundException)) throw e; } diff --git a/src/java/org/apache/cassandra/tools/nodetool/strategy/CommandExecutionStrategy.java b/src/java/org/apache/cassandra/tools/nodetool/strategy/CommandExecutionStrategy.java new file mode 100644 index 0000000000..60d2b6bbc4 --- /dev/null +++ b/src/java/org/apache/cassandra/tools/nodetool/strategy/CommandExecutionStrategy.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.tools.nodetool.strategy; + +import picocli.CommandLine; + +public interface CommandExecutionStrategy extends CommandLine.IExecutionStrategy, AutoCloseable +{ + @Override + default void close() throws ExecutionStrategyCloseException {} + + class ExecutionStrategyCloseException extends RuntimeException + { + public ExecutionStrategyCloseException(String message, Throwable cause) + { + super(message, cause); + } + } + + enum Type + { + CQL, + STATIC_MBEAN, + COMMAND_MBEAN, + } +} diff --git a/src/java/org/apache/cassandra/tools/nodetool/strategy/CommandMBeanExecutionStrategy.java b/src/java/org/apache/cassandra/tools/nodetool/strategy/CommandMBeanExecutionStrategy.java new file mode 100644 index 0000000000..e9243ae763 --- /dev/null +++ b/src/java/org/apache/cassandra/tools/nodetool/strategy/CommandMBeanExecutionStrategy.java @@ -0,0 +1,205 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.tools.nodetool.strategy; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import javax.management.MBeanException; +import javax.management.MBeanServerConnection; +import javax.management.ObjectName; +import javax.management.ReflectionException; +import javax.management.RuntimeMBeanException; + +import com.google.common.base.Strings; + +import org.apache.cassandra.config.CassandraRelevantEnv; +import org.apache.cassandra.cql3.statements.ExecuteCommandStatement; +import org.apache.cassandra.management.CommandExecutionArgsSerde; +import org.apache.cassandra.management.CommandMBeanAdapter; +import org.apache.cassandra.management.api.CommandExecutionArgs; +import org.apache.cassandra.management.picocli.PicocliCommandArgsConverter; +import org.apache.cassandra.tools.NodeProbe; +import org.apache.cassandra.tools.RemoteJmxMBeanAccessor; +import org.apache.cassandra.tools.nodetool.AbstractCommand; +import org.apache.cassandra.tools.nodetool.JmxConnect; +import org.apache.cassandra.tools.nodetool.StopDaemon; + +import picocli.CommandLine; + +import static org.apache.cassandra.config.CassandraRelevantProperties.CASSANDRA_CLI_EXECUTION_SHOW_EXECUTION_ID; +import static org.apache.cassandra.management.ManagementUtils.fullCommandName; +import static org.apache.cassandra.utils.JsonUtils.fromJsonMap; + +public class CommandMBeanExecutionStrategy implements CommandExecutionStrategy +{ + private static final String MBEAN_DOMAIN = "org.apache.cassandra.management"; + private static final String MBEAN_TYPE_COMMAND = "Command"; + + private final JmxConnect connect; + + public CommandMBeanExecutionStrategy(JmxConnect connect) + { + this.connect = connect; + } + + @Override + public int execute(CommandLine.ParseResult parseResult) throws CommandLine.ExecutionException, CommandLine.ParameterException + { + CommandLine.Model.CommandSpec lastParent = StaticMBeanExecutionStrategy.lastExecutableSubcommandWithSameParent(parseResult.asCommandLineList()); + + NodeProbe probe = null; + Object userObject = lastParent.userObject(); + + if (userObject instanceof AbstractCommand) + { + AbstractCommand command = (AbstractCommand) userObject; + if (command.shouldConnect()) + connect.run(); + probe = connect.probe(); + } + + // Local command execution with no JMX connection. + if (probe == null || parseResult.isUsageHelpRequested() || parseResult.isVersionHelpRequested()) + return new CommandLine.RunLast().execute(parseResult); + + String commandName = extractCommandName(parseResult); + if (commandName == null || commandName.isEmpty()) + return new CommandLine.RunLast().execute(parseResult); + + // Command is already populated with args from CommandLine.parseArgs(), convert to CommandExecutionArgs + CommandExecutionArgs args = PicocliCommandArgsConverter.fromCommand(userObject); + String jsonParams = CommandExecutionArgsSerde.toJson(args); + + try + { + MBeanServerConnection mbs = ((RemoteJmxMBeanAccessor) probe.getMBeanAccessor()).getMBeanServerConnection(); + + if (!mbs.isRegistered(constructCommandMBeanName(commandName))) + return new CommandLine.RunLast().execute(parseResult); + + String rawResult = executeViaRemoteMBean(mbs, commandName, jsonParams); + + Map commandResult = fromJsonMap(rawResult); + + String executionId = commandResult.get(ExecuteCommandStatement.COMMAND_RESULT_SCHEMA_EXECUTION_ID); + String output = commandResult.get(ExecuteCommandStatement.COMMAND_RESULT_SCHEMA_OUTPUT); + + if (Strings.isNullOrEmpty(executionId) || output == null) + throw new RuntimeException("Invalid command result schema received from CommandMBeanAdapter: " + rawResult); + + probe.output().printInfo(output); + // The output needs to be consistent for the tests we already have and for thouse which heavily rely on + // asserting command output, so we use this property to control whether the execution id is printed or not. + if (CASSANDRA_CLI_EXECUTION_SHOW_EXECUTION_ID.getBoolean() || + CassandraRelevantEnv.CASSANDRA_CLI_EXECUTION_SHOW_EXECUTION_ID.getBoolean()) + probe.output().printInfo("Command execution id: " + executionId); + return 0; + } + catch (NodetoolConnectionException e) + { + throw e; + } + catch (RuntimeMBeanException e) + { + Throwable cause = e.getCause(); + if (cause instanceof IllegalArgumentException || cause instanceof IllegalStateException) + { + throw new CommandLine.ParameterException(parseResult.commandSpec().commandLine(), + "Invalid command parameters: " + cause.getMessage(), cause); + } + else + { + // Include security exception, instance not found, invocation target exceptions. + throw new CommandLine.ExecutionException(parseResult.commandSpec().commandLine(), + "Failed to execute command via MBean: " + cause.getMessage(), cause); + } + } + catch (IOException e) + { + if (userObject instanceof StopDaemon) + { + probe.output().printInfo("Cassandra has shutdown."); + return 0; + } + throw new CommandLine.ExecutionException(parseResult.commandSpec().commandLine(), + String.format("JMX connection to the node was lost while executing command '%s'. " + + "The command may still be running on the server.", + commandName), + e); + } + catch (Exception e) + { + throw new CommandLine.ExecutionException(parseResult.commandSpec().commandLine(), + "Unexpected error during command execution: " + e.getMessage(), e); + } + } + + @Override + public void close() throws ExecutionStrategyCloseException + { + if (connect.probe() == null) + return; + try + { + connect.probe().close(); + } + catch (Exception e) + { + throw new ExecutionStrategyCloseException("Failed to close JMX connection", e); + } + } + + /** Handles nested commands (e.g. compressiondictionary.train). */ + public static String extractCommandName(CommandLine.ParseResult parseResult) + { + List commandLineList = parseResult.asCommandLineList(); + if (commandLineList.size() <= 1) // Only root "nodetool" + return null; + + // Skip the root command ("nodetool") and concatenate the rest to form the full command name. + return fullCommandName(commandLineList.subList(1, commandLineList.size()), CommandLine::getCommandName); + } + + private String executeViaRemoteMBean(MBeanServerConnection mbs, String commandName, String jsonParams) throws Exception + { + ObjectName mbeanName = constructCommandMBeanName(commandName); + + try + { + Object result = mbs.invoke(mbeanName, CommandMBeanAdapter.INVOKE_METHOD, + new Object[]{ jsonParams }, + new String[]{ String.class.getName() }); + + return result == null ? "" : result.toString(); + } + catch (MBeanException | ReflectionException e) + { + throw new RuntimeMBeanException(new RuntimeException(e), "Failed to invoke CommandMBeanAdapter: " + commandName); + } + } + + private ObjectName constructCommandMBeanName(String commandName) throws Exception + { + String escapedName = ObjectName.quote(commandName); + String objectNameStr = String.format("%s:type=%s,name=%s", MBEAN_DOMAIN, MBEAN_TYPE_COMMAND, escapedName); + return new ObjectName(objectNameStr); + } +} diff --git a/src/java/org/apache/cassandra/tools/nodetool/strategy/CqlCommandExecutionStrategy.java b/src/java/org/apache/cassandra/tools/nodetool/strategy/CqlCommandExecutionStrategy.java new file mode 100644 index 0000000000..627a4e1f98 --- /dev/null +++ b/src/java/org/apache/cassandra/tools/nodetool/strategy/CqlCommandExecutionStrategy.java @@ -0,0 +1,330 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.tools.nodetool.strategy; + +import java.lang.reflect.Array; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import com.google.common.annotations.VisibleForTesting; + +import org.apache.cassandra.config.CassandraRelevantEnv; +import org.apache.cassandra.config.CassandraRelevantProperties; +import org.apache.cassandra.cql3.ColumnIdentifier; +import org.apache.cassandra.cql3.CqlBuilder; +import org.apache.cassandra.db.ConsistencyLevel; +import org.apache.cassandra.db.marshal.ByteArrayAccessor; +import org.apache.cassandra.db.marshal.UTF8Type; +import org.apache.cassandra.db.marshal.UUIDType; +import org.apache.cassandra.exceptions.CommandRequestExecutionException; +import org.apache.cassandra.exceptions.InvalidRequestException; +import org.apache.cassandra.exceptions.UnauthorizedException; +import org.apache.cassandra.management.api.CommandExecutionArgs; +import org.apache.cassandra.management.api.OptionMetadata; +import org.apache.cassandra.management.api.ParameterMetadata; +import org.apache.cassandra.management.picocli.PicocliCommandArgsConverter; +import org.apache.cassandra.tools.nodetool.AbstractCommand; +import org.apache.cassandra.tools.nodetool.CqlConnect; +import org.apache.cassandra.tools.nodetool.StopDaemon; +import org.apache.cassandra.transport.SimpleClient; +import org.apache.cassandra.transport.messages.ResultMessage; + +import picocli.CommandLine; + +import static org.apache.cassandra.config.CassandraRelevantProperties.CASSANDRA_CLI_EXECUTION_SHOW_EXECUTION_ID; +import static org.apache.cassandra.management.ManagementUtils.normalizeOptionName; +import static org.apache.cassandra.management.api.ParameterMetadata.COMMAND_POSITIONAL_PARAM_PREFIX; +import static org.apache.cassandra.tools.nodetool.strategy.CommandMBeanExecutionStrategy.extractCommandName; + +public class CqlCommandExecutionStrategy implements CommandExecutionStrategy +{ + private final CqlConnect connect; + + public CqlCommandExecutionStrategy(CqlConnect connect) + { + this.connect = connect; + } + + @Override + public int execute(CommandLine.ParseResult parseResult) throws CommandLine.ExecutionException, CommandLine.ParameterException + { + CommandLine.Model.CommandSpec lastParent = StaticMBeanExecutionStrategy.lastExecutableSubcommandWithSameParent(parseResult.asCommandLineList()); + + Object userObject = lastParent.userObject(); + + if (userObject instanceof AbstractCommand) + { + AbstractCommand command = (AbstractCommand) userObject; + if (command.shouldConnect()) + connect.run(); + } + + // Local command execution with no CQL connection. + if (connect.client() == null || parseResult.isUsageHelpRequested() || parseResult.isVersionHelpRequested()) + return new CommandLine.RunLast().execute(parseResult); + + String commandName = extractCommandName(parseResult); + if (commandName == null || commandName.isEmpty()) + return new CommandLine.RunLast().execute(parseResult); + + // Command is already populated with args from CommandLine.parseArgs(), converting it to CommandExecutionArgs + CommandExecutionArgs args = PicocliCommandArgsConverter.fromCommand(userObject); + + try + { + String cqlCommand = buildCqlCommandString(commandName, args); + ResultMessage result = connect.client().execute(cqlCommand, ConsistencyLevel.ONE); + + if (result instanceof ResultMessage.Rows) + { + ResultMessage.Rows rows = (ResultMessage.Rows) result; + assert rows.result.size() == 1 : "Command execution result should have exactly 1 row, got " + rows.result.size(); + assert rows.result.metadata.getColumnCount() == 2 : "Command execution result schema has been changed. " + + "Expected 2 columns in result, got " + rows.result.metadata.getColumnCount(); + + if (!rows.result.isEmpty() && rows.result.metadata.names.size() >= 2) + { + List firstRow = rows.result.rows.get(0); + if (firstRow.size() >= 2) + { + UUID executionId = UUIDType.instance.getSerializer().deserialize(firstRow.get(0), ByteArrayAccessor.instance); + String output = UTF8Type.instance.getSerializer().deserialize(firstRow.get(1), ByteArrayAccessor.instance); + // NodeProbe instance is not available here, so print directly to the command output. + parseResult.commandSpec().commandLine().getOut().println(output); + if (CASSANDRA_CLI_EXECUTION_SHOW_EXECUTION_ID.getBoolean() + || CassandraRelevantEnv.CASSANDRA_CLI_EXECUTION_SHOW_EXECUTION_ID.getBoolean()) + parseResult.commandSpec().commandLine().getOut().println("Command execution id: " + executionId.toString()); + } + } + } + + return 0; + } + catch (SimpleClient.ConnectionClosedException e) + { + if (userObject instanceof StopDaemon) + { + parseResult.commandSpec().commandLine().getOut().println("Cassandra has shutdown."); + return 0; + } + throw new CommandLine.ExecutionException(parseResult.commandSpec().commandLine(), + String.format("Connection to the node was closed before a response " + + "was received for command '%s'. " + + "The command may still be running on the server.", + commandName), + e); + } + catch (SimpleClient.TimeoutException e) + { + throw new CommandLine.ExecutionException(parseResult.commandSpec().commandLine(), + String.format("No response received for command '%s' within the client timeout. " + + "The command may still be running on the server. " + + "Increase the timeout via the %s environment variable " + + "or the %s system property (0 waits indefinitely). %s", + commandName, + CassandraRelevantEnv.CASSANDRA_CLI_EXECUTION_TIMEOUT_SECONDS.getKey(), + CassandraRelevantProperties.CASSANDRA_CLI_EXECUTION_TIMEOUT_SECONDS.getKey(), + e.getMessage()), + e); + } + catch (RuntimeException e) + { + Throwable cause = e.getCause(); + if (cause instanceof UnauthorizedException) + { + throw new CommandLine.ExecutionException(parseResult.commandSpec().commandLine(), + "Unauthorized: " + cause.getMessage()); + } + else if (cause instanceof InvalidRequestException) + { + // CQL invalid request (e.g. validating params and/or param values) translates into picocli param exection. + throw new CommandLine.ParameterException(parseResult.commandSpec().commandLine(), + "Invalid request: " + cause.getMessage()); + } + else if (cause instanceof CommandRequestExecutionException) + { + CommandRequestExecutionException cree = (CommandRequestExecutionException) cause; + String msg = String.format("Command execution failed (executionId: %s): %s", + cree.getCommandExecutionId(), cree.getMessage()); + throw new CommandLine.ExecutionException(parseResult.commandSpec().commandLine(), msg); + } + throw new CommandLine.ExecutionException(parseResult.commandSpec().commandLine(), + "Unknown command execution exception via CQL: " + e.getMessage(), e); + } + catch (Exception e) { + // Catch-all for checked exceptions thrown during command execution. + throw new CommandLine.ExecutionException(parseResult.commandSpec().commandLine(), + "Unknown command execution exception via CQL: " + e.getMessage(), e); + } + } + + @Override + public void close() throws ExecutionStrategyCloseException + { + if (connect == null) + return; + try + { + connect.close(); + } + catch (Exception e) + { + throw new ExecutionStrategyCloseException("Failed to close CQL connection", e); + } + } + + /** + * Build a CQL INVOKE COMMAND statement string from command name and CommandExecutionArgs. + * The format is: INVOKE COMMAND commandName WITH "key1" = 'value1' AND "key2" = 'value2'; + */ + @VisibleForTesting + static String buildCqlCommandString(String commandName, CommandExecutionArgs args) + { + CqlBuilder builder = new CqlBuilder(); + builder.append("INVOKE COMMAND "); + builder.appendQuotingIfNeeded(commandName); + + Map paramsMap = new LinkedHashMap<>(); + for (Map.Entry entry : args.options().entrySet()) + { + OptionMetadata optionMetadata = entry.getKey(); + Object value = entry.getValue(); + + if (value == null) + continue; + + String key = normalizeOptionName(optionMetadata.paramLabel()); + paramsMap.put(key, value); + } + + for (Map.Entry entry : args.parameters().entrySet()) + { + ParameterMetadata paramMetadata = entry.getKey(); + Object value = entry.getValue(); + + if (value == null) + continue; + + String key = COMMAND_POSITIONAL_PARAM_PREFIX + paramMetadata.index(); + paramsMap.put(key, value); + } + + if (!paramsMap.isEmpty()) + { + builder.append(" WITH"); + boolean first = true; + for (Map.Entry entry : paramsMap.entrySet()) + { + if (first) + builder.append(" "); + else + builder.append(" AND "); + + // Using ColumnIdentifier.maybeQuote as some of the keys + // may be reserved words in CQL (e.g., "keyspace", "table"). + builder.append(ColumnIdentifier.maybeQuote(entry.getKey())); + builder.append(" = "); + appendCqlValue(builder, entry.getValue()); + + first = false; + } + } + + builder.append(";"); + return builder.toString(); + } + + /** Append a value to CqlBuilder with proper type handling and escaping. */ + private static void appendCqlValue(CqlBuilder builder, Object value) + { + if (value == null) + { + builder.append("NULL"); + return; + } + + if (value instanceof String) + builder.appendWithSingleQuotes((String) value); + else if (value instanceof Enum) + builder.appendWithSingleQuotes(((Enum) value).name()); + else if (value instanceof Number) + builder.append(value); + else if (value instanceof Boolean) + builder.append(value); + else if (value instanceof List) + { + List list = (List) value; + builder.append("["); + for (int i = 0; i < list.size(); i++) + { + if (i > 0) + builder.append(", "); + appendCqlValue(builder, list.get(i)); + } + builder.append("]"); + } + else if (value instanceof Map) + { + Map map = (Map) value; + builder.append("{"); + boolean first = true; + for (Map.Entry entry : map.entrySet()) + { + if (!first) + builder.append(", "); + builder.appendWithSingleQuotes(entry.getKey()); + builder.append(": "); + appendCqlValue(builder, entry.getValue()); + first = false; + } + builder.append("}"); + } + else if (value instanceof Set) + { + Set set = (Set) value; + builder.append("{"); + boolean first = true; + for (Object item : set) + { + if (!first) + builder.append(", "); + appendCqlValue(builder, item); + first = false; + } + builder.append("}"); + } + else if (value.getClass().isArray()) + { + int length = Array.getLength(value); + builder.append("["); + for (int i = 0; i < length; i++) + { + if (i > 0) + builder.append(", "); + appendCqlValue(builder, Array.get(value, i)); + } + builder.append("]"); + } + else + builder.appendWithSingleQuotes(value.toString()); + } +} diff --git a/src/java/org/apache/cassandra/tools/nodetool/strategy/NodetoolConnectionException.java b/src/java/org/apache/cassandra/tools/nodetool/strategy/NodetoolConnectionException.java new file mode 100644 index 0000000000..e22ed050a0 --- /dev/null +++ b/src/java/org/apache/cassandra/tools/nodetool/strategy/NodetoolConnectionException.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.tools.nodetool.strategy; + +/** + * Thrown when nodetool fails to establish its connection to the target node, over either JMX or CQL. + */ +public class NodetoolConnectionException extends RuntimeException +{ + public NodetoolConnectionException(String message, Throwable cause) + { + super(message, cause); + } +} diff --git a/src/java/org/apache/cassandra/tools/nodetool/strategy/ProtocolAwareExecutionStrategy.java b/src/java/org/apache/cassandra/tools/nodetool/strategy/ProtocolAwareExecutionStrategy.java new file mode 100644 index 0000000000..e912e026fd --- /dev/null +++ b/src/java/org/apache/cassandra/tools/nodetool/strategy/ProtocolAwareExecutionStrategy.java @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.tools.nodetool.strategy; + +import org.apache.cassandra.config.CassandraRelevantEnv; +import org.apache.cassandra.config.CassandraRelevantProperties; +import org.apache.cassandra.tools.nodetool.CqlConnect; +import org.apache.cassandra.tools.nodetool.JmxConnect; + +import picocli.CommandLine; + +import static org.apache.cassandra.utils.LocalizeString.toUpperCaseLocalized; + +public class ProtocolAwareExecutionStrategy implements CommandExecutionStrategy +{ + public static int executionStrategy(CommandLine.ParseResult parseResult) + { + try (ProtocolAwareExecutionStrategy strategy = new ProtocolAwareExecutionStrategy()) + { + return strategy.execute(parseResult); + } + } + + /** + * This method is called by picocli and used depending on the execution strategy. + * @param parseResult The parsed command line. + * @return The exit code. + */ + @Override + public int execute(CommandLine.ParseResult parseResult) + { + CommandLine.Model.CommandSpec connectCmd = parseResult.commandSpec() + .mixins() + .get(getExecutionStrategyTypeFromEnvAndSys().toString()); + if (connectCmd == null) + throw new CommandLine.InitializationException("No 'connection' command found in the top-level hierarchy"); + + try (CommandExecutionStrategy invoker = createInvoker(connectCmd.userObject())) + { + return invoker.execute(parseResult); + } + catch (NodetoolConnectionException e) + { + throw new CommandLine.ExecutionException(parseResult.commandSpec().commandLine(), e.getMessage(), e); + } + catch (ExecutionStrategyCloseException e) + { + CommandLine commandLine = parseResult.commandSpec().commandLine(); + commandLine.getErr().println("Failed to close connection: " + e.getMessage()); + CommandLine.IExitCodeExceptionMapper mapper = commandLine.getExitCodeExceptionMapper(); + return mapper != null ? mapper.getExitCode(e) : commandLine.getCommandSpec().exitCodeOnExecutionException(); + } + } + + public static Type getExecutionStrategyTypeFromEnvAndSys() + { + Type defaultStrategy = Type.valueOf(toUpperCaseLocalized(CassandraRelevantProperties.CASSANDRA_CLI_EXECUTION_PROTOCOL.getDefaultValue())); + Type strategyEnv = CassandraRelevantEnv.CASSANDRA_CLI_EXECUTION_PROTOCOL.getEnum(true, Type.class, defaultStrategy.name()); + Type strategySys = CassandraRelevantProperties.CASSANDRA_CLI_EXECUTION_PROTOCOL.getEnum(true, Type.class); + return strategyEnv != defaultStrategy ? strategyEnv : strategySys; + } + + protected CommandExecutionStrategy createInvoker(Object connectCmd) + { + Type strategy = getExecutionStrategyTypeFromEnvAndSys(); + switch (strategy) + { + case CQL: + return new CqlCommandExecutionStrategy((CqlConnect) connectCmd); + case COMMAND_MBEAN: + return new CommandMBeanExecutionStrategy((JmxConnect) connectCmd); + case STATIC_MBEAN: + return new StaticMBeanExecutionStrategy((JmxConnect) connectCmd); + default: + throw new IllegalStateException("Unknown execution strategy: " + strategy); + } + } +} diff --git a/src/java/org/apache/cassandra/tools/nodetool/strategy/StaticMBeanExecutionStrategy.java b/src/java/org/apache/cassandra/tools/nodetool/strategy/StaticMBeanExecutionStrategy.java new file mode 100644 index 0000000000..83abba4e22 --- /dev/null +++ b/src/java/org/apache/cassandra/tools/nodetool/strategy/StaticMBeanExecutionStrategy.java @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.tools.nodetool.strategy; + +import java.util.List; + +import org.apache.cassandra.tools.nodetool.AbstractCommand; +import org.apache.cassandra.tools.nodetool.JmxConnect; + +import picocli.CommandLine; + +public class StaticMBeanExecutionStrategy implements CommandExecutionStrategy +{ + private final JmxConnect connect; + + public StaticMBeanExecutionStrategy(JmxConnect connect) + { + this.connect = connect; + } + + @Override + public int execute(CommandLine.ParseResult parseResult) throws CommandLine.ExecutionException, CommandLine.ParameterException + { + CommandLine.Model.CommandSpec lastParent = lastExecutableSubcommandWithSameParent(parseResult.asCommandLineList()); + if (lastParent.userObject() instanceof AbstractCommand) + { + AbstractCommand command = (AbstractCommand) lastParent.userObject(); + if (command.shouldConnect()) + connect.run(); + command.probe(connect.probe()); + } + return new CommandLine.RunLast().execute(parseResult); + } + + @Override + public void close() throws ExecutionStrategyCloseException + { + if (connect.probe() == null) + return; + try + { + connect.probe().close(); + } + catch (Exception e) + { + throw new ExecutionStrategyCloseException("Failed to close JMX connection", e); + } + } + + static CommandLine.Model.CommandSpec lastExecutableSubcommandWithSameParent(List parsedCommands) + { + int start = parsedCommands.size() - 1; + for (int i = parsedCommands.size() - 2; i >= 0; i--) + { + if (parsedCommands.get(i).getParent() != parsedCommands.get(i + 1).getParent()) + break; + start = i; + } + return parsedCommands.get(start).getCommandSpec(); + } +} diff --git a/src/java/org/apache/cassandra/transport/Connection.java b/src/java/org/apache/cassandra/transport/Connection.java index 074b558a10..06bc391b60 100644 --- a/src/java/org/apache/cassandra/transport/Connection.java +++ b/src/java/org/apache/cassandra/transport/Connection.java @@ -23,6 +23,7 @@ import io.netty.util.AttributeKey; public class Connection { static final AttributeKey attributeKey = AttributeKey.valueOf("CONN"); + static final AttributeKey managementKey = AttributeKey.valueOf("IS_MANAGEMENT_CONN"); private final Channel channel; private final ProtocolVersion version; diff --git a/src/java/org/apache/cassandra/transport/Dispatcher.java b/src/java/org/apache/cassandra/transport/Dispatcher.java index 2e1b36a4f9..f16d2b7b03 100644 --- a/src/java/org/apache/cassandra/transport/Dispatcher.java +++ b/src/java/org/apache/cassandra/transport/Dispatcher.java @@ -33,9 +33,16 @@ import org.slf4j.LoggerFactory; import org.apache.cassandra.concurrent.DebuggableTask; import org.apache.cassandra.concurrent.LocalAwareExecutorPlus; import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.cql3.CQLStatement; +import org.apache.cassandra.cql3.QueryProcessor; +import org.apache.cassandra.cql3.statements.ExecuteCommandStatement; +import org.apache.cassandra.cql3.statements.SelectStatement; +import org.apache.cassandra.cql3.statements.UseStatement; +import org.apache.cassandra.exceptions.InvalidRequestException; import org.apache.cassandra.exceptions.OverloadedException; import org.apache.cassandra.metrics.ClientMetrics; import org.apache.cassandra.net.FrameEncoder; +import org.apache.cassandra.schema.SchemaConstants; import org.apache.cassandra.service.ClientWarn; import org.apache.cassandra.service.QueryState; import org.apache.cassandra.service.reads.thresholds.CoordinatorWarnings; @@ -44,6 +51,7 @@ import org.apache.cassandra.transport.ClientResourceLimits.Overload; import org.apache.cassandra.transport.Flusher.FlushItem; import org.apache.cassandra.transport.messages.ErrorMessage; import org.apache.cassandra.transport.messages.EventMessage; +import org.apache.cassandra.transport.messages.QueryMessage; import org.apache.cassandra.utils.JVMStabilityInspector; import org.apache.cassandra.utils.MonotonicClock; import org.apache.cassandra.utils.NoSpamLogger; @@ -53,6 +61,7 @@ import io.netty.channel.EventLoop; import io.netty.util.AttributeKey; import static org.apache.cassandra.concurrent.SharedExecutorPool.SHARED; +import static org.apache.cassandra.utils.LocalizeString.toLowerCaseLocalized; public class Dispatcher implements CQLMessageHandler.MessageConsumer { @@ -83,8 +92,43 @@ public class Dispatcher implements CQLMessageHandler.MessageConsumerManagement connections are identified via Connection's flag set by the management + * transport server at initial connection setup. Request then are routed to a dedicated + * executor instead of the standard {@link #requestExecutor}. This provides isolation and + * prioritization of management operations, ensuring they can proceed even under + * a high load of regular client requests. + * + *

The executor is configured separately via + * {@link DatabaseDescriptor#getNativeTransportManagementMaxThreads()} to allow + * independent tuning of management operation throughput. + * + *

Management connections are established through the management transport server + * (see {@link org.apache.cassandra.service.NativeTransportManagementService}), which listens + * on a separate port from the regular native transport. + * + *

Unlike auth requests, management requests have no fallback executor, so values + * less than 1 are treated as 1: a zero-sized pool would queue management requests forever. + */ + @VisibleForTesting + static final LocalAwareExecutorPlus managementExecutor = SHARED.newExecutor(Math.max(1, DatabaseDescriptor.getNativeTransportManagementMaxThreads()), + DatabaseDescriptor::setNativeTransportManagementMaxThreads, + "transport", + "Native-Transport-Management-Tasks"); + private static final ConcurrentMap flusherLookup = new ConcurrentHashMap<>(); + + /** + * Set while a management request is executing on the current thread. A management command may itself + * initiate the management server's stop (e.g. INVOKE COMMAND stopdaemon), in which case the drain-wait + * in {@link Server#close} runs on this very thread and must not wait for its own task to complete. + */ + private static final ThreadLocal IN_MANAGEMENT_TASK = ThreadLocal.withInitial(() -> Boolean.FALSE); + private final boolean useLegacyFlusher; + private final boolean isManagementDispatcher; /** * Takes a Channel, Request and the Response produced by processRequest and outputs a FlushItem @@ -98,9 +142,10 @@ public class Dispatcher implements CQLMessageHandler.MessageConsumer toFlushItem(P param, Channel channel, Message.Request request, Message.Response response); } - public Dispatcher(boolean useLegacyFlusher) + public Dispatcher(boolean useLegacyFlusher, boolean isManagementDispatcher) { this.useLegacyFlusher = useLegacyFlusher; + this.isManagementDispatcher = isManagementDispatcher; } @Override @@ -123,10 +168,32 @@ public class Dispatcher implements CQLMessageHandler.MessageConsumer 0 && (request.type == Message.Type.AUTH_RESPONSE || request.type == Message.Type.CREDENTIALS); - // Importantly, the authExecutor will handle the AUTHENTICATE message which may be CPU intensive. - LocalAwareExecutorPlus executor = isAuthQuery ? authExecutor : requestExecutor; + if (isAuthQuery) + { + // Importantly, the authExecutor will handle the AUTHENTICATE message which may be CPU intensive. + authExecutor.submit(new RequestProcessor<>(channel, request, forFlusher, param, backpressure)); + ClientMetrics.instance.markRequestDispatched(); + return; + } - executor.submit(new RequestProcessor<>(channel, request, forFlusher, param, backpressure)); + // Use connection object to check for management connections, this could be faster than checking + // channel attributies directly every time. For management connections, we route requests to + // the management executor. + Connection connection = request.connection(); + if (connection instanceof ServerConnection) + { + ServerConnection serverConnection = (ServerConnection) connection; + if (serverConnection.isManagementConnection()) + { + // Intentionally skipping ClientMetrics calls here: that meter tracks regular client request + // dispatch, and management API requests are accounted for separately dedicated metrics rather + // than mixing them into the client requests rate. + managementExecutor.submit(new ManagementRequestProcessor<>(channel, request, forFlusher, param, backpressure)); + return; + } + } + + requestExecutor.submit(new RequestProcessor<>(channel, request, forFlusher, param, backpressure)); ClientMetrics.instance.markRequestDispatched(); } @@ -293,13 +360,13 @@ public class Dispatcher implements CQLMessageHandler.MessageConsumer implements DebuggableTask.RunnableDebuggableTask { - private final Channel channel; - private final Message.Request request; - private final FlushItemConverter

forFlusher; - private final P flusherParam; - private final Overload backpressure; + protected final Channel channel; + protected final Message.Request request; + protected final FlushItemConverter

forFlusher; + protected final P flusherParam; + protected final Overload backpressure; - private volatile long startTimeNanos; + protected volatile long startTimeNanos; public RequestProcessor(Channel channel, Message.Request request, FlushItemConverter

forFlusher, P flusherParam, Overload backpressure) { @@ -345,6 +412,124 @@ public class Dispatcher implements CQLMessageHandler.MessageConsumer extends RequestProcessor

{ + + public ManagementRequestProcessor(Channel channel, + Message.Request request, + FlushItemConverter

forFlusher, + P flusherParam, + Overload backpressure) { + super(channel, request, forFlusher, flusherParam, backpressure); + } + + @Override + public void run() { + startTimeNanos = MonotonicClock.Global.preciseTime.now(); + RequestTime requestTime = new RequestTime(request.createdAtNanos, startTimeNanos); + + // Validate management request BEFORE executing + Connection connection = request.connection(); + if (connection instanceof ServerConnection) { + ServerConnection serverConnection = (ServerConnection) connection; + if (serverConnection.isManagementConnection()) { + if (!isManagementRequestAllowed(request)) { + // The flush pipeline takes the stream id from the request envelope, so the + // response must not carry one of its own. + Message.Response response = ErrorMessage.fromExceptionNoStreamId( + new InvalidRequestException( + "Only executions of the INVOKE COMMAND statements are allowed on the management port.")); + response.attach(connection); + FlushItem toFlush = forFlusher.toFlushItem(flusherParam, channel, request, response); + flush(toFlush); + return; + } + } + } + + IN_MANAGEMENT_TASK.set(Boolean.TRUE); + try { + // If validation passes, call the normal processRequest to execute + // This calls the instance method processRequest() which does all the work + processRequest(channel, request, forFlusher, flusherParam, backpressure, requestTime); + } finally { + IN_MANAGEMENT_TASK.set(Boolean.FALSE); + } + } + } + + @VisibleForTesting + static boolean isManagementRequestAllowed(Message.Request request) + { + switch (request.type) + { + case QUERY: + try + { + // Early parse the query to check if it's an INVOKE COMMAND statement. + // For management non-intensive operations double parsing is probably acceptable. + CQLStatement.Raw rawStatement = QueryProcessor.parseStatement(((QueryMessage) request).query); + if (rawStatement instanceof ExecuteCommandStatement.Raw) + return true; + + // Allow read-only SELECT queries on system keyspaces (needed for driver metadata + // discovery), except system_auth (see isManagementReadableSystemKeyspace). + if (rawStatement instanceof SelectStatement.RawStatement) + { + SelectStatement.RawStatement selectRaw = (SelectStatement.RawStatement) rawStatement; + return selectRaw.isFullyQualified() + && isManagementReadableSystemKeyspace(selectRaw.keyspace()); + } + + // This is also a corner case for the driver's behavior on the management port. + // When connecting, the driver sends a USE statement for the keyspace provided + // in driver.connect("system_schema"). + if (rawStatement instanceof UseStatement) + { + UseStatement useStatement = (UseStatement) rawStatement; + return isManagementReadableSystemKeyspace(useStatement.keyspace()); + } + + return false; + } + catch (Exception e) + { + logger.warn("The command request parsing failed. The command will not be executed: {}", e.getMessage()); + // If parsing fails (syntax error, etc.), it's not a valid command statement; + // this is expected for non-command queries. + return false; + } + case STARTUP: + case CREDENTIALS: + case AUTH_RESPONSE: + case OPTIONS: + case REGISTER: + return true; // Protocol messages are always allowed. + case EXECUTE: + case PREPARE: + case BATCH: + default: + return false; // Not supported and not allowed on management connections. + } + } + + /** + * System keyspaces a CQL driver may read/USE on the management port for metadata discovery. This is + * {@link SchemaConstants#isSystemKeyspace(String)} (which also covers virtual system keyspaces) minus + * {@code system_auth}: that keyspace is the credential store (bcrypt {@code salted_hash}, superuser + * flags) and must never be readable over the management port, which is reachable without authorization + * (if AllowAllAuthorizer is enabled). + */ + private static boolean isManagementReadableSystemKeyspace(String keyspace) + { + if (keyspace == null) + return false; + if (SchemaConstants.AUTH_KEYSPACE_NAME.equals(toLowerCaseLocalized(keyspace))) + return false; + return SchemaConstants.isSystemKeyspace(keyspace) + || SchemaConstants.isVirtualSystemKeyspace(keyspace); + } + /** * Checks if the item in the head of the queue has spent more than allowed time in the queue. */ @@ -355,7 +540,8 @@ public class Dispatcher implements CQLMessageHandler.MessageConsumer 0) { @@ -369,11 +376,6 @@ public class PipelineConfigurator return new ClientResourceLimits.ResourceProvider.Default(allocator); } - protected Dispatcher dispatcher(boolean useLegacyFlusher) - { - return new Dispatcher(useLegacyFlusher); - } - protected CQLMessageHandler.MessageConsumer messageConsumer() { return dispatcher; diff --git a/src/java/org/apache/cassandra/transport/QueueBackpressure.java b/src/java/org/apache/cassandra/transport/QueueBackpressure.java index 09c9e95b75..7c2ed36c73 100644 --- a/src/java/org/apache/cassandra/transport/QueueBackpressure.java +++ b/src/java/org/apache/cassandra/transport/QueueBackpressure.java @@ -48,16 +48,35 @@ public interface QueueBackpressure { QueueBackpressure NO_OP = timeUnit -> 0; - QueueBackpressure DEFAULT = new QueueBackpressure() - { - private final AtomicReference state = new AtomicReference<>(noBackpressure(() -> DatabaseDescriptor.getNativeTransportMinBackoffOnQueueOverload(TimeUnit.NANOSECONDS), - () -> DatabaseDescriptor.getNativeTransportMaxBackoffOnQueueOverload(TimeUnit.NANOSECONDS))); + /** Shared incident state for connections served by {@link Dispatcher#requestExecutor}. */ + QueueBackpressure DEFAULT = newDefault(); - public long markAndGetDelay(TimeUnit timeUnit) + /** + * Shared incident state for management connections ({@link Dispatcher#managementExecutor}). Kept separate + * from {@link #DEFAULT} because incident severity describes a single executor's queue: an overload streak + * on the client transport must not inflate delays applied to management connections, and vice versa. + */ + QueueBackpressure MANAGEMENT_DEFAULT = newDefault(); + + static QueueBackpressure newDefault() + { + return newDefault(() -> DatabaseDescriptor.getNativeTransportMinBackoffOnQueueOverload(TimeUnit.NANOSECONDS), + () -> DatabaseDescriptor.getNativeTransportMaxBackoffOnQueueOverload(TimeUnit.NANOSECONDS)); + } + + @VisibleForTesting + static QueueBackpressure newDefault(LongSupplier minDelayNanos, LongSupplier maxDelayNanos) + { + return new QueueBackpressure() { - return state.updateAndGet(Incident::mark).delay(timeUnit); - } - }; + private final AtomicReference state = new AtomicReference<>(noBackpressure(minDelayNanos, maxDelayNanos)); + + public long markAndGetDelay(TimeUnit timeUnit) + { + return state.updateAndGet(Incident::mark).delay(timeUnit); + } + }; + } long markAndGetDelay(TimeUnit timeUnit); diff --git a/src/java/org/apache/cassandra/transport/Server.java b/src/java/org/apache/cassandra/transport/Server.java index d7af40eb96..8ade530288 100644 --- a/src/java/org/apache/cassandra/transport/Server.java +++ b/src/java/org/apache/cassandra/transport/Server.java @@ -85,7 +85,8 @@ public class Server implements CassandraDaemon.Server { public Connection newConnection(Channel channel, ProtocolVersion version) { - return new ServerConnection(channel, version, connectionTracker); + boolean isManagementConnection = Boolean.TRUE.equals(channel.attr(Connection.managementKey).get()); + return new ServerConnection(channel, version, connectionTracker, isManagementConnection); } }; @@ -111,13 +112,14 @@ public class Server implements CassandraDaemon.Server workerGroup = new NioEventLoopGroup(); } - dispatcher = new Dispatcher(DatabaseDescriptor.useNativeTransportLegacyFlusher()); + dispatcher = new Dispatcher(DatabaseDescriptor.useNativeTransportLegacyFlusher(), builder.isManagementConnection); pipelineConfigurator = builder.pipelineConfigurator != null ? builder.pipelineConfigurator : new PipelineConfigurator(useEpoll, DatabaseDescriptor.getRpcKeepAlive(), builder.tlsEncryptionPolicy, - dispatcher); + dispatcher, + builder.isManagementConnection); EventNotifier notifier = builder.eventNotifier != null ? builder.eventNotifier : new EventNotifier(); connectionTracker = new ConnectionTracker(isRunning::get); @@ -234,6 +236,7 @@ public class Server implements CassandraDaemon.Server private InetSocketAddress socket; private PipelineConfigurator pipelineConfigurator; private EventNotifier eventNotifier; + private boolean isManagementConnection = false; public Builder withTlsEncryptionPolicy(EncryptionOptions.TlsEncryptionPolicy tlsEncryptionPolicy) { @@ -273,6 +276,12 @@ public class Server implements CassandraDaemon.Server return this; } + public Builder withManagementConnectionFlag(boolean management) + { + this.isManagementConnection = management; + return this; + } + public Server build() { return new Server(this); diff --git a/src/java/org/apache/cassandra/transport/ServerConnection.java b/src/java/org/apache/cassandra/transport/ServerConnection.java index 8667f8601f..6eea90b924 100644 --- a/src/java/org/apache/cassandra/transport/ServerConnection.java +++ b/src/java/org/apache/cassandra/transport/ServerConnection.java @@ -43,13 +43,20 @@ public class ServerConnection extends Connection private final ClientState clientState; private volatile ConnectionStage stage; public final Counter requests = new ThreadLocalCounter(); + private final boolean isManagementConnection; ServerConnection(Channel channel, ProtocolVersion version, Connection.Tracker tracker) + { + this(channel, version, tracker, false); + } + + ServerConnection(Channel channel, ProtocolVersion version, Connection.Tracker tracker, boolean isManagementConnection) { super(channel, version, tracker); clientState = ClientState.forExternalCalls(channel.remoteAddress()); stage = ConnectionStage.ESTABLISHED; + this.isManagementConnection = isManagementConnection; } public ClientState getClientState() @@ -158,4 +165,9 @@ public class ServerConnection extends Connection .get("ssl"); return sslHandler != null; } + + public boolean isManagementConnection() + { + return isManagementConnection; + } } diff --git a/src/java/org/apache/cassandra/transport/SimpleClient.java b/src/java/org/apache/cassandra/transport/SimpleClient.java index aee18ac227..deb5141b7c 100644 --- a/src/java/org/apache/cassandra/transport/SimpleClient.java +++ b/src/java/org/apache/cassandra/transport/SimpleClient.java @@ -91,7 +91,7 @@ import static org.apache.cassandra.net.SocketFactory.newSslHandler; import static org.apache.cassandra.transport.CQLMessageHandler.envelopeSize; import static org.apache.cassandra.transport.Flusher.MAX_FRAMED_PAYLOAD_SIZE; import static org.apache.cassandra.transport.PipelineConfigurator.SSL_FACTORY_CONTEXT_DESCRIPTION; -import static org.apache.cassandra.utils.Clock.Global.currentTimeMillis; +import static org.apache.cassandra.utils.Clock.Global.nanoTime; import static org.apache.cassandra.utils.concurrent.BlockingQueues.newBlockingQueue; import static org.apache.cassandra.utils.concurrent.NonBlockingRateLimiter.NO_OP_LIMITER; @@ -100,6 +100,23 @@ public class SimpleClient implements Closeable public static final int TIMEOUT_SECONDS = 10; + public static class TimeoutException extends RuntimeException + { + TimeoutException(long seconds) + { + super("Timed out after waiting " + seconds + " seconds for a server response. " + + "The request may still be executing on the server."); + } + } + + public static class ConnectionClosedException extends RuntimeException + { + ConnectionClosedException() + { + super("Connection was closed by the server before a response was received."); + } + } + static { InternalLoggerFactory.setDefaultFactory(new Slf4JLoggerFactory()); @@ -111,6 +128,8 @@ public class SimpleClient implements Closeable public final int port; private final EncryptionOptions.ClientEncryptionOptions encryptionOptions; private final int largeMessageThreshold; + /** How long {@link #execute(Message.Request)} waits for a server response; {@code <= 0} means wait indefinitely. */ + private final long requestTimeoutSeconds; protected final ResponseHandler responseHandler = new ResponseHandler(); protected final Connection.Tracker tracker = new ConnectionTracker(); @@ -131,6 +150,7 @@ public class SimpleClient implements Closeable private ProtocolVersion version = ProtocolVersion.CURRENT; private boolean useBeta = false; private int largeMessageThreshold = FrameEncoder.Payload.MAX_SIZE; + private long requestTimeoutSeconds = TIMEOUT_SECONDS; private Builder(String host, int port) { @@ -162,6 +182,13 @@ public class SimpleClient implements Closeable return this; } + /** @param seconds response wait limit for each request; {@code <= 0} waits indefinitely */ + public Builder requestTimeoutSeconds(long seconds) + { + requestTimeoutSeconds = seconds; + return this; + } + public SimpleClient build() { if (version.isBeta() && !useBeta) @@ -182,6 +209,7 @@ public class SimpleClient implements Closeable this.version = builder.version; this.encryptionOptions = builder.encryptionOptions.applyConfig(); this.largeMessageThreshold = builder.largeMessageThreshold; + this.requestTimeoutSeconds = builder.requestTimeoutSeconds; } public SimpleClient(String host, int port, ProtocolVersion version, EncryptionOptions.ClientEncryptionOptions encryptionOptions) @@ -211,6 +239,7 @@ public class SimpleClient implements Closeable this.largeMessageThreshold = FrameEncoder.Payload.MAX_SIZE - Math.max(FrameEncoderCrc.HEADER_AND_TRAILER_LENGTH, FrameEncoderLZ4.HEADER_AND_TRAILER_LENGTH); + this.requestTimeoutSeconds = TIMEOUT_SECONDS; } public SimpleClient(String host, int port) @@ -328,9 +357,9 @@ public class SimpleClient implements Closeable { request.attach(connection); lastWriteFuture = channel.writeAndFlush(Collections.singletonList(request)); - Message.Response msg = responseHandler.responses.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS); + Message.Response msg = awaitResponse(responseDeadlineNanos()); if (msg == null) - throw new RuntimeException("timeout"); + throw new TimeoutException(requestTimeoutSeconds); if (throwOnErrorResponse && msg instanceof ErrorMessage) throw new RuntimeException((Throwable)((ErrorMessage)msg).error); return msg; @@ -357,12 +386,12 @@ public class SimpleClient implements Closeable } lastWriteFuture = channel.writeAndFlush(requests); - long deadline = currentTimeMillis() + TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS); + long deadlineNanos = responseDeadlineNanos(); for (int i = 0; i < requests.size(); i++) { - Message.Response msg = responseHandler.responses.poll(deadline - currentTimeMillis(), TimeUnit.MILLISECONDS); + Message.Response msg = awaitResponse(deadlineNanos); if (msg == null) - throw new RuntimeException("timeout"); + throw new TimeoutException(requestTimeoutSeconds); if (msg instanceof ErrorMessage) throw new RuntimeException((Throwable) ((ErrorMessage) msg).error); rrMap.put(requests.get(msg.getSource().header.streamId), msg); @@ -395,6 +424,26 @@ public class SimpleClient implements Closeable return source == null ? 0 : source.header.streamId; } + private long responseDeadlineNanos() + { + return requestTimeoutSeconds > 0 ? nanoTime() + TimeUnit.SECONDS.toNanos(requestTimeoutSeconds) : Long.MAX_VALUE; + } + + private Message.Response awaitResponse(long deadlineNanos) throws InterruptedException + { + while (true) + { + long waitNanos = Math.min(TimeUnit.SECONDS.toNanos(1), deadlineNanos - nanoTime()); + Message.Response msg = waitNanos > 0 ? responseHandler.responses.poll(waitNanos, TimeUnit.NANOSECONDS) : null; + if (msg != null) + return msg; + if (responseHandler.connectionClosed) + throw new ConnectionClosedException(); + if (nanoTime() - deadlineNanos >= 0) + return null; + } + } + public interface EventHandler { void onEvent(Event event); @@ -702,6 +751,14 @@ public class SimpleClient implements Closeable { public final BlockingQueue responses = new SynchronousQueue<>(true); public EventHandler eventHandler; + volatile boolean connectionClosed; + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception + { + connectionClosed = true; + ctx.fireChannelInactive(); + } @Override public void channelRead0(ChannelHandlerContext ctx, Message.Response r) diff --git a/src/java/org/apache/cassandra/transport/messages/ErrorMessage.java b/src/java/org/apache/cassandra/transport/messages/ErrorMessage.java index fbeb3c66ec..567420912b 100644 --- a/src/java/org/apache/cassandra/transport/messages/ErrorMessage.java +++ b/src/java/org/apache/cassandra/transport/messages/ErrorMessage.java @@ -21,6 +21,7 @@ import java.net.InetAddress; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.UUID; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Predicate; @@ -37,6 +38,7 @@ import org.apache.cassandra.exceptions.AuthenticationException; import org.apache.cassandra.exceptions.CDCWriteException; import org.apache.cassandra.exceptions.CasWriteTimeoutException; import org.apache.cassandra.exceptions.CasWriteUnknownResultException; +import org.apache.cassandra.exceptions.CommandRequestExecutionException; import org.apache.cassandra.exceptions.ConfigurationException; import org.apache.cassandra.exceptions.ExceptionCode; import org.apache.cassandra.exceptions.FunctionExecutionException; @@ -217,6 +219,18 @@ public class ErrorMessage extends Message.Response int blockFor = body.readInt(); te = new CasWriteUnknownResultException(cl, received, blockFor); break; + case COMMAND_FAILED: + if (version.isSmallerThan(ProtocolVersion.V5)) + { + // Fallback for older clients - they'll get SERVER_ERROR instead + te = new ServerError(msg); + } + else + { + UUID executionId = CBUtil.readUUID(body); + te = new CommandRequestExecutionException(executionId, msg, null); + } + break; } return new ErrorMessage(te); } @@ -302,6 +316,11 @@ public class ErrorMessage extends Message.Response CBUtil.writeConsistencyLevel(cwue.consistency, dest); dest.writeInt(cwue.received); dest.writeInt(cwue.blockFor); + break; + case COMMAND_FAILED: + CommandRequestExecutionException cree = (CommandRequestExecutionException)err; + CBUtil.writeUUID(cree.getCommandExecutionId(), dest); + break; } } @@ -371,6 +390,10 @@ public class ErrorMessage extends Message.Response CasWriteUnknownResultException cwue = (CasWriteUnknownResultException)err; size += CBUtil.sizeOfConsistencyLevel(cwue.consistency) + 4 + 4; // receivedFor: 4, blockFor: 4 break; + case COMMAND_FAILED: + CommandRequestExecutionException cree = (CommandRequestExecutionException)err; + size += CBUtil.sizeOfUUID(cree.getCommandExecutionId()); // 16 bytes + break; } return size; } @@ -408,6 +431,13 @@ public class ErrorMessage extends Message.Response case CAS_WRITE_UNKNOWN: CasWriteUnknownResultException cwue = (CasWriteUnknownResultException) msg.error; return new WriteTimeoutException(WriteType.CAS, cwue.consistency, cwue.received, cwue.blockFor); + case COMMAND_FAILED: + // For older clients, the executionId is lost, but the message should contain it. + CommandRequestExecutionException cree = (CommandRequestExecutionException) msg.error; + String msgWithId = String.format("Command execution failed (executionId: %s): %s", + cree.getCommandExecutionId(), + cree.getMessage()); + return new ServerError(msgWithId); } } diff --git a/src/java/org/apache/cassandra/transport/messages/StartupMessage.java b/src/java/org/apache/cassandra/transport/messages/StartupMessage.java index c76db6826e..93380238af 100644 --- a/src/java/org/apache/cassandra/transport/messages/StartupMessage.java +++ b/src/java/org/apache/cassandra/transport/messages/StartupMessage.java @@ -134,6 +134,12 @@ public class StartupMessage extends Message.Request Guardrails.minimumClientDriverVersion.guard(driverName, driverVersion, clientState); + if (connection instanceof ServerConnection) + { + ServerConnection serverConnection = (ServerConnection) connection; + clientState.setManagement(serverConnection.isManagementConnection()); + } + IAuthenticator authenticator = DatabaseDescriptor.getAuthenticator(); if (authenticator.requireAuthentication()) { diff --git a/src/java/org/apache/cassandra/utils/JsonUtils.java b/src/java/org/apache/cassandra/utils/JsonUtils.java index a9b5d18f22..bbba2ce5f8 100644 --- a/src/java/org/apache/cassandra/utils/JsonUtils.java +++ b/src/java/org/apache/cassandra/utils/JsonUtils.java @@ -259,4 +259,51 @@ public final class JsonUtils valueMap.put(lowered, valueMap.remove(mapKey)); } } + + public static String getJsonType(Class type) + { + if (type == String.class) + return "string"; + else if (type == int.class || type == Integer.class) + return "integer"; + else if (type == long.class || type == Long.class) + return "integer"; + else if (type == boolean.class || type == Boolean.class) + return "boolean"; + else if (type == double.class || type == Double.class || + type == float.class || type == Float.class) + return "number"; + else if (type.isArray() || List.class.isAssignableFrom(type)) + return "array"; + else if (Map.class.isAssignableFrom(type)) + return "object"; + else if (type.isEnum()) + return "string"; + else + return "string"; + } + + public static Object convertDefaultValue(String defaultValue, Class type) + { + try + { + if (type == boolean.class || type == Boolean.class) + return Boolean.parseBoolean(defaultValue); + else if (type == int.class || type == Integer.class) + return Integer.parseInt(defaultValue); + else if (type == long.class || type == Long.class) + return Long.parseLong(defaultValue); + else if (type == double.class || type == Double.class) + return Double.parseDouble(defaultValue); + else if (type == float.class || type == Float.class) + return Float.parseFloat(defaultValue); + else + return defaultValue; + } + catch (Exception e) + { + // Fall back to string default value if parsing fails. + return defaultValue; + } + } } diff --git a/src/resources/META-INF/services/org.apache.cassandra.management.api.CommandsProvider b/src/resources/META-INF/services/org.apache.cassandra.management.api.CommandsProvider new file mode 100644 index 0000000000..4a25d60f4a --- /dev/null +++ b/src/resources/META-INF/services/org.apache.cassandra.management.api.CommandsProvider @@ -0,0 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# + +org.apache.cassandra.management.picocli.PicocliCommandsProvider + diff --git a/test/distributed/org/apache/cassandra/distributed/impl/Instance.java b/test/distributed/org/apache/cassandra/distributed/impl/Instance.java index c3b0e3ebd4..80d81ac863 100644 --- a/test/distributed/org/apache/cassandra/distributed/impl/Instance.java +++ b/test/distributed/org/apache/cassandra/distributed/impl/Instance.java @@ -118,6 +118,7 @@ import org.apache.cassandra.io.util.DataOutputPlus; import org.apache.cassandra.io.util.File; import org.apache.cassandra.io.util.PathUtils; import org.apache.cassandra.locator.InetAddressAndPort; +import org.apache.cassandra.management.CommandInvokerService; import org.apache.cassandra.metrics.CassandraMetricsRegistry; import org.apache.cassandra.metrics.Sampler; import org.apache.cassandra.metrics.ThreadLocalMetrics; @@ -1044,6 +1045,7 @@ public class Instance extends IsolatedExecutor implements IInvokableInstance SnapshotManager.instance::close, () -> IndexStatusManager.instance.shutdownAndWait(1L, MINUTES), DiskErrorsHandlerService::close, + CommandInvokerService::shutdown, () -> ThreadLocalMetrics.shutdownCleaner(1L, MINUTES) ); diff --git a/test/distributed/org/apache/cassandra/distributed/mock/nodetool/InternalNodeProbe.java b/test/distributed/org/apache/cassandra/distributed/mock/nodetool/InternalNodeProbe.java index 62e0dad3ea..21c73c22ce 100644 --- a/test/distributed/org/apache/cassandra/distributed/mock/nodetool/InternalNodeProbe.java +++ b/test/distributed/org/apache/cassandra/distributed/mock/nodetool/InternalNodeProbe.java @@ -19,79 +19,69 @@ package org.apache.cassandra.distributed.mock.nodetool; import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.lang.management.RuntimeMXBean; +import java.util.HashMap; import java.util.Iterator; +import java.util.List; import java.util.Map; import com.google.common.collect.Multimap; import org.apache.cassandra.batchlog.BatchlogManager; +import org.apache.cassandra.batchlog.BatchlogManagerMBean; import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.db.ColumnFamilyStoreMBean; import org.apache.cassandra.db.Keyspace; import org.apache.cassandra.db.compaction.CompactionManager; +import org.apache.cassandra.db.compaction.CompactionManagerMBean; +import org.apache.cassandra.db.compression.CompressionDictionaryManagerMBean; import org.apache.cassandra.gms.FailureDetector; import org.apache.cassandra.gms.FailureDetectorMBean; import org.apache.cassandra.gms.Gossiper; +import org.apache.cassandra.gms.GossiperMBean; import org.apache.cassandra.hints.HintsService; +import org.apache.cassandra.hints.HintsServiceMBean; import org.apache.cassandra.locator.DynamicEndpointSnitch; import org.apache.cassandra.locator.DynamicEndpointSnitchMBean; import org.apache.cassandra.locator.EndpointSnitchInfo; import org.apache.cassandra.locator.EndpointSnitchInfoMBean; import org.apache.cassandra.locator.SnitchAdapter; +import org.apache.cassandra.management.MBeanAccessor; import org.apache.cassandra.metrics.CassandraMetricsRegistry; import org.apache.cassandra.net.MessagingService; +import org.apache.cassandra.net.MessagingServiceMBean; +import org.apache.cassandra.profiler.AsyncProfilerMBean; import org.apache.cassandra.service.ActiveRepairService; +import org.apache.cassandra.service.ActiveRepairServiceMBean; import org.apache.cassandra.service.AsyncProfilerService; import org.apache.cassandra.service.CacheService; import org.apache.cassandra.service.CacheServiceMBean; import org.apache.cassandra.service.GCInspector; +import org.apache.cassandra.service.GCInspectorMXBean; import org.apache.cassandra.service.StorageProxy; +import org.apache.cassandra.service.StorageProxyMBean; import org.apache.cassandra.service.StorageService; +import org.apache.cassandra.service.StorageServiceMBean; import org.apache.cassandra.service.accord.AccordOperations; +import org.apache.cassandra.service.accord.AccordOperationsMBean; import org.apache.cassandra.service.snapshot.SnapshotManager; +import org.apache.cassandra.service.snapshot.SnapshotManagerMBean; import org.apache.cassandra.streaming.StreamManager; +import org.apache.cassandra.streaming.StreamManagerMBean; import org.apache.cassandra.tcm.CMSOperations; +import org.apache.cassandra.tcm.CMSOperationsMBean; import org.apache.cassandra.tools.NodeProbe; public class InternalNodeProbe extends NodeProbe { - private final boolean withNotifications; private boolean previousSkipNotificationListeners = false; public InternalNodeProbe(boolean withNotifications) { - this.withNotifications = withNotifications; - connect(); - } - - protected void connect() - { - // note that we are not connecting via JMX for testing - mbeanServerConn = null; - jmxc = null; - + super(new TestMockMBeanAccessor()); // host/port are unused in InternalNodeProbe previousSkipNotificationListeners = StorageService.instance.skipNotificationListeners; StorageService.instance.skipNotificationListeners = !withNotifications; - - ssProxy = StorageService.instance; - snapshotProxy = SnapshotManager.instance; - cmsProxy = CMSOperations.instance; - accordProxy = AccordOperations.instance; - msProxy = MessagingService.instance(); - streamProxy = StreamManager.instance; - compactionProxy = CompactionManager.instance; - fdProxy = (FailureDetectorMBean) FailureDetector.instance; - cacheService = CacheService.instance; - spProxy = StorageProxy.instance; - hsProxy = HintsService.instance; - - gcProxy = new GCInspector(); - gossProxy = Gossiper.instance; - bmProxy = BatchlogManager.instance; - arsProxy = ActiveRepairService.instance(); - memProxy = ManagementFactory.getMemoryMXBean(); - runtimeProxy = ManagementFactory.getRuntimeMXBean(); - asyncProfilerProxy = AsyncProfilerService.instance(); } @Override @@ -185,4 +175,78 @@ public class InternalNodeProbe extends NodeProbe { throw new UnsupportedOperationException(); } + + private static class TestMockMBeanAccessor implements MBeanAccessor + { + private final Map, Object> mbeanRegistry = new HashMap<>(); + + public TestMockMBeanAccessor() + { + registerMBean(StorageServiceMBean.class, StorageService.instance); + registerMBean(SnapshotManagerMBean.class, SnapshotManager.instance); + registerMBean(CMSOperationsMBean.class, CMSOperations.instance); + registerMBean(AccordOperationsMBean.class, AccordOperations.instance); + registerMBean(MessagingServiceMBean.class, MessagingService.instance()); + registerMBean(StreamManagerMBean.class, StreamManager.instance); + registerMBean(CompactionManagerMBean.class, CompactionManager.instance); + registerMBean(FailureDetectorMBean.class, (FailureDetectorMBean) FailureDetector.instance); + registerMBean(CacheServiceMBean.class, CacheService.instance); + registerMBean(StorageProxyMBean.class, StorageProxy.instance); + registerMBean(HintsServiceMBean.class, HintsService.instance); + registerMBean(GCInspectorMXBean.class, new GCInspector()); + registerMBean(GossiperMBean.class, Gossiper.instance); + registerMBean(BatchlogManagerMBean.class, BatchlogManager.instance); + registerMBean(ActiveRepairServiceMBean.class, ActiveRepairService.instance()); + registerMBean(MemoryMXBean.class, ManagementFactory.getMemoryMXBean()); + registerMBean(RuntimeMXBean.class, ManagementFactory.getRuntimeMXBean()); + registerMBean(AsyncProfilerMBean.class, AsyncProfilerService.instance()); + } + + protected void registerMBean(Class clazz, T mbean) + { + mbeanRegistry.put(clazz, mbean); + } + + @Override + public T findMBean(Class clazz) + { + return mbeanRegistry.get(clazz) == null ? null : clazz.cast(mbeanRegistry.get(clazz)); + } + + @Override + public T findMBeanMetric(Class clazz, Props props) + { + return null; + } + + @Override + public boolean isMBeanMetricRegistered(Props props) + { + return false; + } + + @Override + public ColumnFamilyStoreMBean findColumnFamily(String type, String keyspace, String columnFamily) + { + return null; + } + + @Override + public CompressionDictionaryManagerMBean findCompressionDictionary(String keyspace, String table) + { + return null; + } + + @Override + public List threadPoolInfos() + { + return List.of(); + } + + @Override + public List> findColumnFamilies(String type) + { + return List.of(); + } + } } diff --git a/test/distributed/org/apache/cassandra/distributed/test/ManagementRequestServingTest.java b/test/distributed/org/apache/cassandra/distributed/test/ManagementRequestServingTest.java new file mode 100644 index 0000000000..ac936f679e --- /dev/null +++ b/test/distributed/org/apache/cassandra/distributed/test/ManagementRequestServingTest.java @@ -0,0 +1,172 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.distributed.test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; + +import com.datastax.driver.core.ResultSet; +import com.datastax.driver.core.Session; + +import net.bytebuddy.ByteBuddy; +import net.bytebuddy.dynamic.loading.ClassLoadingStrategy; +import net.bytebuddy.implementation.MethodDelegation; +import net.bytebuddy.implementation.bind.annotation.SuperCall; + +import org.junit.Test; + +import org.apache.cassandra.concurrent.SEPExecutor; +import org.apache.cassandra.concurrent.SharedExecutorPool; +import org.apache.cassandra.cql3.QueryOptions; +import org.apache.cassandra.cql3.statements.SelectStatement; +import org.apache.cassandra.distributed.Cluster; +import org.apache.cassandra.service.QueryState; +import org.apache.cassandra.transport.Dispatcher; +import org.apache.cassandra.transport.messages.ResultMessage; + +import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.takesArguments; +import static org.apache.cassandra.distributed.api.Feature.GOSSIP; +import static org.apache.cassandra.distributed.api.Feature.NATIVE_PROTOCOL; +import static org.apache.cassandra.distributed.api.Feature.NETWORK; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * This test verifyes that management requests are served even when native transport is under a heavy + * load by driver's SELECT requests. We block these requests and verify that management requests are + * still processed. + */ +public class ManagementRequestServingTest extends TestBaseImpl +{ + private static final int MANAGEMENT_PORT = 11211; + private static final int NATIVE_PORT = 9042; + private static final int SELECT_REQUESTS_NUM = 10; + + @Test + public void testManagementRequestsServedUnderRegularPortLoad() throws Exception + { + ExecutorService executor = Executors.newFixedThreadPool(SELECT_REQUESTS_NUM); + try (Cluster cluster = init(Cluster.build(1) + .withInstanceInitializer(BlockingSelectByteBuddy::install) + .withConfig(config -> config.with(NETWORK, GOSSIP, NATIVE_PROTOCOL) + .set("start_native_transport_management", true) + .set("native_transport_management_port", MANAGEMENT_PORT)) + .start()); + com.datastax.driver.core.Cluster driver = com.datastax.driver.core.Cluster.builder() + .addContactPoint("127.0.0.1") + .withPort(NATIVE_PORT) + .build(); + Session session = driver.connect()) + { + cluster.schemaChange(withKeyspace("CREATE TABLE %s.tbl (pk int PRIMARY KEY, v int)")); + + long mgmtBefore = cluster.get(1).callOnInstance(() -> getCompletedTaskCountByExecutor("Native-Transport-Management-Tasks")); + long reqBefore = cluster.get(1).callOnInstance(() -> getCompletedTaskCountByExecutor("Native-Transport-Requests")); + + cluster.get(1).runOnInstance(() -> assertTrue(BlockingSelectByteBuddy.enabled.compareAndSet(false, true))); + + List> futures = new ArrayList<>(); + for (int i = 0; i < SELECT_REQUESTS_NUM; i++) + futures.add(CompletableFuture.supplyAsync(() -> session.execute("SELECT * FROM " + KEYSPACE + ".tbl"), executor)); + + cluster.get(1).runOnInstance(() -> { + try + { + BlockingSelectByteBuddy.latch.await(); + } + catch (InterruptedException e) + { + throw new RuntimeException(e); + } + }); + + try (com.datastax.driver.core.Cluster mgmt = com.datastax.driver.core.Cluster.builder() + .addContactPoint("127.0.0.1") + .withPort(MANAGEMENT_PORT) + .build(); + Session mgmtSession = mgmt.connect("system")) + { + ResultSet rs = mgmtSession.execute("SELECT * FROM system.local"); + assertFalse("Management query should return data", rs.all().isEmpty()); + } + + long mgmtAfter = cluster.get(1).callOnInstance(() -> getCompletedTaskCountByExecutor("Native-Transport-Management-Tasks")); + assertTrue("Management executor should have processed requests", mgmtAfter > mgmtBefore); + + cluster.get(1).runOnInstance(() -> BlockingSelectByteBuddy.signal.countDown()); + + for (Future f : futures) + f.get(); + + long reqAfter = cluster.get(1).callOnInstance(() -> getCompletedTaskCountByExecutor("Native-Transport-Requests")); + assertTrue("Request executor should have processed requests", reqAfter > reqBefore); + } + finally + { + executor.shutdown(); + } + } + + private static long getCompletedTaskCountByExecutor(String name) + { + return SharedExecutorPool.SHARED.executors.stream() + .filter(e -> e.name.endsWith(name)) + .findFirst() + .map(SEPExecutor::getCompletedTaskCount) + .orElse(0L); + } + + public static class BlockingSelectByteBuddy + { + static CountDownLatch latch = new CountDownLatch(SELECT_REQUESTS_NUM); + static CountDownLatch signal = new CountDownLatch(1); + static AtomicBoolean enabled = new AtomicBoolean(false); + + static void install(ClassLoader cl, int ignored) + { + new ByteBuddy().rebase(SelectStatement.class) + .method(named("execute") + .and(takesArguments(QueryState.class, QueryOptions.class, Dispatcher.RequestTime.class))) + .intercept(MethodDelegation.to(BlockingSelectByteBuddy.class)) + .make() + .load(cl, ClassLoadingStrategy.Default.INJECTION); + } + + public static ResultMessage.Rows execute(QueryState state, + QueryOptions options, + Dispatcher.RequestTime requestTime, + @SuperCall Callable r) throws Exception + { + if (enabled.get() && !state.getClientState().isInternal && !state.getClientState().isManagement()) + { + latch.countDown(); + signal.await(); + } + return r.call(); + } + } +} diff --git a/test/distributed/org/apache/cassandra/distributed/test/ManagementTransportMultiNodeTest.java b/test/distributed/org/apache/cassandra/distributed/test/ManagementTransportMultiNodeTest.java new file mode 100644 index 0000000000..8ba01351d1 --- /dev/null +++ b/test/distributed/org/apache/cassandra/distributed/test/ManagementTransportMultiNodeTest.java @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.distributed.test; + +import com.datastax.driver.core.Session; + +import org.junit.Test; + +import org.apache.cassandra.distributed.Cluster; + +import static org.apache.cassandra.distributed.api.Feature.GOSSIP; +import static org.apache.cassandra.distributed.api.Feature.NATIVE_PROTOCOL; +import static org.apache.cassandra.distributed.api.Feature.NETWORK; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +public class ManagementTransportMultiNodeTest extends TestBaseImpl +{ + private static final int MANAGEMENT_PORT = 11211; + + @Test + public void testMultiNodeManagementPort() throws Exception + { + int nodes = 3; + try (Cluster cluster = init(Cluster.build(nodes) + .withConfig(config -> config.with(NETWORK, GOSSIP, NATIVE_PROTOCOL) + .set("start_native_transport_management", true) + .set("native_transport_management_port", MANAGEMENT_PORT)) + .start())) + { + for (int i = 1; i <= nodes; i++) + { + String host = cluster.get(i).config().broadcastAddress().getAddress().getHostAddress(); + try (com.datastax.driver.core.Cluster c = com.datastax.driver.core.Cluster.builder() + .addContactPoint(host) + .withPort(MANAGEMENT_PORT) + .build(); + Session s = c.connect()) + { + assertFalse("Management port on node " + i + " should respond", + s.execute("SELECT * FROM system.local").all().isEmpty()); + } + } + + String node1Host = cluster.get(1).config().broadcastAddress().getAddress().getHostAddress(); + try (com.datastax.driver.core.Cluster c = com.datastax.driver.core.Cluster.builder() + .addContactPoint(node1Host) + .withPort(MANAGEMENT_PORT) + .build(); + Session s = c.connect()) + { + int peerCount = s.execute("SELECT * FROM system.peers").all().size(); + assertEquals("Node 1 should see other peers via management port", + nodes - 1, peerCount); + + assertFalse("Keyspaces should be visible via management port", + s.execute("SELECT * FROM system_schema.keyspaces").all().isEmpty()); + } + } + } +} diff --git a/test/distributed/org/apache/cassandra/distributed/test/ManagementTransportProtocolTest.java b/test/distributed/org/apache/cassandra/distributed/test/ManagementTransportProtocolTest.java new file mode 100644 index 0000000000..5e1dc30508 --- /dev/null +++ b/test/distributed/org/apache/cassandra/distributed/test/ManagementTransportProtocolTest.java @@ -0,0 +1,208 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.distributed.test; + +import com.datastax.driver.core.Session; +import com.datastax.driver.core.exceptions.InvalidQueryException; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.cassandra.distributed.Cluster; +import org.apache.cassandra.tools.ToolRunner; +import org.apache.cassandra.tools.ToolRunner.ToolResult; + +import static org.apache.cassandra.distributed.api.Feature.GOSSIP; +import static org.apache.cassandra.distributed.api.Feature.NATIVE_PROTOCOL; +import static org.apache.cassandra.distributed.api.Feature.NETWORK; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * End-to-end tests for the management native transport protocol (CEP-38). + * + * @see org.apache.cassandra.service.NativeTransportManagementService + */ +public class ManagementTransportProtocolTest extends TestBaseImpl +{ + private static final Logger logger = LoggerFactory.getLogger(ManagementTransportProtocolTest.class); + + private static final int MANAGEMENT_PORT = 11211; + private static final int NATIVE_PORT = 9042; + private static Cluster CLUSTER; + + @BeforeClass + public static void setupCluster() throws Exception + { + CLUSTER = init(Cluster.build(1) + .withConfig(config -> config.with(NETWORK, GOSSIP, NATIVE_PROTOCOL) + .set("start_native_transport_management", true) + .set("native_transport_management_port", MANAGEMENT_PORT)) + .start()); + } + + @AfterClass + public static void teardownCluster() + { + if (CLUSTER == null) + return; + CLUSTER.close(); + } + + @Test + public void testManagementPortRemainsUpWhenBinaryDisabled() throws Throwable + { + assertTrue("Regular port accessible", isAlive(NATIVE_PORT)); + assertTrue("Management port accessible", isAlive(MANAGEMENT_PORT)); + + ToolResult tool = ToolRunner.invokeNodetoolJvmDtest(CLUSTER.get(1), "disablebinary"); + assertEquals(0, tool.getExitCode()); + assertFalse("Regular port NOT accessible after disable", isAlive(NATIVE_PORT)); + assertTrue("Management port still accessible after disable", isAlive(MANAGEMENT_PORT)); + + tool = ToolRunner.invokeNodetoolJvmDtest(CLUSTER.get(1), "enablebinary"); + assertEquals(0, tool.getExitCode()); + assertTrue("Regular port accessible after re-enable", isAlive(NATIVE_PORT)); + } + + @Test + public void testSelectOnSystemKeyspacesAllowed() + { + try (com.datastax.driver.core.Cluster c = driverOnPort(MANAGEMENT_PORT); + Session s = c.connect()) + { + assertFalse(s.execute("SELECT * FROM system.local").all().isEmpty()); + // system.peers is empty on single-node + assertTrue(s.execute("SELECT * FROM system.peers").all().isEmpty()); + assertFalse(s.execute("SELECT * FROM system_schema.keyspaces").all().isEmpty()); + assertFalse(s.execute("SELECT * FROM system_schema.tables").all().isEmpty()); + assertFalse(s.execute("SELECT * FROM system_schema.columns").all().isEmpty()); + } + } + + @Test + public void testSelectOnVirtualKeyspacesAllowed() + { + try (com.datastax.driver.core.Cluster c = driverOnPort(MANAGEMENT_PORT); + Session s = c.connect()) + { + s.execute("SELECT * FROM system_virtual_schema.keyspaces"); + s.execute("SELECT * FROM system_virtual_schema.tables"); + } + } + + @Test(expected = InvalidQueryException.class) + public void testSelectOnUserKeyspaceRejected() + { + CLUSTER.schemaChange("CREATE TABLE IF NOT EXISTS " + KEYSPACE + ".mgmt_test (pk int PRIMARY KEY, v int)"); + try (com.datastax.driver.core.Cluster c = driverOnPort(MANAGEMENT_PORT); + Session s = c.connect()) + { + s.execute("SELECT * FROM " + KEYSPACE + ".mgmt_test"); + } + } + + @Test(expected = InvalidQueryException.class) + public void testInsertRejectedOnManagementPort() + { + try (com.datastax.driver.core.Cluster c = driverOnPort(MANAGEMENT_PORT); + Session s = c.connect()) + { + s.execute("INSERT INTO system.local (key) VALUES ('test')"); + } + } + + @Test(expected = InvalidQueryException.class) + public void testDDLRejectedOnManagementPort() + { + try (com.datastax.driver.core.Cluster c = driverOnPort(MANAGEMENT_PORT); + Session s = c.connect()) + { + s.execute("CREATE TABLE " + KEYSPACE + ".should_fail (pk int PRIMARY KEY)"); + } + } + + @Test(expected = InvalidQueryException.class) + public void testUnqualifiedSelectRejected() + { + try (com.datastax.driver.core.Cluster c = driverOnPort(MANAGEMENT_PORT); + Session s = c.connect()) + { + s.execute("SELECT * FROM local"); + } + } + + /** + * This is also a corner case for the driver's behavior on the management port. + * When connecting, the driver sends a USE statement for the keyspace provided in connect(). + * If that keyspace is a system keyspace, the USE msut succeeds, and subsequent queries without + * keyspace qualification are allowed. If that keyspace is a user keyspace, the USE should fail. + */ + @Test + public void testConnectWithSystemKeyspaceAllowed() + { + try (com.datastax.driver.core.Cluster c = driverOnPort(MANAGEMENT_PORT); + Session s = c.connect("system_schema")) + { + // Driver sends USE system_schema, then we query a table without qualification + assertFalse(s.execute("SELECT * FROM system_schema.keyspaces").all().isEmpty()); + } + } + + @Test + public void testConnectWithUserKeyspaceRejected() + { + try (com.datastax.driver.core.Cluster c = driverOnPort(MANAGEMENT_PORT); + Session s = c.connect(KEYSPACE)) + { + fail("Should not be able to USE a user keyspace on management port"); + } + catch (Exception e) + { + logger.debug("Expected failure when connecting with user keyspace: {}", e.getMessage()); + } + } + + private static com.datastax.driver.core.Cluster driverOnPort(int port) + { + return com.datastax.driver.core.Cluster.builder() + .addContactPoint("127.0.0.1") + .withPort(port) + .build(); + } + + private static boolean isAlive(int port) + { + try (com.datastax.driver.core.Cluster c = driverOnPort(port); + Session s = c.connect()) + { + s.execute("SELECT * FROM system.local"); + return true; + } + catch (Exception e) + { + return false; + } + } +} diff --git a/test/distributed/org/apache/cassandra/distributed/test/NodeToolEnableDisableBinaryTest.java b/test/distributed/org/apache/cassandra/distributed/test/NodeToolEnableDisableBinaryTest.java index 793f311a5b..9250abb761 100644 --- a/test/distributed/org/apache/cassandra/distributed/test/NodeToolEnableDisableBinaryTest.java +++ b/test/distributed/org/apache/cassandra/distributed/test/NodeToolEnableDisableBinaryTest.java @@ -18,13 +18,10 @@ package org.apache.cassandra.distributed.test; -import java.io.IOException; - +import com.datastax.driver.core.Cluster; import com.datastax.driver.core.Session; import org.assertj.core.api.Assertions; -import org.junit.AfterClass; -import org.junit.Before; import org.junit.Test; import org.apache.cassandra.distributed.api.ICluster; @@ -46,52 +43,96 @@ import static org.junit.Assert.assertTrue; */ public class NodeToolEnableDisableBinaryTest extends TestBaseImpl { - private static ICluster cluster; - - @Before - public void setupEnv() throws IOException - { - if (cluster == null) - { - cluster = init(builder().withNodes(1) - .withConfig(config -> config.with(NETWORK, GOSSIP, NATIVE_PROTOCOL)) - .start()); - cluster.get(1).nodetool("disableautocompaction"); - } - } - - @AfterClass - public static void teardownEnv() throws Exception - { - cluster.close(); - } - @Test public void testEnableDisableBinary() throws Throwable { - // We can connect - assertTrue(canConnect()); + try (ICluster nodeCluster = init(builder().withNodes(1) + .withConfig(config -> config.with(NETWORK, GOSSIP, NATIVE_PROTOCOL)) + .start())) + { + nodeCluster.get(1).nodetool("disableautocompaction"); - // We can't connect after disabling - ToolResult tool = ToolRunner.invokeNodetoolJvmDtest(cluster.get(1), "disablebinary"); - Assertions.assertThat(tool.getStdout()).containsIgnoringCase("Stop listening for CQL clients"); - assertTrue(tool.getCleanedStderr().isEmpty()); - assertEquals(0, tool.getExitCode()); - assertFalse(canConnect()); + // We can connect + assertTrue(canConnect()); - // We can connect after re-enabling - tool = ToolRunner.invokeNodetoolJvmDtest(cluster.get(1), "enablebinary"); - Assertions.assertThat(tool.getStdout()).containsIgnoringCase("Starting listening for CQL clients"); - assertTrue(tool.getCleanedStderr().isEmpty()); - assertEquals(0, tool.getExitCode()); - assertTrue(canConnect()); + // We can't connect after disabling + ToolResult tool = ToolRunner.invokeNodetoolJvmDtest(nodeCluster.get(1), "disablebinary"); + Assertions.assertThat(tool.getStdout()).containsIgnoringCase("Stop listening for CQL clients"); + assertTrue(tool.getCleanedStderr().isEmpty()); + assertEquals(0, tool.getExitCode()); + assertFalse(canConnect()); + + // We can connect after re-enabling + tool = ToolRunner.invokeNodetoolJvmDtest(nodeCluster.get(1), "enablebinary"); + Assertions.assertThat(tool.getStdout()).containsIgnoringCase("Starting listening for CQL clients"); + assertTrue(tool.getCleanedStderr().isEmpty()); + assertEquals(0, tool.getExitCode()); + assertTrue(canConnect()); + } + } + + /** + * This is an important corner case to verify that when binary protocol is disabled, + * we can still connect to the management port and query system tables. This case + * must be explicitly tested because the management port is used by ops and other + * monitoring tools to control the cluster. + * + * @throws Throwable If any exception occurs during the test. + */ + @Test + public void testSystemQueriesViaManagementPortWhenBinaryDisabled() throws Throwable + { + int managementPort = 11211; + int numberOfNodes = 1; + try (ICluster managementCluster = init(builder().withNodes(numberOfNodes) + .withConfig(config -> config.with(NETWORK, GOSSIP, NATIVE_PROTOCOL) + .set("start_native_transport_management", true) + .set("native_transport_management_port", managementPort)) + .start())) + { + managementCluster.get(1).nodetool("disableautocompaction"); + assertTrue("Regular native CQL port should is accessible", canConnect()); + assertTrue("Management port should is accessible", canConnect(managementPort)); + + ToolResult tool = ToolRunner.invokeNodetoolJvmDtest(managementCluster.get(1), "disablebinary"); + Assertions.assertThat(tool.getStdout()).containsIgnoringCase("Stop listening for CQL clients"); + assertEquals(0, tool.getExitCode()); + assertFalse("Regular native CQL port should NOT be accessible", canConnect()); + assertTrue("Management port should is accessible", canConnect(managementPort)); + + try (Cluster c = Cluster.builder() + .addContactPoint("127.0.0.1") + .withPort(managementPort) + .build(); + Session s = c.connect()) + { + assertFalse("system.local should return data", + s.execute("SELECT * FROM system.local").all().isEmpty()); + assertTrue("system.peers is NOT empty, howeverwe only have one node in the cluster", + s.execute("SELECT * FROM system.peers").all().isEmpty()); + assertFalse("system_schema.keyspaces should return data", + s.execute("SELECT * FROM system_schema.keyspaces").all().isEmpty()); + assertFalse("system_schema.tables should return data", + s.execute("SELECT * FROM system_schema.tables").all().isEmpty()); + } + + tool = ToolRunner.invokeNodetoolJvmDtest(managementCluster.get(1), "enablebinary"); + assertEquals(0, tool.getExitCode()); + assertTrue("Should connect to regular binary after re-enabling", canConnect()); + } } private boolean canConnect() + { + return canConnect(9042); + } + + private boolean canConnect(int port) { boolean canConnect = false; try(com.datastax.driver.core.Cluster c = com.datastax.driver.core.Cluster.builder() .addContactPoint("127.0.0.1") + .withPort(port) .build(); Session s = c.connect("system_schema")) { diff --git a/test/distributed/org/apache/cassandra/distributed/test/NodeToolTest.java b/test/distributed/org/apache/cassandra/distributed/test/NodeToolTest.java index f85a0efe4f..6960ca24db 100644 --- a/test/distributed/org/apache/cassandra/distributed/test/NodeToolTest.java +++ b/test/distributed/org/apache/cassandra/distributed/test/NodeToolTest.java @@ -27,6 +27,7 @@ import org.junit.Test; import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.distributed.Cluster; +import org.apache.cassandra.distributed.api.ConsistencyLevel; import org.apache.cassandra.distributed.api.ICluster; import org.apache.cassandra.distributed.api.IInvokableInstance; import org.apache.cassandra.distributed.api.NodeToolResult; @@ -141,6 +142,24 @@ public class NodeToolTest extends TestBaseImpl } } + @Test + public void testCompactionCommandsThroughMockProbe() + { + String table = "compaction_history_tbl"; + CLUSTER.schemaChange("CREATE TABLE " + KEYSPACE + '.' + table + " (k int PRIMARY KEY, v int)"); + assertEquals(0, NODE.nodetool("disableautocompaction", KEYSPACE)); + for (int i = 0; i < 2; i++) + { + CLUSTER.coordinator(1).execute("INSERT INTO " + KEYSPACE + '.' + table + " (k, v) VALUES (?, ?)", + ConsistencyLevel.ONE, i, i); + NODE.flush(KEYSPACE); + } + NODE.forceCompact(KEYSPACE, table); + + NODE.nodetoolResult("compactionhistory").asserts().success().stdoutContains(table); + NODE.nodetoolResult("stop", "COMPACTION").asserts().success(); + } + @Test public void testVersionIncludesGitSHAWhenVerbose() throws Throwable { diff --git a/test/unit/org/apache/cassandra/cql3/CQLNodetoolProtocolTester.java b/test/unit/org/apache/cassandra/cql3/CQLNodetoolProtocolTester.java new file mode 100644 index 0000000000..72f710839a --- /dev/null +++ b/test/unit/org/apache/cassandra/cql3/CQLNodetoolProtocolTester.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.cql3; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.regex.Pattern; + +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import org.apache.cassandra.config.CassandraRelevantProperties; +import org.apache.cassandra.distributed.shared.WithProperties; +import org.apache.cassandra.tools.NodeTool; +import org.apache.cassandra.tools.ToolRunner; +import org.apache.cassandra.tools.nodetool.strategy.CommandExecutionStrategy; + +@RunWith(Parameterized.class) +public abstract class CQLNodetoolProtocolTester extends CQLTester +{ + protected static final Pattern EXECUTION_ID_UUID_PATTERN = + Pattern.compile("Command execution id: [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\s*"); + + @Parameterized.Parameter + public CommandExecutionStrategy.Type strategy; + + @Parameterized.Parameters(name = "strategy={0}") + public static Collection data() + { + List params = new ArrayList<>(); + for (CommandExecutionStrategy.Type type : CommandExecutionStrategy.Type.values()) + params.add(new Object[]{type}); + return params; + } + + public ToolRunner.ToolResult invokeNodetool(List args) + { + return invokeNodetool(args.toArray(new String[0])); + } + + public ToolRunner.ToolResult invokeNodetool(String... args) + { + // Use invokeNodetoolInJvm for faster execution of the command operations and + // enabling easier debugging when running in debug mode from IDE. There is also + // no need to run 'ant jars' to run nodetool commands in this test after code changes. + try (WithProperties with = new WithProperties().set(CassandraRelevantProperties.CASSANDRA_CLI_EXECUTION_PROTOCOL, + strategy.name().toLowerCase())) + { + return ToolRunner.invokeNodetoolInJvm(NodeTool::new, + strategy == CommandExecutionStrategy.Type.CQL ? + CQLTester::buildNodetoolCqlArgs : + CQLTester::buildNodetoolArgs, + args); + } + } +} diff --git a/test/unit/org/apache/cassandra/cql3/CQLTester.java b/test/unit/org/apache/cassandra/cql3/CQLTester.java index 6f836c0e86..881cfa5efb 100644 --- a/test/unit/org/apache/cassandra/cql3/CQLTester.java +++ b/test/unit/org/apache/cassandra/cql3/CQLTester.java @@ -173,6 +173,7 @@ import org.apache.cassandra.io.sstable.Descriptor; import org.apache.cassandra.io.util.File; import org.apache.cassandra.io.util.FileSystems; import org.apache.cassandra.io.util.FileUtils; +import org.apache.cassandra.management.CommandInvokerService; import org.apache.cassandra.metrics.CassandraMetricsRegistry; import org.apache.cassandra.metrics.ClientMetrics; import org.apache.cassandra.net.MessagingService; @@ -276,12 +277,14 @@ public abstract class CQLTester */ private static boolean coordinatorExecution = false; + private static org.apache.cassandra.transport.Server managementServer; private static org.apache.cassandra.transport.Server server; private static JMXConnectorServer jmxServer; protected static String jmxHost; protected static int jmxPort; protected static MBeanServerConnection jmxConnection; + protected static int managementPort; protected static int nativePort; protected static final InetAddress nativeAddr; private static final Map clusters = new HashMap<>(); @@ -327,6 +330,7 @@ public abstract class CQLTester nativeAddr = InetAddress.getLoopbackAddress(); nativePort = getAutomaticallyAllocatedPort(nativeAddr); + managementPort = getAutomaticallyAllocatedPort(nativeAddr); } private List keyspaces = new ArrayList<>(); @@ -485,6 +489,7 @@ public abstract class CQLTester StorageService.instance.setPartitionerUnsafe(Murmur3Partitioner.instance); SnapshotManager.instance.registerMBean(); SYSTEM_DISTRIBUTED_DEFAULT_RF.setInt(1); + CommandInvokerService.instance.start(); } // So derived classes can get enough intialization to start setting DatabaseDescriptor options @@ -504,6 +509,9 @@ public abstract class CQLTester if (server != null) server.stop(); + if (managementServer != null) + managementServer.stop(); + // We use queryInternal for CQLTester so prepared statement will populate our internal cache (if reusePrepared is used; otherwise prepared // statements are not cached but re-prepared every time). So we clear the cache between test files to avoid accumulating too much. if (reusePrepared) @@ -519,6 +527,8 @@ public abstract class CQLTester { logger.warn("Error shutting down jmx", e); } + + CommandInvokerService.instance.stop(); } } @@ -601,6 +611,29 @@ public abstract class CQLTester return allArgs; } + public static List buildNodetoolCqlArgs(List args) + { + List allArgs = new ArrayList<>(); + allArgs.add("bin/nodetool"); + allArgs.add("-p"); + allArgs.add(Integer.toString(managementPort)); + allArgs.add("-h"); + allArgs.add(nativeAddr.getHostAddress()); + allArgs.addAll(args); + return allArgs; + } + + public static List buildCqlshManagementArgs(List args) + { + List allArgs = new ArrayList<>(); + allArgs.add("bin/cqlsh"); + allArgs.add(nativeAddr.getHostAddress()); + allArgs.add(Integer.toString(managementPort)); + allArgs.add("-e"); + allArgs.addAll(args); + return allArgs; + } + public static List buildCqlshArgs(List args) { List allArgs = new ArrayList<>(); @@ -721,6 +754,7 @@ public abstract class CQLTester startServices(); startServer(serverConfigurator); + startManagementServer(server -> {}); } protected static void requireNetworkWithoutDriver() @@ -730,6 +764,7 @@ public abstract class CQLTester startServices(); startServer(server -> {}); + startManagementServer(server -> {}); } private static void startServices() @@ -765,6 +800,13 @@ public abstract class CQLTester clusterBuilderConfigurator = clusterConfigurator; startServer(serverConfigurator); + + if (managementServer != null && managementServer.isRunning()) + { + managementServer.stop(); + managementServer = null; + } + startManagementServer(serverConfigurator); } private static void startServer(Consumer decorator) @@ -777,6 +819,17 @@ public abstract class CQLTester server.start(); } + private static void startManagementServer(Consumer decorator) + { + managementPort = getAutomaticallyAllocatedPort(nativeAddr); + Server.Builder serverBuilder = new Server.Builder().withHost(nativeAddr) + .withPort(managementPort) + .withManagementConnectionFlag(true); + decorator.accept(serverBuilder); + managementServer = serverBuilder.build(); + managementServer.start(); + } + private static Cluster initClientCluster(User user, ProtocolVersion version, boolean useEncryption, boolean useClientCert) { SocketOptions socketOptions = diff --git a/test/unit/org/apache/cassandra/db/SnapshotTest.java b/test/unit/org/apache/cassandra/db/SnapshotTest.java index 4db0941736..520c418194 100644 --- a/test/unit/org/apache/cassandra/db/SnapshotTest.java +++ b/test/unit/org/apache/cassandra/db/SnapshotTest.java @@ -23,13 +23,13 @@ import java.nio.file.StandardOpenOption; import org.junit.Test; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.io.sstable.format.SSTableFormat.Components; import org.apache.cassandra.io.sstable.format.SSTableReader; import org.apache.cassandra.io.util.File; import org.apache.cassandra.service.snapshot.SnapshotManager; -public class SnapshotTest extends CQLTester +public class SnapshotTest extends CQLNodetoolProtocolTester { @Test public void testEmptyTOC() throws Throwable diff --git a/test/unit/org/apache/cassandra/db/compression/CompressionDictionaryIntegrationTest.java b/test/unit/org/apache/cassandra/db/compression/CompressionDictionaryIntegrationTest.java index eb1d325377..4e9814c16c 100644 --- a/test/unit/org/apache/cassandra/db/compression/CompressionDictionaryIntegrationTest.java +++ b/test/unit/org/apache/cassandra/db/compression/CompressionDictionaryIntegrationTest.java @@ -87,8 +87,8 @@ public class CompressionDictionaryIntegrationTest extends CQLTester Map.of(TRAINING_MAX_DICTIONARY_SIZE_PARAMETER_NAME, TRAINING_MAX_DICTIONARY_SIZE, TRAINING_MAX_TOTAL_SAMPLE_SIZE_PARAMETER_NAME, TRAINING_MAX_TOTAL_SAMPLE_SIZE))) .as("Should disallow manual training when using lz4") - .isInstanceOf(UnsupportedOperationException.class) - .hasMessageContaining("does not support dictionary compression"); + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("is not enabled or SSTable compressor is not a dictionary compressor"); // Re-enable dictionary compression CompressionParams dictParams = CompressionParams.zstd(CompressionParams.DEFAULT_CHUNK_LENGTH, true, diff --git a/test/unit/org/apache/cassandra/db/compression/CompressionDictionaryManagerTest.java b/test/unit/org/apache/cassandra/db/compression/CompressionDictionaryManagerTest.java index be7d0d0dfb..fe9bacb896 100644 --- a/test/unit/org/apache/cassandra/db/compression/CompressionDictionaryManagerTest.java +++ b/test/unit/org/apache/cassandra/db/compression/CompressionDictionaryManagerTest.java @@ -176,8 +176,8 @@ public class CompressionDictionaryManagerTest public void testTrainManualWithNonDictionaryTable() { assertThatThrownBy(() -> managerWithoutDict.train(false)) - .isInstanceOf(UnsupportedOperationException.class) - .hasMessageContaining("does not support dictionary compression"); + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("is not enabled or SSTable compressor is not a dictionary compressor"); } @Test diff --git a/test/unit/org/apache/cassandra/db/compression/CompressionDictionaryTrainingFrequencyTest.java b/test/unit/org/apache/cassandra/db/compression/CompressionDictionaryTrainingFrequencyTest.java index 11bb2b17ca..fd304b1d07 100644 --- a/test/unit/org/apache/cassandra/db/compression/CompressionDictionaryTrainingFrequencyTest.java +++ b/test/unit/org/apache/cassandra/db/compression/CompressionDictionaryTrainingFrequencyTest.java @@ -25,11 +25,12 @@ import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.junit.After; import org.junit.BeforeClass; import org.junit.Test; import org.apache.cassandra.config.DurationSpec; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.db.ColumnFamilyStore; import org.apache.cassandra.db.compression.CompressionDictionary.LightweightCompressionDictionary; import org.apache.cassandra.db.compression.CompressionDictionaryDetailsTabularData.CompressionDictionaryDataObject; @@ -43,10 +44,9 @@ import org.apache.cassandra.utils.JsonUtils; import org.apache.cassandra.utils.Pair; import static java.lang.String.format; -import static org.apache.cassandra.tools.ToolRunner.invokeNodetool; import static org.assertj.core.api.Assertions.assertThat; -public class CompressionDictionaryTrainingFrequencyTest extends CQLTester +public class CompressionDictionaryTrainingFrequencyTest extends CQLNodetoolProtocolTester { private static final String tableName = "mytable"; @@ -57,6 +57,14 @@ public class CompressionDictionaryTrainingFrequencyTest extends CQLTester startJMXServer(); } + @After + public void afterCompressionDictionaryTest() throws Throwable + { + execute(format("TRUNCATE %s.%s", + SchemaConstants.DISTRIBUTED_KEYSPACE_NAME, + SystemDistributedKeyspace.COMPRESSION_DICTIONARIES)); + } + @Test public void testTrainingFrequency() throws Throwable { @@ -169,7 +177,7 @@ public class CompressionDictionaryTrainingFrequencyTest extends CQLTester // Test training command with --force since we have limited test data ToolRunner.ToolResult result = invokeNodetool("compressiondictionary", "train", "--force", keyspace(), tableName); - assertThat(result.getExitCode()).isEqualTo(1); + assertThat(result.getExitCode()).isEqualTo(2); assertThat(result.getStderr()) .as("Should indicate training can not be triggered") @@ -181,12 +189,12 @@ public class CompressionDictionaryTrainingFrequencyTest extends CQLTester .findFirst() .orElseThrow(() -> new RuntimeException("Unable to find failing message")); - String pattern = "Failed to trigger training: The next training or importing can occur only at least after " + + String pattern = "The next training or importing can occur only at least after " + "(.*) from the last training which happened at (.*). " + "You can train again no earlier than at (.*)."; Matcher matcher = Pattern.compile(pattern).matcher(failingMessage); - assertThat(matcher.matches()).isTrue(); + assertThat(matcher.find()).isTrue(); DurationSpec.IntMinutesBound frequencySpec = new DurationSpec.IntMinutesBound(matcher.group(1)); Instant lastTraining = Instant.parse(matcher.group(2)); @@ -199,7 +207,7 @@ public class CompressionDictionaryTrainingFrequencyTest extends CQLTester private void assertFailingImport(File file) { ToolRunner.ToolResult result = invokeNodetool("compressiondictionary", "import", file.absolutePath()); - assertThat(result.getExitCode()).isEqualTo(1); + assertThat(result.getExitCode()).isEqualTo(2); } private void assertSuccessfulImport(File file) diff --git a/test/unit/org/apache/cassandra/db/guardrails/GuardrailTablePropertiesTest.java b/test/unit/org/apache/cassandra/db/guardrails/GuardrailTablePropertiesTest.java index f70a4244b8..430c910e10 100644 --- a/test/unit/org/apache/cassandra/db/guardrails/GuardrailTablePropertiesTest.java +++ b/test/unit/org/apache/cassandra/db/guardrails/GuardrailTablePropertiesTest.java @@ -18,9 +18,11 @@ package org.apache.cassandra.db.guardrails; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; @@ -35,6 +37,7 @@ import org.apache.cassandra.config.GuardrailsOptions; import org.apache.cassandra.cql3.statements.schema.TableAttributes; import static java.lang.String.format; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; /** @@ -96,6 +99,18 @@ public class GuardrailTablePropertiesTest extends GuardrailTester assertInvalidPropertyCSV("invalid1,comment,invalid2", "[invalid1, invalid2]"); } + @Test + public void testInsertionOrderPreservedOnSet() + { + LinkedHashSet properties = new LinkedHashSet<>(Arrays.asList("comment", "cdc", "gc_grace_seconds")); + guardrails().setTablePropertiesWarned(properties); + + Set result = guardrails().getTablePropertiesWarned(); + assertThat(result).isInstanceOf(LinkedHashSet.class); + assertEquals(Arrays.asList("comment", "cdc", "gc_grace_seconds"), + new ArrayList<>(result)); + } + private void assertValidProperty(Set properties) { assertValidProperty(Guardrails::setTablePropertiesWarned, Guardrails::getTablePropertiesWarned, properties); diff --git a/test/unit/org/apache/cassandra/management/CommandMBeanAdapterTest.java b/test/unit/org/apache/cassandra/management/CommandMBeanAdapterTest.java new file mode 100644 index 0000000000..e0c8dca363 --- /dev/null +++ b/test/unit/org/apache/cassandra/management/CommandMBeanAdapterTest.java @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management; + +import org.junit.Test; +import org.mockito.Mockito; + +import org.apache.cassandra.management.api.Command; +import org.apache.cassandra.management.api.CommandMetadata; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class CommandMBeanAdapterTest +{ + private static CommandMBeanAdapter newAdapter() + { + @SuppressWarnings("unchecked") + Command command = Mockito.mock(Command.class); + Mockito.when(command.metadata()).thenReturn(Mockito.mock(CommandMetadata.class)); + // Executor must never be reached for the invalid-argument cases below. + CommandInvokerService.Executor executor = (name, args) -> { + throw new AssertionError("Executor should not be invoked for invalid arguments"); + }; + return new CommandMBeanAdapter("version", command, executor); + } + + @Test + public void invokeRejectsNonStringArgument() + { + assertThatThrownBy(() -> newAdapter().invoke("invoke", new Object[]{ 42 }, new String[]{ "int" })) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exactly one parameter"); + } + + @Test + public void invokeRejectsWrongArgumentCount() + { + assertThatThrownBy(() -> newAdapter().invoke("invoke", new Object[]{ "a", "b" }, new String[]{ "String", "String" })) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exactly one parameter"); + } + + @Test + public void invokeRejectsNullParams() + { + assertThatThrownBy(() -> newAdapter().invoke("invoke", null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exactly one parameter"); + } + + @Test + public void invokeRejectsUnknownAction() + { + assertThatThrownBy(() -> newAdapter().invoke("bogus", new Object[]{ "{}" }, new String[]{ "String" })) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("Unknown operation"); + } +} diff --git a/test/unit/org/apache/cassandra/management/CommandServiceTest.java b/test/unit/org/apache/cassandra/management/CommandServiceTest.java new file mode 100644 index 0000000000..1b4af70a2a --- /dev/null +++ b/test/unit/org/apache/cassandra/management/CommandServiceTest.java @@ -0,0 +1,202 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import javax.management.MBeanServer; +import javax.management.ObjectName; + +import org.junit.Test; + +import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.management.api.Command; +import org.apache.cassandra.management.api.CommandExecutionArgs; +import org.apache.cassandra.management.api.CommandMetadata; +import org.apache.cassandra.utils.MBeanWrapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.Assert.assertNotNull; + +public class CommandServiceTest extends CQLTester +{ + private static final String COMMAND_MBEAN_PATTERN = "org.apache.cassandra.management:type=Command,name=*"; + + @Test + public void testCommandServiceMBeanRegistered() + { + try + { + ObjectName serviceName = new ObjectName(CommandInvokerServiceMBean.MBEAN_NAME); + assertThat(MBeanWrapper.instance.isRegistered(serviceName)).as("CommandInvokerService MBean should be registered after start()").isTrue(); + } + catch (Exception e) + { + throw new AssertionError("Failed to check MBean registration", e); + } + } + + @Test + public void testCommandMBeansRegistered() + { + CommandInvokerService service = CommandInvokerService.instance; + try + { + ObjectName pattern = new ObjectName(COMMAND_MBEAN_PATTERN); + Set commandMBeans = MBeanWrapper.instance.queryNames(pattern, null); + + assertThat(commandMBeans).as("At least one Command MBean should be registered").isNotEmpty(); + + String[] commandNames = service.getCommandNames(); + assertThat(commandNames).as("Service should return command names").isNotEmpty(); + + for (String commandName : commandNames) + { + String mbeanName = service.getCommandMBeanName(commandName); + assertNotNull("MBean name should not be null for command: " + commandName, mbeanName); + + ObjectName objectName = new ObjectName(mbeanName); + assertThat(MBeanWrapper.instance.isRegistered(objectName)).as("Command MBean should be registered for: " + commandName).isTrue(); + } + } + catch (Exception e) + { + throw new AssertionError("Failed to verify Command MBeans", e); + } + } + + @Test + public void testUnsupportedCommandsNotRegistered() + { + CommandInvokerService service = CommandInvokerService.instance; + String[] commandNames = service.getCommandNames(); + + for (String unsupported : CassandraCommandRegistry.UNSUPPORTED_COMMANDS) + { + Command cmd = service.getRegistry().command(unsupported); + assertThat(cmd).as("Unsupported command '%s' should not be in the registry", unsupported).isNull(); + + assertThat(Arrays.asList(commandNames)) + .as("Unsupported command '%s' should not appear in getCommandNames()", unsupported) + .noneMatch(name -> name.equals(unsupported) || name.startsWith(unsupported + ".")); + } + } + + @Test + public void testCommandMBeanInvoke() + { + CommandInvokerService service = CommandInvokerService.instance; + + try + { + for (String testCommand : service.getCommandNames()) + { + String mbeanName = service.getCommandMBeanName(testCommand); + ObjectName commandObjectName = new ObjectName(mbeanName); + assertThat(MBeanWrapper.instance.isRegistered(commandObjectName)).as("Command MBean should be registered").isTrue(); + + MBeanServer mbs = MBeanWrapper.instance.getMBeanServer(); + String schema = (String) mbs.invoke(commandObjectName, "getJsonSchema", null, null); + assertThat(schema).as("getJsonSchema() should return non-null JSON string").isNotNull().isNotEmpty(); + + assertThat(schema.trim()).as("Schema should start with '{'").startsWith("{"); + } + } + catch (Exception e) + { + throw new AssertionError("Failed to test CommandMBeanAdapter invoke", e); + } + } + + /** + * A successful execution is recorded in the bounded execution history as a success. + */ + @Test + public void testSuccessfulExecutionRecordedInHistory() throws Exception + { + CommandInvokerService service = CommandInvokerService.instance; + UUID executionId = service.invokeCommand("version", CommandServiceTest::emptyArgs).getExecutionId(); + + CommandInvokerService.ExecutionHistory record = findInHistory(service, executionId); + assertThat(record).as("Successful 'version' execution should be recorded in history").isNotNull(); + assertThat(record.isSuccess()).as("Record should be marked successful").isTrue(); + assertThat(record.commandName()).isEqualTo("version"); + } + + @Test + public void testValidationFailureRecordedInHistory() + { + CommandInvokerService service = CommandInvokerService.instance; + + int before = service.executionHistory().size(); + assertThatThrownBy(() -> service.invokeCommand("getauthcacheconfig", CommandServiceTest::emptyArgs)) + .isInstanceOf(CommandValidationException.class); + + List history = service.executionHistory(); + assertThat(history.size()).as("Failed execution should still be recorded").isGreaterThan(before); + + CommandInvokerService.ExecutionHistory last = history.get(history.size() - 1); + assertThat(last.commandName()).isEqualTo("getauthcacheconfig"); + assertThat(last.isSuccess()).as("Validation failure should be recorded as not successful").isFalse(); + assertThat(last.error()).as("Failure record should retain the error").isNotNull(); + } + + @Test + public void testMalformedJsonMappedToValidationException() + { + CommandInvokerService service = CommandInvokerService.instance; + CommandMetadata metadata = service.getRegistry().command("version").metadata(); + + assertThatThrownBy(() -> service.invokeCommand("version", () -> CommandExecutionArgsSerde.fromJson("{not valid json", metadata))) + .isInstanceOf(CommandValidationException.class) + .hasMessageContaining("Bad usage"); + } + + @Test + public void testMalformedJsonReportedAsValidationErrorViaMBean() + { + CommandInvokerService service = CommandInvokerService.instance; + Command command = service.getRegistry().command("version"); + CommandMBeanAdapter adapter = new CommandMBeanAdapter("version", command, service::invokeCommand); + + assertThatThrownBy(() -> adapter.invoke(CommandMBeanAdapter.INVOKE_METHOD, + new Object[]{ "{not valid json" }, + new String[]{ String.class.getName() })) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Error decoding JSON string"); + } + + private static CommandExecutionArgs emptyArgs() + { + return new SimpleCommandExecutionArgs(Collections.emptyMap(), Collections.emptyMap()); + } + + private static CommandInvokerService.ExecutionHistory findInHistory(CommandInvokerService service, UUID executionId) + { + return service.executionHistory().stream() + .filter(r -> executionId.equals(r.executionId())) + .reduce((first, second) -> second) // last match + .orElse(null); + } +} diff --git a/test/unit/org/apache/cassandra/management/CqlExecuteCommandTest.java b/test/unit/org/apache/cassandra/management/CqlExecuteCommandTest.java new file mode 100644 index 0000000000..5807e21392 --- /dev/null +++ b/test/unit/org/apache/cassandra/management/CqlExecuteCommandTest.java @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.cassandra.management; + +import org.junit.Test; + +import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.UntypedResultSet; +import org.apache.cassandra.exceptions.InvalidRequestException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class CqlExecuteCommandTest extends CQLTester +{ + @Test + public void testExecuteCommandForcecompact() throws Throwable + { + String keyspaceName = createKeyspace("CREATE KEYSPACE %s WITH replication = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 }"); + String tableName = createTable(keyspaceName, "CREATE TABLE %s (k text PRIMARY KEY, v int)"); + + execute(String.format("INSERT INTO %s.%s (k, v) VALUES (?, ?)", keyspaceName, tableName), "k1", 1); + execute(String.format("INSERT INTO %s.%s (k, v) VALUES (?, ?)", keyspaceName, tableName), "k2", 2); + execute(String.format("INSERT INTO %s.%s (k, v) VALUES (?, ?)", keyspaceName, tableName), "k4", 4); + execute(String.format("INSERT INTO %s.%s (k, v) VALUES (?, ?)", keyspaceName, tableName), "k7", 7); + + flush(keyspaceName, tableName); + + // Execute the INVOKE COMMAND statement using CQL-style syntax. + String command = String.format("INVOKE COMMAND forcecompact WITH \"keyspace\" = '%s' AND \"table\" = '%s' AND keys = ['k4', 'k2', 'k7'];", + keyspaceName, tableName); + + UntypedResultSet result = execute(command); + + assertNotNull("Result should not be null", result); + assertEquals("Result should have one row", 1, result.size()); + + UntypedResultSet.Row row = result.one(); + assertNotNull("Row should not be null", row); + + assertTrue("Result should have 'output' column", row.has("output")); + String output = row.getString("output"); + assertNotNull("Output should not be null", output); + assertFalse("Output should not be empty", output.trim().isEmpty()); + } + + @Test + public void testExecuteCommandWithInvalidCommand() throws Throwable + { + String command = "INVOKE COMMAND nonexistentcommand WITH key = 'value';"; + assertInvalidThrow(InvalidRequestException.class, command); + } + + @Test + public void testExecuteCommandWithoutParameters() throws Throwable + { + // Test that INVOKE COMMAND statements without WITH clause parse correctly + String command = "INVOKE COMMAND status;"; + + UntypedResultSet result = execute(command); + + assertNotNull("Result should not be null", result); + assertEquals("Result should have one row", 1, result.size()); + + UntypedResultSet.Row row = result.one(); + assertNotNull("Row should not be null", row); + assertTrue("Result should have 'output' column", row.has("output")); + String output = row.getString("output"); + assertNotNull("Output should not be null", output); + } +} diff --git a/test/unit/org/apache/cassandra/management/ExecuteCommandStatementParseTest.java b/test/unit/org/apache/cassandra/management/ExecuteCommandStatementParseTest.java new file mode 100644 index 0000000000..d14390ade7 --- /dev/null +++ b/test/unit/org/apache/cassandra/management/ExecuteCommandStatementParseTest.java @@ -0,0 +1,174 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management; + +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +import org.apache.cassandra.cql3.CQLStatement; +import org.apache.cassandra.cql3.QueryProcessor; +import org.apache.cassandra.cql3.statements.ExecuteCommandStatement; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Tests that INVOKE COMMAND CQL statements are parsed correctly into {@link ExecuteCommandStatement.Raw}, + * using real nodetool command names and realistic argument patterns. + */ +public class ExecuteCommandStatementParseTest +{ + @Test + public void testParseVersionWithoutArgs() + { + ExecuteCommandStatement.Raw stmt = parse("INVOKE COMMAND version;"); + assertEquals("version", stmt.commandName()); + assertTrue(stmt.args().isEmpty()); + } + + @Test + public void testParseStatusWithKeyspace() + { + ExecuteCommandStatement.Raw stmt = parse("INVOKE COMMAND status WITH param0 = 'system';"); + assertEquals("status", stmt.commandName()); + assertEquals(Map.of("param0", "system"), stmt.args()); + } + + @Test + public void testParseSnapshotWithEscapedQuoteInTag() + { + ExecuteCommandStatement.Raw stmt = parse("INVOKE COMMAND snapshot WITH tag = 'pre-upgrade''s_backup';"); + assertEquals("snapshot", stmt.commandName()); + assertEquals(Map.of("tag", "pre-upgrade's_backup"), stmt.args()); + } + + @Test + public void testParseCleanupWithJobs() + { + ExecuteCommandStatement.Raw stmt = parse("INVOKE COMMAND cleanup WITH jobs = 2;"); + assertEquals("cleanup", stmt.commandName()); + assertEquals(Map.of("jobs", "2"), stmt.args()); + } + + @Test + public void testParseDecommissionWithForce() + { + ExecuteCommandStatement.Raw stmt = parse("INVOKE COMMAND decommission WITH force = true;"); + assertEquals("decommission", stmt.commandName()); + assertEquals(Map.of("force", "true"), stmt.args()); + } + + @Test + public void testParseSetTraceProbabilityWithFloat() + { + ExecuteCommandStatement.Raw stmt = parse("INVOKE COMMAND settraceprobability WITH param0 = 0.2;"); + assertEquals("settraceprobability", stmt.commandName()); + assertEquals(Map.of("param0", "0.2"), stmt.args()); + } + + @Test + public void testParseSetTraceProbabilityWithExponentAndNegativeFloats() + { + ExecuteCommandStatement.Raw stmt = parse( + "INVOKE COMMAND foo WITH a = 1.5e3 AND b = -0.75 AND c = 100.0;" + ); + assertEquals("foo", stmt.commandName()); + Map args = stmt.args(); + assertEquals(3, args.size()); + assertEquals("1.5e3", args.get("a")); + assertEquals("-0.75", args.get("b")); + assertEquals("100.0", args.get("c")); + } + + @Test + public void testParseRepairWithDatacenters() + { + ExecuteCommandStatement.Raw stmt = parse("INVOKE COMMAND repair WITH specific_dc = ['dc1', 'dc2', 'dc3'];"); + assertEquals("repair", stmt.commandName()); + Object dcs = stmt.args().get("specific_dc"); + assertTrue("Expected List but got " + dcs.getClass().getName(), dcs instanceof List); + assertEquals(List.of("dc1", "dc2", "dc3"), dcs); + } + + @Test + public void testParseRepairWithEmptyHostList() + { + ExecuteCommandStatement.Raw stmt = parse("INVOKE COMMAND repair WITH specific_host = [];"); + assertEquals("repair", stmt.commandName()); + Object hosts = stmt.args().get("specific_host"); + assertTrue("Expected List but got " + hosts.getClass().getName(), hosts instanceof List); + assertEquals(List.of(), hosts); + } + + @Test + public void testParseRepairWithHostsContainingEscapedQuotes() + { + ExecuteCommandStatement.Raw stmt = parse( + "INVOKE COMMAND repair WITH specific_host = ['node-1''s.example.com', 'node-2''s.example.com'];" + ); + assertEquals(List.of("node-1's.example.com", "node-2's.example.com"), + stmt.args().get("specific_host")); + } + + @Test + public void testParseRepairWithSingleDatacenter() + { + ExecuteCommandStatement.Raw stmt = parse("INVOKE COMMAND repair WITH specific_dc = ['us-east-1'];"); + assertEquals(List.of("us-east-1"), stmt.args().get("specific_dc")); + } + + @Test + public void testParseForcecompactWithMixedArgs() + { + ExecuteCommandStatement.Raw stmt = parse( + "INVOKE COMMAND forcecompact WITH \"keyspace\" = 'my_ks' AND \"table\" = 'my_table' AND keys = ['k1', 'k2'];" + ); + + assertEquals("forcecompact", stmt.commandName()); + Map args = stmt.args(); + assertEquals(3, args.size()); + assertEquals("my_ks", args.get("keyspace")); + assertEquals("my_table", args.get("table")); + assertEquals(List.of("k1", "k2"), args.get("keys")); + } + + @Test + public void testParseSetLoggingLevelWithNamedParams() + { + ExecuteCommandStatement.Raw stmt = parse( + "INVOKE COMMAND setlogginglevel WITH component = 'org.apache.cassandra.db' AND level = 'DEBUG';" + ); + + assertEquals("setlogginglevel", stmt.commandName()); + Map args = stmt.args(); + assertEquals(2, args.size()); + assertEquals("org.apache.cassandra.db", args.get("component")); + assertEquals("DEBUG", args.get("level")); + } + + private static ExecuteCommandStatement.Raw parse(String cql) + { + CQLStatement.Raw stmt = QueryProcessor.parseStatement(cql); + assertTrue("Expected ExecuteCommandStatement.Raw but got: " + stmt.getClass().getName(), + stmt instanceof ExecuteCommandStatement.Raw); + return (ExecuteCommandStatement.Raw) stmt; + } +} diff --git a/test/unit/org/apache/cassandra/management/InternalNodeMBeanAccessorTest.java b/test/unit/org/apache/cassandra/management/InternalNodeMBeanAccessorTest.java new file mode 100644 index 0000000000..80c95a09dd --- /dev/null +++ b/test/unit/org/apache/cassandra/management/InternalNodeMBeanAccessorTest.java @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management; + +import org.junit.Test; + +import org.apache.cassandra.auth.jmx.AuthorizationProxy; +import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.db.ColumnFamilyStoreMBean; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class InternalNodeMBeanAccessorTest extends CQLTester +{ + private final InternalNodeMBeanAccessor accessor = new InternalNodeMBeanAccessor(); + + @Test + public void testFindColumnFamilyResolvesSecondaryIndexStores() + { + createTable("CREATE TABLE %s (k int PRIMARY KEY, v int)"); + String index = createIndex("CREATE INDEX ON %s(v) USING 'legacy_local_table'"); + String indexStoreName = currentTable() + '.' + index; + + assertThat(accessor.findColumnFamily("ColumnFamilies", KEYSPACE, currentTable())).isNotNull(); + + ColumnFamilyStoreMBean indexStore = accessor.findColumnFamily("IndexColumnFamilies", KEYSPACE, indexStoreName); + assertThat(indexStore).isNotNull(); + assertThat(indexStore.getTableName()).isEqualTo(indexStoreName); + + assertThatThrownBy(() -> accessor.findColumnFamily("IndexColumnFamilies", KEYSPACE, currentTable() + ".missing_idx")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Index not found"); + } + + @Test + public void testFindColumnFamiliesListsSecondaryIndexStores() + { + createTable("CREATE TABLE %s (k int PRIMARY KEY, v int)"); + String index = createIndex("CREATE INDEX ON %s(v) USING 'legacy_local_table'"); + String indexStoreName = currentTable() + '.' + index; + + assertThat(accessor.findColumnFamilies("IndexColumnFamilies")) + .anySatisfy(e -> { + assertThat(e.getKey()).isEqualTo(KEYSPACE); + assertThat(e.getValue().getTableName()).isEqualTo(indexStoreName); + }); + + assertThat(accessor.findColumnFamilies("ColumnFamilies")) + .anySatisfy(e -> { + assertThat(e.getKey()).isEqualTo(KEYSPACE); + assertThat(e.getValue().getTableName()).isEqualTo(currentTable()); + }) + .noneSatisfy(e -> assertThat(e.getValue().getTableName()).isEqualTo(indexStoreName)); + } + + @Test + public void testFindMBeanResolvesJmxPermissionsCache() + { + AuthorizationProxy.JmxPermissionsCache expected = AuthorizationProxy.jmxPermissionsCache; + assertThat(accessor.findMBean(AuthorizationProxy.JmxPermissionsCacheMBean.class)).isSameAs(expected); + } + + @Test + public void testFindCompressionDictionaryContract() + { + createTable("CREATE TABLE %s (k int PRIMARY KEY, v text)"); + assertThatThrownBy(() -> accessor.findCompressionDictionary(KEYSPACE, currentTable())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("is not enabled or SSTable compressor is not a dictionary compressor"); + + assertThatThrownBy(() -> accessor.findCompressionDictionary(KEYSPACE, "table_does_not_exist")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not exist"); + + assertThatThrownBy(() -> accessor.findCompressionDictionary("keyspace_does_not_exist", currentTable())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not exist"); + } +} diff --git a/test/unit/org/apache/cassandra/management/PicocliCommandArgsConverterTest.java b/test/unit/org/apache/cassandra/management/PicocliCommandArgsConverterTest.java new file mode 100644 index 0000000000..5b60429fcb --- /dev/null +++ b/test/unit/org/apache/cassandra/management/PicocliCommandArgsConverterTest.java @@ -0,0 +1,267 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.management; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.Test; + +import org.apache.cassandra.management.api.CommandExecutionArgs; +import org.apache.cassandra.management.api.CommandMetadata; +import org.apache.cassandra.management.api.OptionMetadata; +import org.apache.cassandra.management.picocli.PicocliCommandArgsConverter; +import org.apache.cassandra.management.picocli.PicocliCommandMetadata; +import org.apache.cassandra.service.AsyncProfilerService.AsyncProfilerEvent; +import org.apache.cassandra.tools.NodeProbe; +import org.apache.cassandra.tools.nodetool.AbstractCommand; +import org.apache.cassandra.tools.nodetool.AsyncProfileCommandGroup.AsyncProfileStartCommand; + +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +import static org.assertj.core.api.Assertions.assertThat; + +public class PicocliCommandArgsConverterTest +{ + @Test + public void testFromCommandDefaultValueHandling() + { + CommandExecutionArgs annotationDefaultArgs = PicocliCommandArgsConverter.fromCommand(new CommandWithAnnotationDefaults()); + assertThat(findOptionValue(annotationDefaultArgs, "--name")).isEqualTo("default-name"); + assertThat(findOptionValue(annotationDefaultArgs, "--count")).isEqualTo(1); + assertThat(findOptionValue(annotationDefaultArgs, "--items")).isEqualTo(List.of()); + assertThat(findOptionValue(annotationDefaultArgs, "--flag")).isNull(); + assertThat(annotationDefaultArgs.parameters()).isEmpty(); + + CommandWithAnnotationDefaults overridden = new CommandWithAnnotationDefaults(); + overridden.flag = true; + overridden.name = "cassandra-node-1"; + overridden.count = 42; + overridden.items = List.of("a", "b", "c"); + overridden.target = "node1"; + + CommandExecutionArgs overriddenArgs = PicocliCommandArgsConverter.fromCommand(overridden); + assertThat(findOptionValue(overriddenArgs, "--flag")).isEqualTo(Boolean.TRUE); + assertThat(findOptionValue(overriddenArgs, "--name")).isEqualTo("cassandra-node-1"); + assertThat(findOptionValue(overriddenArgs, "--count")).isEqualTo(42); + assertThat(findOptionValue(overriddenArgs, "--items")).isEqualTo(List.of("a", "b", "c")); + assertThat(overriddenArgs.parameters()).hasSize(1); + assertThat(overriddenArgs.parameters().values().iterator().next()).isEqualTo("node1"); + + CommandExecutionArgs javaDefaultArgs = PicocliCommandArgsConverter.fromCommand(new CommandWithJavaDefaults()); + assertThat(findOptionValue(javaDefaultArgs, "--name")).isEqualTo("default-name"); + assertThat(findOptionValue(javaDefaultArgs, "--count")).isEqualTo(0); + assertThat(findOptionValue(javaDefaultArgs, "--flag")).isNull(); + assertThat(javaDefaultArgs.parameters()).isEmpty(); + } + + @Test + public void testRoundTripSimpleCommand() + { + CommandWithJavaDefaults source = new CommandWithJavaDefaults(); + source.flag = true; + source.name = "round-trip"; + source.count = 7; + source.items = List.of("p", "q"); + source.target = "node42"; + + CommandExecutionArgs args = PicocliCommandArgsConverter.fromCommand(source); + + CommandWithJavaDefaults target = new CommandWithJavaDefaults(); + PicocliCommandArgsConverter.toCommand(args, target); + + assertThat(target.flag).isTrue(); + assertThat(target.name).isEqualTo("round-trip"); + assertThat(target.count).isEqualTo(7); + assertThat(target.items).containsExactly("p", "q"); + assertThat(target.target).isEqualTo("node42"); + } + + @Test + public void testRoundTripMixinCommand() + { + CommandExecutionArgs defaultArgs = PicocliCommandArgsConverter.fromCommand(new CommandWithMixin()); + assertThat(findOptionValue(defaultArgs, "--verbose")).isNull(); + + CommandWithMixin source = new CommandWithMixin(); + source.verboseMixin.verbose = true; + source.host = "node-dc2"; + + CommandExecutionArgs args = PicocliCommandArgsConverter.fromCommand(source); + + CommandWithMixin target = new CommandWithMixin(); + PicocliCommandArgsConverter.toCommand(args, target); + + assertThat(target.verboseMixin.verbose).isTrue(); + assertThat(target.host).isEqualTo("node-dc2"); + } + + @Test + public void testSetOfEnumElementsAreConverted() + { + CollectionElementCommand command = new CollectionElementCommand(); + CommandMetadata metadata = PicocliCommandMetadata.from(command); + + Map raw = Map.of("colorSet", List.of("green", "red")); + CommandExecutionArgs args = CommandExecutionArgsSerde.fromMap(raw, metadata); + + PicocliCommandArgsConverter.toCommand(args, command); + + String joined = command.colorSet.stream().map(Enum::name).collect(Collectors.joining(",")); + assertThat(joined).contains("green").contains("red"); + assertThat(command.colorSet).containsExactlyInAnyOrder(Color.green, Color.red); + } + + @Test + public void testArrayOfEnumElementsAreConverted() + { + CollectionElementCommand command = new CollectionElementCommand(); + CommandMetadata metadata = PicocliCommandMetadata.from(command); + + Map raw = Map.of("colorArray", List.of("red", "green")); + CommandExecutionArgs args = CommandExecutionArgsSerde.fromMap(raw, metadata); + + PicocliCommandArgsConverter.toCommand(args, command); + + assertThat(command.colorArray).containsExactly(Color.red, Color.green); + } + + @Test + public void testAsyncProfilerStartEventListIsConverted() + { + AsyncProfileStartCommand command = new AsyncProfileStartCommand(); + CommandMetadata metadata = PicocliCommandMetadata.from(command); + + // The grammar parses "cpu,alloc" into a list of String literals. + Map raw = Map.of("event", List.of("cpu", "alloc")); + CommandExecutionArgs args = CommandExecutionArgsSerde.fromMap(raw, metadata); + + PicocliCommandArgsConverter.toCommand(args, command); + + assertThat(command.event).containsExactly(AsyncProfilerEvent.cpu, AsyncProfilerEvent.alloc); + + String joined = command.event.stream().map(Enum::name).collect(Collectors.joining(",")); + assertThat(joined).isEqualTo("cpu,alloc"); + } + + private static Object findOptionValue(CommandExecutionArgs args, String optionName) + { + for (java.util.Map.Entry entry : args.options().entrySet()) + { + if (List.of(entry.getKey().names()).contains(optionName)) + return entry.getValue(); + } + return null; + } + + @Command(name = "command-with-annotation-defaults") + static class CommandWithAnnotationDefaults extends AbstractCommand + { + @Option(names = { "--flag", "-f" }, description = "A boolean flag") + boolean flag = false; + + @Option(names = { "--name", "-n" }, description = "A string option", paramLabel = "name", defaultValue = "default-name") + String name = "default-name"; + + @Option(names = { "--count", "-c" }, description = "An integer option", paramLabel = "count", defaultValue = "1") + int count = 1; + + @Option(names = { "--items" }, description = "A list option", paramLabel = "items") + List items = List.of(); + + @Parameters(index = "0", paramLabel = "target", description = "A positional parameter", arity = "0..1") + String target = null; + + @Override + protected void execute(NodeProbe probe) + { + } + } + + @Command(name = "command-with-java-defaults") + static class CommandWithJavaDefaults extends AbstractCommand + { + @Option(names = { "--flag", "-f" }, description = "A boolean flag") + boolean flag = false; + + @Option(names = { "--name", "-n" }, description = "A string option", paramLabel = "name") + String name = "default-name"; + + @Option(names = { "--count", "-c" }, description = "An integer option", paramLabel = "count") + int count = 0; + + @Option(names = { "--items" }, description = "A list option", paramLabel = "items") + List items = List.of(); + + @Parameters(index = "0", paramLabel = "target", description = "A positional parameter", arity = "0..1") + String target = null; + + @Override + protected void execute(NodeProbe probe) + { + } + } + + static class VerboseMixin + { + @Option(names = { "--verbose", "-v" }, description = "Enable verbose output") + boolean verbose = false; + } + + @Command(name = "mixin-command") + static class CommandWithMixin extends AbstractCommand + { + @Mixin + VerboseMixin verboseMixin = new VerboseMixin(); + + @Option(names = { "--host" }, description = "Target host", + paramLabel = "host", defaultValue = "localhost") + String host = "localhost"; + + @Override + protected void execute(NodeProbe probe) + { + } + } + + enum Color + { + red, green, blue + } + + @Command(name = "collection-element-command") + static class CollectionElementCommand extends AbstractCommand + { + @Option(names = { "--colorSet", "-s" }, paramLabel = "colorSet", description = "A set of enum values") + public Set colorSet = Set.of(); + + @Option(names = { "--colorArray", "-a" }, paramLabel = "colorArray", description = "An array of enum values") + public Color[] colorArray = new Color[0]; + + @Override + protected void execute(NodeProbe probe) + { + } + } +} \ No newline at end of file diff --git a/test/unit/org/apache/cassandra/service/NativeTransportManagementServiceTest.java b/test/unit/org/apache/cassandra/service/NativeTransportManagementServiceTest.java new file mode 100644 index 0000000000..60fb947c93 --- /dev/null +++ b/test/unit/org/apache/cassandra/service/NativeTransportManagementServiceTest.java @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.service; + +import java.util.function.BooleanSupplier; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import org.apache.cassandra.config.DatabaseDescriptor; + +import io.netty.channel.EventLoopGroup; + +import static org.apache.cassandra.service.NativeTransportServiceTest.withService; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class NativeTransportManagementServiceTest +{ + @BeforeClass + public static void setupTransport() + { + DatabaseDescriptor.daemonInitialization(); + DatabaseDescriptor.setStartNativeTransportManagement(true); + } + + @AfterClass + public static void cleanupManagementConfig() + { + DatabaseDescriptor.setStartNativeTransportManagement(false); + } + + @Test + public void testStart() + { + withService((CassandraDaemon.Server service) -> assertTrue(service.isRunning()), + NativeTransportManagementService::new, true, 1); + } + + @Test + public void testDestroy() + { + withService((CassandraDaemon.Server srv) -> { + NativeTransportManagementService service = (NativeTransportManagementService) srv; + EventLoopGroup workerGroup = service.getWorkerGroup(); + BooleanSupplier allTerminated = () -> workerGroup != null + && workerGroup.isShutdown() + && workerGroup.isTerminated(); + + assertFalse(allTerminated.getAsBoolean()); + service.destroy(); + assertTrue(allTerminated.getAsBoolean()); + }, NativeTransportManagementService::new, true, 1); + } + + @Test + public void testConcurrentDestroys() + { + withService(srv -> ((NativeTransportManagementService) srv).destroy(), + NativeTransportManagementService::new, true, 20); + } +} diff --git a/test/unit/org/apache/cassandra/service/NativeTransportServiceTest.java b/test/unit/org/apache/cassandra/service/NativeTransportServiceTest.java index 9602899400..1f8deebe56 100644 --- a/test/unit/org/apache/cassandra/service/NativeTransportServiceTest.java +++ b/test/unit/org/apache/cassandra/service/NativeTransportServiceTest.java @@ -19,6 +19,7 @@ package org.apache.cassandra.service; import java.util.function.BooleanSupplier; import java.util.function.Consumer; +import java.util.function.Supplier; import java.util.stream.IntStream; import org.junit.After; @@ -170,7 +171,16 @@ public class NativeTransportServiceTest private static void withService(Consumer f, boolean start, int concurrently) { - NativeTransportService service = new NativeTransportService(); + withService(srv -> f.accept((NativeTransportService) srv), + NativeTransportService::new, start, concurrently); + } + + static void withService(Consumer f, + Supplier provider, + boolean start, + int concurrently) + { + CassandraDaemon.Server service = provider.get(); assertFalse(service.isRunning()); if (start) { diff --git a/test/unit/org/apache/cassandra/tools/JMXStandardsTest.java b/test/unit/org/apache/cassandra/tools/JMXStandardsTest.java index 78085f36ed..66b106806d 100644 --- a/test/unit/org/apache/cassandra/tools/JMXStandardsTest.java +++ b/test/unit/org/apache/cassandra/tools/JMXStandardsTest.java @@ -125,6 +125,7 @@ public class JMXStandardsTest Pattern mbeanPattern = Pattern.compile(".*MBean$"); Set matches = reflections.getAll(Scanners.SubTypes).stream() .filter(s -> mbeanPattern.matcher(s).find()) + .filter(s -> s.startsWith("org.apache.cassandra.")) .collect(Collectors.toSet()); List warnings = new ArrayList<>(); diff --git a/test/unit/org/apache/cassandra/tools/NodeProbeMBeanProxyTest.java b/test/unit/org/apache/cassandra/tools/NodeProbeMBeanProxyTest.java new file mode 100644 index 0000000000..acc9da1c79 --- /dev/null +++ b/test/unit/org/apache/cassandra/tools/NodeProbeMBeanProxyTest.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.tools; + +import java.util.List; +import java.util.Map; + +import javax.management.InstanceNotFoundException; + +import org.junit.Test; + +import org.apache.cassandra.db.ColumnFamilyStoreMBean; +import org.apache.cassandra.db.compression.CompressionDictionaryManagerMBean; +import org.apache.cassandra.management.MBeanAccessor; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class NodeProbeMBeanProxyTest +{ + @Test + public void testMissingMBeanSurfacesAsInstanceNotFound() + { + NodeProbe probe = new NodeProbe(new NullMBeanAccessor()); + + assertThatThrownBy(probe::stopCassandraDaemon) + .isInstanceOf(RuntimeException.class) + .hasRootCauseInstanceOf(InstanceNotFoundException.class) + .hasRootCauseMessage("StorageServiceMBean is not available on this node"); + } + + private static class NullMBeanAccessor implements MBeanAccessor + { + @Override + public T findMBean(Class clazz) + { + return null; + } + + @Override + public T findMBeanMetric(Class clazz, Props props) + { + return null; + } + + @Override + public boolean isMBeanMetricRegistered(Props props) + { + return false; + } + + @Override + public ColumnFamilyStoreMBean findColumnFamily(String type, String keyspace, String columnFamily) + { + return null; + } + + @Override + public CompressionDictionaryManagerMBean findCompressionDictionary(String keyspace, String table) + { + return null; + } + + @Override + public List threadPoolInfos() + { + return List.of(); + } + + @Override + public List> findColumnFamilies(String type) + { + return List.of(); + } + } +} diff --git a/test/unit/org/apache/cassandra/tools/NodeProbeTest.java b/test/unit/org/apache/cassandra/tools/NodeProbeTest.java index 1d41095ab8..d3c5b30013 100644 --- a/test/unit/org/apache/cassandra/tools/NodeProbeTest.java +++ b/test/unit/org/apache/cassandra/tools/NodeProbeTest.java @@ -18,8 +18,6 @@ package org.apache.cassandra.tools; -import java.io.IOException; - import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; @@ -39,11 +37,11 @@ public class NodeProbeTest extends CQLTester { requireNetwork(); startJMXServer(); - probe = new NodeProbe(jmxHost, jmxPort); + probe = new NodeProbe(new RemoteJmxMBeanAccessor(jmxHost, jmxPort)); } @AfterClass - public static void teardown() throws IOException + public static void teardown() throws Exception { probe.close(); } diff --git a/test/unit/org/apache/cassandra/tools/ToolRunner.java b/test/unit/org/apache/cassandra/tools/ToolRunner.java index 1bdd7cffac..c1f4e2fe7e 100644 --- a/test/unit/org/apache/cassandra/tools/ToolRunner.java +++ b/test/unit/org/apache/cassandra/tools/ToolRunner.java @@ -182,6 +182,11 @@ public class ToolRunner return invoke(CQLTester.buildCqlshArgs(args)); } + public static ToolResult invokeCqlshManagement(List args) + { + return invoke(CQLTester.buildCqlshManagementArgs(args)); + } + public static ToolResult invokeCassandraStress(String... args) { return invokeCassandraStress(Arrays.asList(args)); @@ -212,6 +217,11 @@ public class ToolRunner return invoke(env, CQLTester.buildNodetoolArgs(args)); } + public static ToolResult invokeCqlNodetool(Map env, List args) + { + return invoke(env, CQLTester.buildNodetoolCqlArgs(args)); + } + public static ToolRunner.ToolResult invokeNodetoolInJvm(String... args) { return ToolRunner.invokeNodetoolInJvm(NodeTool::new, args); @@ -357,6 +367,13 @@ public class ToolRunner } public static ToolRunner.ToolResult invokeNodetoolInJvm(BiFunction nodeTool, String... args) + { + return invokeNodetoolInJvm(nodeTool, CQLTester::buildNodetoolArgs, args); + } + + public static ToolRunner.ToolResult invokeNodetoolInJvm(BiFunction nodeTool, + Function, List> argsBuilder, + String... args) { PrintStream originalSysOut = System.out; PrintStream originalSysErr = System.err; @@ -365,7 +382,7 @@ public class ToolRunner PrintStream printOut = new PrintStream(out); PrintStream printErr = new PrintStream(err); Output output = new Output(printOut, printErr); - List clearedArgs = CQLTester.buildNodetoolArgs(isEmpty(args) ? new ArrayList<>() : List.of(args)); + List clearedArgs = argsBuilder.apply(isEmpty(args) ? new ArrayList<>() : List.of(args)); clearedArgs.remove("bin/nodetool"); try { @@ -927,7 +944,10 @@ public class ToolRunner public String getOutput() { flush(); - return String.join("\n", outputLines); + String joined = String.join("\n", outputLines); + if (!joined.isEmpty() && !joined.endsWith("\n")) + joined += "\n"; + return joined; } } } diff --git a/test/unit/org/apache/cassandra/tools/cqlsh/CqlshTest.java b/test/unit/org/apache/cassandra/tools/cqlsh/CqlshTest.java index 9b7868c118..13bd805a0f 100644 --- a/test/unit/org/apache/cassandra/tools/cqlsh/CqlshTest.java +++ b/test/unit/org/apache/cassandra/tools/cqlsh/CqlshTest.java @@ -23,6 +23,7 @@ import java.io.Writer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Collections; import org.junit.BeforeClass; import org.junit.Test; @@ -45,6 +46,7 @@ public class CqlshTest extends CQLTester public static void setUp() { requireNetwork(); + addVirtualKeyspace(); } @Test @@ -148,6 +150,84 @@ public class CqlshTest extends CQLTester row(3, vector(1, 2, 3, 4, 6, 7))); } + @Test + public void testManagementPortCommandStatus() + { + ToolResult tool = ToolRunner.invokeCqlshManagement(Collections.singletonList("INVOKE COMMAND status;")); + tool.asserts().success(); + assertThat(tool.getStdout()).contains("execution_id"); + assertThat(tool.getStdout()).contains("output"); + } + + @Test + public void testManagementPortSelectSystemLocal() + { + ToolResult tool = ToolRunner.invokeCqlshManagement( + Collections.singletonList("SELECT cluster_name, release_version FROM system.local;")); + tool.asserts().success(); + assertThat(tool.getStdout()).contains("cluster_name"); + assertThat(tool.getStdout()).contains("release_version"); + } + + @Test + public void testManagementPortSelectSystemPeers() + { + ToolResult tool = ToolRunner.invokeCqlshManagement(Collections.singletonList("SELECT * FROM system.peers;")); + tool.asserts().success(); + assertThat(tool.getStdout()).contains("peer"); + } + + @Test + public void testManagementPortSelectSystemSchemaKeyspaces() + { + ToolResult tool = ToolRunner.invokeCqlshManagement( + Collections.singletonList("SELECT keyspace_name FROM system_schema.keyspaces;")); + tool.asserts().success(); + assertThat(tool.getStdout()).contains("keyspace_name"); + assertThat(tool.getStdout()).contains("system"); + } + + @Test + public void testManagementPortSelectVirtualTableSettings() + { + ToolResult tool = ToolRunner.invokeCqlshManagement( + Collections.singletonList("SELECT name FROM system_views.settings LIMIT 1;")); + tool.asserts().success(); + assertThat(tool.getStdout()).contains("name"); + } + + @Test + public void testManagementPortSelectVirtualTableClients() + { + ToolResult tool = ToolRunner.invokeCqlshManagement( + Collections.singletonList("SELECT * FROM system_views.clients;")); + tool.asserts().success(); + assertThat(tool.getStdout()).contains("address"); + } + + @Test + public void testManagementPortRejectsNonSystemSelect() + { + String keyspaceName = createKeyspace( + "CREATE KEYSPACE %s WITH replication = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 }"); + String tableName = createTable(keyspaceName, "CREATE TABLE %s (k text PRIMARY KEY, v int)"); + ToolResult tool = ToolRunner.invokeCqlshManagement( + Collections.singletonList(format("SELECT * FROM %s.%s;", keyspaceName, tableName))); + tool.asserts().failure(); + } + + @Test + public void testManagementPortRejectsInsert() + { + String keyspaceName = createKeyspace( + "CREATE KEYSPACE %s WITH replication = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 }"); + String tableName = createTable(keyspaceName, "CREATE TABLE %s (k text PRIMARY KEY, v int)"); + + ToolResult tool = ToolRunner.invokeCqlshManagement( + Collections.singletonList(format("INSERT INTO %s.%s (k, v) VALUES ('a', 1);", keyspaceName, tableName))); + tool.asserts().failure(); + } + private static Path prepareCSVFile(Object[][] rows) throws IOException { Path csv = createTempFile("test_copy_from_vector"); diff --git a/test/unit/org/apache/cassandra/tools/nodetool/CIDRFilteringStatsTest.java b/test/unit/org/apache/cassandra/tools/nodetool/CIDRFilteringStatsTest.java index dc0abc3bd2..b6c1998b31 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/CIDRFilteringStatsTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/CIDRFilteringStatsTest.java @@ -39,6 +39,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +// TODO: CASSANDRA-XXXXX Test needs to be migrated to CQLNodetoolProtocolTester +// once nodetool supports passing authentication credentials. public class CIDRFilteringStatsTest extends CQLTester { @BeforeClass diff --git a/test/unit/org/apache/cassandra/tools/nodetool/ClearSnapshotTest.java b/test/unit/org/apache/cassandra/tools/nodetool/ClearSnapshotTest.java index eb5e543109..c25780eee1 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/ClearSnapshotTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/ClearSnapshotTest.java @@ -18,7 +18,6 @@ package org.apache.cassandra.tools.nodetool; -import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -35,13 +34,14 @@ import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.db.Keyspace; import org.apache.cassandra.io.util.File; import org.apache.cassandra.schema.TableMetadata; import org.apache.cassandra.service.snapshot.SnapshotManager; import org.apache.cassandra.service.snapshot.SnapshotManifest; import org.apache.cassandra.tools.NodeProbe; +import org.apache.cassandra.tools.RemoteJmxMBeanAccessor; import org.apache.cassandra.tools.ToolRunner.ToolResult; import static java.lang.String.format; @@ -49,13 +49,12 @@ import static java.time.temporal.ChronoUnit.HOURS; import static java.time.temporal.ChronoUnit.SECONDS; import static java.util.Collections.emptyMap; import static org.apache.cassandra.config.DatabaseDescriptor.getAllDataFileLocations; -import static org.apache.cassandra.tools.ToolRunner.invokeNodetool; import static org.apache.cassandra.utils.Clock.Global.currentTimeMillis; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; import static org.junit.Assert.assertTrue; -public class ClearSnapshotTest extends CQLTester +public class ClearSnapshotTest extends CQLNodetoolProtocolTester { private static final Pattern DASH_PATTERN = Pattern.compile("-"); private static NodeProbe probe; @@ -65,7 +64,7 @@ public class ClearSnapshotTest extends CQLTester { startJMXServer(); requireNetwork(); - probe = new NodeProbe(jmxHost, jmxPort); + probe = new NodeProbe(new RemoteJmxMBeanAccessor(jmxHost, jmxPort)); } @Before @@ -75,7 +74,7 @@ public class ClearSnapshotTest extends CQLTester } @AfterClass - public static void teardown() throws IOException + public static void teardown() throws Exception { probe.close(); } diff --git a/test/unit/org/apache/cassandra/tools/nodetool/CompactTest.java b/test/unit/org/apache/cassandra/tools/nodetool/CompactTest.java index 1763147547..023cd92da0 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/CompactTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/CompactTest.java @@ -23,13 +23,11 @@ import org.assertj.core.api.Assertions; import org.junit.BeforeClass; import org.junit.Test; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.db.ColumnFamilyStore; import org.apache.cassandra.db.Keyspace; -import static org.apache.cassandra.tools.ToolRunner.invokeNodetool; - -public class CompactTest extends CQLTester +public class CompactTest extends CQLNodetoolProtocolTester { @BeforeClass public static void setup() throws Throwable @@ -88,7 +86,7 @@ public class CompactTest extends CQLTester invokeNodetool("compact", "--partition", Long.toString(42), keyspace(), "doesnotexist") .asserts() .failure() - .errorContains(String.format("java.lang.IllegalArgumentException: Unknown keyspace/cf pair (%s.doesnotexist)", keyspace())); + .errorContains(String.format("Unknown keyspace/cf pair (%s.doesnotexist)", keyspace())); } @Test diff --git a/test/unit/org/apache/cassandra/tools/nodetool/CompactionHistoryTest.java b/test/unit/org/apache/cassandra/tools/nodetool/CompactionHistoryTest.java index b9ee543a43..caffc146b1 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/CompactionHistoryTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/CompactionHistoryTest.java @@ -31,19 +31,17 @@ import com.google.common.collect.Lists; import org.junit.BeforeClass; import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; import org.junit.runners.Parameterized.Parameter; import org.junit.runners.Parameterized.Parameters; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.db.ColumnFamilyStore; import org.apache.cassandra.db.Keyspace; import org.apache.cassandra.db.SystemKeyspace; import org.apache.cassandra.db.compaction.OperationType; import org.apache.cassandra.tools.ToolRunner.ToolResult; +import org.apache.cassandra.tools.nodetool.strategy.CommandExecutionStrategy; -import static org.apache.cassandra.tools.ToolRunner.invokeNodetool; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertTrue; @@ -53,25 +51,27 @@ import static org.junit.Assert.assertTrue; * @see Compact * @see GarbageCollect */ -@RunWith(Parameterized.class) -public class CompactionHistoryTest extends CQLTester +public class CompactionHistoryTest extends CQLNodetoolProtocolTester { - @Parameter + @Parameter(1) public List cmd; - @Parameter(1) + @Parameter(2) public String compactionType; - @Parameter(2) + @Parameter(3) public int systemTableRecord; - @Parameters(name = "{index}: cmd={0} compactionType={1} systemTableRecord={2}") + @Parameters(name = "{index}: strategy={0} cmd={1} compactionType={2} systemTableRecord={3}") public static Collection data() { List result = new ArrayList<>(); - result.add(new Object[]{ Lists.newArrayList("compact"), OperationType.MAJOR_COMPACTION.type, 1 }); - result.add(new Object[]{ Lists.newArrayList("garbagecollect"), OperationType.GARBAGE_COLLECT.type, 10 }); - result.add(new Object[]{ Lists.newArrayList("upgradesstables", "-a"), OperationType.UPGRADE_SSTABLES.type, 10 }); + for (CommandExecutionStrategy.Type strategy : CommandExecutionStrategy.Type.values()) + { + result.add(new Object[]{ strategy, Lists.newArrayList("compact"), OperationType.MAJOR_COMPACTION.type, 1 }); + result.add(new Object[]{ strategy, Lists.newArrayList("garbagecollect"), OperationType.GARBAGE_COLLECT.type, 10 }); + result.add(new Object[]{ strategy, Lists.newArrayList("upgradesstables", "-a"), OperationType.UPGRADE_SSTABLES.type, 10 }); + } return result; } diff --git a/test/unit/org/apache/cassandra/tools/nodetool/CompactionStatsTest.java b/test/unit/org/apache/cassandra/tools/nodetool/CompactionStatsTest.java index 7892a3c003..c01de13985 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/CompactionStatsTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/CompactionStatsTest.java @@ -26,6 +26,7 @@ import java.util.stream.IntStream; import org.junit.BeforeClass; import org.junit.Test; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.cql3.CQLTester; import org.apache.cassandra.db.ColumnFamilyStore; import org.apache.cassandra.db.compaction.CompactionInfo; @@ -40,7 +41,7 @@ import static org.apache.cassandra.utils.TimeUUID.Generator.nextTimeUUID; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; -public class CompactionStatsTest extends CQLTester +public class CompactionStatsTest extends CQLNodetoolProtocolTester { @BeforeClass public static void setup() throws Exception @@ -264,7 +265,7 @@ public class CompactionStatsTest extends CQLTester { AtomicReference stdout = new AtomicReference<>(); await().until(() -> { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool(args); + ToolRunner.ToolResult tool = invokeNodetool(args); tool.assertOnCleanExit(); String output = tool.getStdout(); stdout.set(output); diff --git a/test/unit/org/apache/cassandra/tools/nodetool/DataPathsTest.java b/test/unit/org/apache/cassandra/tools/nodetool/DataPathsTest.java index 2ee114368f..2ee354715c 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/DataPathsTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/DataPathsTest.java @@ -21,12 +21,12 @@ import org.apache.commons.lang3.StringUtils; import org.junit.BeforeClass; import org.junit.Test; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.tools.ToolRunner; import static org.assertj.core.api.Assertions.assertThat; -public class DataPathsTest extends CQLTester +public class DataPathsTest extends CQLNodetoolProtocolTester { private static final String SUBCOMMAND = "datapaths"; @@ -40,7 +40,7 @@ public class DataPathsTest extends CQLTester @Test public void testAllOutput() { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool(SUBCOMMAND); + ToolRunner.ToolResult tool = invokeNodetool(SUBCOMMAND); tool.assertOnCleanExit(); assertThat(tool.getStdout()).contains("Keyspace: system_schema"); assertThat(StringUtils.countMatches(tool.getStdout(), "Keyspace:")).isGreaterThan(1); @@ -51,7 +51,7 @@ public class DataPathsTest extends CQLTester @Test public void testSelectedKeyspace() { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool(SUBCOMMAND, "system_traces"); + ToolRunner.ToolResult tool = invokeNodetool(SUBCOMMAND, "system_traces"); tool.assertOnCleanExit(); assertThat(tool.getStdout()).contains("Keyspace: system_traces"); assertThat(StringUtils.countMatches(tool.getStdout(), "Keyspace:")).isEqualTo(1); @@ -62,7 +62,7 @@ public class DataPathsTest extends CQLTester @Test public void testSelectedMultipleKeyspaces() { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool(SUBCOMMAND, "system_traces", "system_auth"); + ToolRunner.ToolResult tool = invokeNodetool(SUBCOMMAND, "system_traces", "system_auth"); tool.assertOnCleanExit(); assertThat(tool.getStdout()).contains("Keyspace: system_traces"); assertThat(tool.getStdout()).contains("Keyspace: system_auth"); @@ -74,7 +74,7 @@ public class DataPathsTest extends CQLTester @Test public void testSelectedTable() { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool(SUBCOMMAND, "system_auth.roles"); + ToolRunner.ToolResult tool = invokeNodetool(SUBCOMMAND, "system_auth.roles"); tool.assertOnCleanExit(); assertThat(tool.getStdout()).contains("Keyspace: system_auth"); assertThat(StringUtils.countMatches(tool.getStdout(), "Keyspace:")).isEqualTo(1); @@ -86,7 +86,7 @@ public class DataPathsTest extends CQLTester @Test public void testSelectedMultipleTables() { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool(SUBCOMMAND, "system_auth.roles", "system_auth.role_members"); + ToolRunner.ToolResult tool = invokeNodetool(SUBCOMMAND, "system_auth.roles", "system_auth.role_members"); tool.assertOnCleanExit(); assertThat(tool.getStdout()).contains("Keyspace: system_auth"); assertThat(StringUtils.countMatches(tool.getStdout(), "Keyspace:")).isEqualTo(1); @@ -99,21 +99,21 @@ public class DataPathsTest extends CQLTester @Test public void testFormatArgJson() { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool(SUBCOMMAND, "--format", "json"); + ToolRunner.ToolResult tool = invokeNodetool(SUBCOMMAND, "--format", "json"); tool.assertOnCleanExit(); } @Test public void testFormatArgYaml() { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool(SUBCOMMAND, "--format", "yaml"); + ToolRunner.ToolResult tool = invokeNodetool(SUBCOMMAND, "--format", "yaml"); tool.assertOnCleanExit(); } @Test public void testFormatArgBad() { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool(SUBCOMMAND, "--format", "bad"); + ToolRunner.ToolResult tool = invokeNodetool(SUBCOMMAND, "--format", "bad"); assertThat(tool.getStdout()).contains("arguments for -F are yaml and json only."); } } diff --git a/test/unit/org/apache/cassandra/tools/nodetool/DropCIDRGroupTest.java b/test/unit/org/apache/cassandra/tools/nodetool/DropCIDRGroupTest.java index 38d27ab598..3adf50a4f7 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/DropCIDRGroupTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/DropCIDRGroupTest.java @@ -34,6 +34,8 @@ import org.apache.cassandra.tools.ToolRunner; import static org.assertj.core.api.Assertions.assertThat; +// TODO: CASSANDRA-XXXXX Test needs to be migrated to CQLNodetoolProtocolTester +// once nodetool supports passing authentication credentials. public class DropCIDRGroupTest extends CQLTester { @BeforeClass diff --git a/test/unit/org/apache/cassandra/tools/nodetool/ExportImportListCompressionDictionaryTest.java b/test/unit/org/apache/cassandra/tools/nodetool/ExportImportListCompressionDictionaryTest.java index 6792b3537a..fc01612e2c 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/ExportImportListCompressionDictionaryTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/ExportImportListCompressionDictionaryTest.java @@ -20,15 +20,18 @@ package org.apache.cassandra.tools.nodetool; import java.nio.file.Files; import java.time.Instant; +import java.util.HashSet; +import java.util.Set; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.After; import org.junit.Assert; import org.junit.BeforeClass; import org.junit.Test; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.db.compression.CompressionDictionaryDetailsTabularData.CompressionDictionaryDataObject; import org.apache.cassandra.io.util.File; import org.apache.cassandra.io.util.FileUtils; @@ -37,13 +40,14 @@ import org.apache.cassandra.utils.JsonUtils; import org.apache.cassandra.utils.Pair; import static java.lang.String.format; -import static org.apache.cassandra.tools.ToolRunner.invokeNodetool; import static org.apache.cassandra.utils.JsonUtils.serializeToJsonFile; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertTrue; -public class ExportImportListCompressionDictionaryTest extends CQLTester +public class ExportImportListCompressionDictionaryTest extends CQLNodetoolProtocolTester { + private final Set createdTables = new HashSet<>(); + @BeforeClass public static void setup() throws Throwable { @@ -51,6 +55,15 @@ public class ExportImportListCompressionDictionaryTest extends CQLTester startJMXServer(); } + @After + public void afterTest() throws Throwable + { + for (String table : createdTables) + execute(String.format("DROP TABLE IF EXISTS %s.%s", keyspace(), table)); + createdTables.clear(); + super.afterTest(); + } + @Test public void testExportImportListCompressionDictionary() throws Throwable { @@ -97,7 +110,7 @@ public class ExportImportListCompressionDictionaryTest extends CQLTester Instant.now()), pair.right); ToolResult result = invokeNodetool("compressiondictionary", "import", pair.right.absolutePath()); - assertTrue(result.getStderr().contains("Unable to import dictionary JSON: Table abc.def does not exist or does not support dictionary compression")); + assertTrue(result.getStdout().contains("Table abc.def does not exist")); } @Test @@ -117,7 +130,7 @@ public class ExportImportListCompressionDictionaryTest extends CQLTester JsonUtils.JSON_OBJECT_MAPPER.writeValue(jsonWithoutTable.toJavaIOFile(), node); ToolResult result = invokeNodetool("compressiondictionary", "import", jsonWithoutTable.absolutePath()); - assertTrue(result.getStderr().contains("Unable to import dictionary JSON: Table not specified.")); + assertTrue(result.getStderr().contains("Table not specified.")); } @Test @@ -136,7 +149,7 @@ public class ExportImportListCompressionDictionaryTest extends CQLTester result.assertOnCleanExit(); // older will not be possible to import result = invokeNodetool("compressiondictionary", "import", pair.right.absolutePath()); - assertTrue(result.getStderr().contains(format("Unable to import dictionary JSON: Dictionary to import has older dictionary id " + + assertTrue(result.getStdout().contains(format("Dictionary to import has older dictionary id " + "(%s) than the latest compression dictionary (%s) " + "for table %s.%s", pair.left.dictId, @@ -154,11 +167,18 @@ public class ExportImportListCompressionDictionaryTest extends CQLTester ToolResult result = invokeNodetool("compressiondictionary", "import", pair.right.absolutePath()); Assert.assertEquals(1, result.getExitCode()); - assertTrue(result.getStderr().contains(format("Unable to import dictionary JSON: Table %s.%s does not exist or does not support dictionary compression", + assertTrue(result.getStdout().contains(format("The compression on table %s.%s is not enabled or SSTable compressor is not a dictionary compressor", keyspace(), table))); } + protected String createTable(String query) + { + String table = createTable(KEYSPACE, query); + createdTables.add(table); + return table; + } + private void importDictionary(File dictFile) { ToolResult result = invokeNodetool("compressiondictionary", "import", dictFile.absolutePath()); @@ -193,8 +213,11 @@ public class ExportImportListCompressionDictionaryTest extends CQLTester private Pair trainAndExport(String table) throws Throwable { + disableCompaction(); trainDictionary(table); - return export(table, null); + Pair pair = export(table, null); + enableCompaction(); + return pair; } private Pair export(String table, Long id) throws Throwable diff --git a/test/unit/org/apache/cassandra/tools/nodetool/ForceCompactionTest.java b/test/unit/org/apache/cassandra/tools/nodetool/ForceCompactionTest.java index a78b80584a..3bfbbbd349 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/ForceCompactionTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/ForceCompactionTest.java @@ -29,7 +29,8 @@ import org.junit.BeforeClass; import org.junit.Test; import org.apache.cassandra.Util; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.db.ColumnFamilyStore; import org.apache.cassandra.db.Keyspace; import org.apache.cassandra.db.rows.Cell; @@ -38,13 +39,12 @@ import org.apache.cassandra.db.rows.Unfiltered; import org.apache.cassandra.db.rows.UnfilteredRowIterator; import org.apache.cassandra.io.sstable.ISSTableScanner; import org.apache.cassandra.io.sstable.format.SSTableReader; -import org.apache.cassandra.tools.ToolRunner; import static org.apache.commons.lang3.ArrayUtils.addAll; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; -public class ForceCompactionTest extends CQLTester +public class ForceCompactionTest extends CQLNodetoolProtocolTester { private final static int NUM_PARTITIONS = 10; private final static int NUM_ROWS = 100; @@ -54,6 +54,11 @@ public class ForceCompactionTest extends CQLTester { requireNetwork(); startJMXServer(); + + // This ensures initialization happens before any transport classes (like Envelope.Decoder) + // are loaded, as they have static initializers that depend on DatabaseDescriptor. + if (!DatabaseDescriptor.isClientOrToolInitialized()) + DatabaseDescriptor.clientInitialization(false); } @Before @@ -251,7 +256,7 @@ public class ForceCompactionTest extends CQLTester if (cfs != null) { cfs.forceMajorCompaction(); - ToolRunner.invokeNodetool(addAll(new String[]{ "forcecompact", cfs.keyspace.getName(), cfs.getTableName() }, + invokeNodetool(addAll(new String[]{ "forcecompact", cfs.keyspace.getName(), cfs.getTableName() }, partitionKeysIgnoreGcGrace)).assertOnCleanExit(); } } diff --git a/test/unit/org/apache/cassandra/tools/nodetool/GcStatsTest.java b/test/unit/org/apache/cassandra/tools/nodetool/GcStatsTest.java index 0daadada42..a37985bdfe 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/GcStatsTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/GcStatsTest.java @@ -23,9 +23,8 @@ import org.junit.BeforeClass; import org.junit.Test; import org.yaml.snakeyaml.Yaml; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.service.GCInspector; -import org.apache.cassandra.tools.ToolRunner; import org.apache.cassandra.tools.ToolRunner.ToolResult; import org.apache.cassandra.tools.nodetool.stats.GcStatsHolder; import org.apache.cassandra.utils.JsonUtils; @@ -38,7 +37,7 @@ import static org.apache.cassandra.tools.nodetool.stats.GcStatsHolder.RESERVED_D import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; -public class GcStatsTest extends CQLTester +public class GcStatsTest extends CQLNodetoolProtocolTester { @BeforeClass public static void setUp() throws Exception @@ -51,7 +50,7 @@ public class GcStatsTest extends CQLTester @Test public void testDefaultGcStatsOutput() { - ToolResult tool = ToolRunner.invokeNodetool("gcstats"); + ToolResult tool = invokeNodetool("gcstats"); tool.assertOnCleanExit(); String output = tool.getStdout(); for (String value : GcStatsHolder.columnDescriptionMap.values()) @@ -62,7 +61,7 @@ public class GcStatsTest extends CQLTester public void testJsonGcStatsOutput() { asList("-F", "--format").forEach(arg -> { - ToolResult tool = ToolRunner.invokeNodetool("gcstats", arg, "json"); + ToolResult tool = invokeNodetool("gcstats", arg, "json"); tool.assertOnCleanExit(); String json = tool.getStdout(); assertThatCode(() -> JsonUtils.JSON_OBJECT_MAPPER.readTree(json)).doesNotThrowAnyException(); @@ -76,7 +75,7 @@ public class GcStatsTest extends CQLTester public void testYamlGcStatsOutput() { asList("-F", "--format").forEach(arg -> { - ToolResult tool = ToolRunner.invokeNodetool("gcstats", arg, "yaml"); + ToolResult tool = invokeNodetool("gcstats", arg, "yaml"); tool.assertOnCleanExit(); String yamlOutput = tool.getStdout(); Yaml yaml = new Yaml(); @@ -90,7 +89,7 @@ public class GcStatsTest extends CQLTester @Test public void testInvalidFormatOption() { - ToolResult tool = ToolRunner.invokeNodetool("gcstats", "-F", "invalid_format"); + ToolResult tool = invokeNodetool("gcstats", "-F", "invalid_format"); assertThat(tool.getExitCode()).isEqualTo(1); assertThat(tool.getStdout()).contains("arguments for -F are json, yaml, table only."); } @@ -98,7 +97,7 @@ public class GcStatsTest extends CQLTester @Test public void testWithoutNoOption() { - ToolResult tool = ToolRunner.invokeNodetool("gcstats"); + ToolResult tool = invokeNodetool("gcstats"); tool.assertOnCleanExit(); for (String value : GcStatsHolder.columnDescriptionMap.values()) @@ -108,7 +107,7 @@ public class GcStatsTest extends CQLTester @Test public void testWithHumanReadableOption() { - ToolResult tool = ToolRunner.invokeNodetool("gcstats", "--human-readable", "-F", "table"); + ToolResult tool = invokeNodetool("gcstats", "--human-readable", "-F", "table"); tool.assertOnCleanExit(); String gcStatsOutput = tool.getStdout(); diff --git a/test/unit/org/apache/cassandra/tools/nodetool/GetAuditLogTest.java b/test/unit/org/apache/cassandra/tools/nodetool/GetAuditLogTest.java index 3ff266059d..8646c689c0 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/GetAuditLogTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/GetAuditLogTest.java @@ -28,7 +28,7 @@ import org.junit.Test; import org.apache.cassandra.audit.AuditLogOptions; import org.apache.cassandra.config.DatabaseDescriptor; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.tools.ToolRunner; import static org.assertj.core.api.Assertions.assertThat; @@ -37,7 +37,7 @@ import static org.assertj.core.api.Assertions.assertThat; * Tests for the {@code GetAuditLog} nodetool command. * @see GetAuditLog */ -public class GetAuditLogTest extends CQLTester +public class GetAuditLogTest extends CQLNodetoolProtocolTester { @BeforeClass public static void setup() throws Exception @@ -84,24 +84,24 @@ public class GetAuditLogTest extends CQLTester private String getAuditLog() { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("getauditlog"); + ToolRunner.ToolResult tool = invokeNodetool("getauditlog"); tool.assertOnCleanExit(); return tool.getStdout(); } private void disableAuditLog() { - ToolRunner.invokeNodetool("disableauditlog").assertOnCleanExit(); + invokeNodetool("disableauditlog").assertOnCleanExit(); } private void enableAuditLogSimple() { - ToolRunner.invokeNodetool("enableauditlog").assertOnCleanExit(); + invokeNodetool("enableauditlog").assertOnCleanExit(); } private void enableAuditLogComplex() { - ToolRunner.invokeNodetool("enableauditlog", + invokeNodetool("enableauditlog", "--included-keyspaces", "ks1,ks2,ks3", "--excluded-categories", "ddl,dcl").assertOnCleanExit(); } diff --git a/test/unit/org/apache/cassandra/tools/nodetool/GetAuthCacheConfigTest.java b/test/unit/org/apache/cassandra/tools/nodetool/GetAuthCacheConfigTest.java index 02fd75e827..014d1cbd23 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/GetAuthCacheConfigTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/GetAuthCacheConfigTest.java @@ -35,6 +35,10 @@ import org.apache.cassandra.tools.ToolRunner; import static org.assertj.core.api.Assertions.assertThat; +/** + * TODO: CASSANDRA-XXXXX Test needs to be migrated to CQLNodetoolProtocolTester + * once nodetool supports passing authentication credentials. + */ public class GetAuthCacheConfigTest extends CQLTester { @BeforeClass diff --git a/test/unit/org/apache/cassandra/tools/nodetool/GetCIDRGroupsOfIPTest.java b/test/unit/org/apache/cassandra/tools/nodetool/GetCIDRGroupsOfIPTest.java index c274e0a4ab..03c9e3e419 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/GetCIDRGroupsOfIPTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/GetCIDRGroupsOfIPTest.java @@ -34,6 +34,10 @@ import org.apache.cassandra.tools.ToolRunner; import static org.assertj.core.api.Assertions.assertThat; +/** + * TODO: CASSANDRA-XXXXX Test needs to be migrated to CQLNodetoolProtocolTester + * once nodetool supports passing authentication credentials. + */ public class GetCIDRGroupsOfIPTest extends CQLTester { @BeforeClass diff --git a/test/unit/org/apache/cassandra/tools/nodetool/GetDefaultKeyspaceRFTest.java b/test/unit/org/apache/cassandra/tools/nodetool/GetDefaultKeyspaceRFTest.java index 32ab151772..c72a44640c 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/GetDefaultKeyspaceRFTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/GetDefaultKeyspaceRFTest.java @@ -22,12 +22,12 @@ import org.junit.BeforeClass; import org.junit.Test; import org.apache.cassandra.config.DatabaseDescriptor; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.tools.ToolRunner; import static org.assertj.core.api.Assertions.assertThat; -public class GetDefaultKeyspaceRFTest extends CQLTester +public class GetDefaultKeyspaceRFTest extends CQLNodetoolProtocolTester { @BeforeClass public static void setup() throws Exception @@ -39,7 +39,7 @@ public class GetDefaultKeyspaceRFTest extends CQLTester @Test public void testGetDefaultRF() { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("getdefaultrf"); + ToolRunner.ToolResult tool = invokeNodetool("getdefaultrf"); tool.assertOnCleanExit(); assertThat(tool.getStdout().trim()).isEqualTo(Integer.toString(DatabaseDescriptor.getDefaultKeyspaceRF())); } diff --git a/test/unit/org/apache/cassandra/tools/nodetool/GetFullQueryLogTest.java b/test/unit/org/apache/cassandra/tools/nodetool/GetFullQueryLogTest.java index d4877ffa29..d3e6919332 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/GetFullQueryLogTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/GetFullQueryLogTest.java @@ -24,7 +24,7 @@ import org.junit.ClassRule; import org.junit.Test; import org.junit.rules.TemporaryFolder; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.fql.FullQueryLoggerOptions; import org.apache.cassandra.tools.ToolRunner; @@ -38,7 +38,7 @@ import static org.assertj.core.api.Assertions.assertThat; * @see ResetFullQueryLog * @see DisableFullQueryLog */ -public class GetFullQueryLogTest extends CQLTester +public class GetFullQueryLogTest extends CQLNodetoolProtocolTester { @ClassRule public static TemporaryFolder temporaryFolder = new TemporaryFolder(); @@ -83,24 +83,24 @@ public class GetFullQueryLogTest extends CQLTester private String getFullQueryLog() { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("getfullquerylog"); + ToolRunner.ToolResult tool = invokeNodetool("getfullquerylog"); tool.assertOnCleanExit(); return tool.getStdout(); } private void resetFullQueryLog() { - ToolRunner.invokeNodetool("resetfullquerylog").assertOnCleanExit(); + invokeNodetool("resetfullquerylog").assertOnCleanExit(); } private void disableFullQueryLog() { - ToolRunner.invokeNodetool("disablefullquerylog").assertOnCleanExit(); + invokeNodetool("disablefullquerylog").assertOnCleanExit(); } private void enableFullQueryLog() { - ToolRunner.invokeNodetool("enablefullquerylog", + invokeNodetool("enablefullquerylog", "--path", temporaryFolder.getRoot().toString(), "--blocking", diff --git a/test/unit/org/apache/cassandra/tools/nodetool/GossipInfoTest.java b/test/unit/org/apache/cassandra/tools/nodetool/GossipInfoTest.java index 5d703e7f26..63e87bb95b 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/GossipInfoTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/GossipInfoTest.java @@ -23,7 +23,7 @@ import org.assertj.core.api.Assertions; import org.junit.BeforeClass; import org.junit.Test; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.net.Message; import org.apache.cassandra.net.MessagingService; import org.apache.cassandra.net.NoPayload; @@ -37,7 +37,7 @@ import static org.assertj.core.api.Assertions.assertThat; /** * @see GossipInfo */ -public class GossipInfoTest extends CQLTester +public class GossipInfoTest extends CQLNodetoolProtocolTester { private static String token; @@ -52,7 +52,7 @@ public class GossipInfoTest extends CQLTester @Test public void testGossipInfo() { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("gossipinfo"); + ToolRunner.ToolResult tool = invokeNodetool("gossipinfo"); tool.assertOnCleanExit(); String stdout = tool.getStdout(); Assertions.assertThat(stdout).contains("/127.0.0.1"); @@ -75,7 +75,7 @@ public class GossipInfoTest extends CQLTester MessagingService.instance().send(echoMessageOut, FBUtilities.getBroadcastAddressAndPort()); String origHeartbeatCount = StringUtils.substringBetween(stdout, "heartbeat:", "\n"); - tool = ToolRunner.invokeNodetool("gossipinfo"); + tool = invokeNodetool("gossipinfo"); tool.assertOnCleanExit(); String newHeartbeatCount = StringUtils.substringBetween(stdout, "heartbeat:", "\n"); assertThat(Integer.parseInt(origHeartbeatCount)).isLessThanOrEqualTo(Integer.parseInt(newHeartbeatCount)); @@ -84,7 +84,7 @@ public class GossipInfoTest extends CQLTester @Test public void testGossipInfoWithPortPrint() { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("-pp", "gossipinfo"); + ToolRunner.ToolResult tool = invokeNodetool("-pp", "gossipinfo"); tool.assertOnCleanExit(); String stdout = tool.getStdout(); Assertions.assertThat(stdout).containsPattern("/127.0.0.1\\:[0-9]+\\s+generation"); @@ -93,7 +93,7 @@ public class GossipInfoTest extends CQLTester @Test public void testGossipInfoWithResolveIp() { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("gossipinfo", "--resolve-ip"); + ToolRunner.ToolResult tool = invokeNodetool("gossipinfo", "--resolve-ip"); tool.assertOnCleanExit(); String stdout = tool.getStdout(); Assertions.assertThat(stdout).containsPattern("^localhost\\s+generation"); @@ -102,7 +102,7 @@ public class GossipInfoTest extends CQLTester @Test public void testGossipInfoWithPortPrintAndResolveIp() { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("-pp", "gossipinfo", "--resolve-ip"); + ToolRunner.ToolResult tool = invokeNodetool("-pp", "gossipinfo", "--resolve-ip"); tool.assertOnCleanExit(); String stdout = tool.getStdout(); Assertions.assertThat(stdout).containsPattern("^localhost\\:[0-9]+\\s+generation"); diff --git a/test/unit/org/apache/cassandra/tools/nodetool/GuardrailsConfigCommandsTest.java b/test/unit/org/apache/cassandra/tools/nodetool/GuardrailsConfigCommandsTest.java index 86430dba97..c7435670af 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/GuardrailsConfigCommandsTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/GuardrailsConfigCommandsTest.java @@ -27,22 +27,22 @@ import java.util.List; import java.util.Map; import java.util.Set; +import org.junit.After; import org.junit.BeforeClass; import org.junit.Test; import org.apache.cassandra.config.Config; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.db.guardrails.GuardrailsMBean; import org.apache.cassandra.tools.ToolRunner.ToolResult; import org.apache.cassandra.tools.nodetool.GuardrailsConfigCommand.GetGuardrailsConfig; -import static org.apache.cassandra.tools.ToolRunner.invokeNodetool; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; -public class GuardrailsConfigCommandsTest extends CQLTester +public class GuardrailsConfigCommandsTest extends CQLNodetoolProtocolTester { @BeforeClass public static void setup() throws Exception @@ -51,6 +51,14 @@ public class GuardrailsConfigCommandsTest extends CQLTester startJMXServer(); } + @After + public void tearDown() + { + setFlag("allow_filtering_enabled", true); + setValues("table_properties_warned", "null"); + setThresholds("keyspaces_threshold", "-1", "-1"); + } + @Test public void testGuardrailsConfigCommands() { diff --git a/test/unit/org/apache/cassandra/tools/nodetool/InfoTest.java b/test/unit/org/apache/cassandra/tools/nodetool/InfoTest.java new file mode 100644 index 0000000000..f9c9d413ef --- /dev/null +++ b/test/unit/org/apache/cassandra/tools/nodetool/InfoTest.java @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.tools.nodetool; + +import java.lang.management.ManagementFactory; + +import javax.management.ObjectName; + +import org.junit.BeforeClass; +import org.junit.Test; + +import org.apache.cassandra.cache.ChunkCache; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; +import org.apache.cassandra.metrics.CassandraMetricsRegistry; +import org.apache.cassandra.tools.ToolRunner; +import org.apache.cassandra.utils.MBeanWrapper; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @see Info + */ +public class InfoTest extends CQLNodetoolProtocolTester +{ + @BeforeClass + public static void setup() throws Exception + { + requireNetwork(); + startJMXServer(); + } + + @Test + public void testInfoOutput() + { + ToolRunner.ToolResult tool = invokeNodetool("info"); + tool.assertOnCleanExit(); + String stdout = tool.getStdout(); + + assertThat(stdout).contains("ID"); + assertThat(stdout).contains("Gossip active"); + assertThat(stdout).contains("Native Transport active"); + assertThat(stdout).contains("Load"); + assertThat(stdout).contains("Uncompressed load"); + assertThat(stdout).contains("Generation No"); + assertThat(stdout).contains("Uptime (seconds)"); + assertThat(stdout).contains("Heap Memory (MB)"); + assertThat(stdout).contains("Data Center"); + assertThat(stdout).contains("Rack"); + assertThat(stdout).contains("Exceptions"); + assertThat(stdout).contains("Key Cache"); + assertThat(stdout).contains("Row Cache"); + assertThat(stdout).contains("Counter Cache"); + assertThat(stdout).contains("Percent Repaired"); + assertThat(stdout).contains("Token"); + assertThat(stdout).contains("Bootstrap state"); + } + + @Test + public void testInfoWithMissingCacheMBean() throws Exception + { + javax.management.MBeanServer mbs = ManagementFactory.getPlatformMBeanServer(); + ObjectName chunkCacheEntries = new ObjectName("org.apache.cassandra.metrics:type=Cache,scope=ChunkCache,name=Entries"); + + boolean wasRegistered = mbs.isRegistered(chunkCacheEntries); + if (wasRegistered) + mbs.unregisterMBean(chunkCacheEntries); + try + { + ToolRunner.ToolResult tool = invokeNodetool("info"); + tool.assertOnCleanExit(); + String stdout = tool.getStdout(); + + assertThat(stdout).doesNotContain("Chunk Cache"); + assertThat(stdout).contains("Percent Repaired"); + assertThat(stdout).contains("Bootstrap state"); + } + finally + { + if (wasRegistered && !mbs.isRegistered(chunkCacheEntries)) + { + CassandraMetricsRegistry.Metrics.registerMBean( + ChunkCache.instance.metrics.entries, chunkCacheEntries, MBeanWrapper.instance, false); + } + } + } +} diff --git a/test/unit/org/apache/cassandra/tools/nodetool/InvalidateCIDRPermissionsCacheTest.java b/test/unit/org/apache/cassandra/tools/nodetool/InvalidateCIDRPermissionsCacheTest.java index 8b4eb0d153..35660e547a 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/InvalidateCIDRPermissionsCacheTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/InvalidateCIDRPermissionsCacheTest.java @@ -35,6 +35,8 @@ import static org.apache.cassandra.auth.AuthTestUtils.ROLE_B; import static org.apache.cassandra.auth.AuthTestUtils.getCidrPermissionsReadCount; import static org.assertj.core.api.Assertions.assertThat; +// TODO: CASSANDRA-XXXXX Test needs to be migrated to CQLNodetoolProtocolTester +// once nodetool supports passing authentication credentials. public class InvalidateCIDRPermissionsCacheTest extends CQLTester { static InetSocketAddress ipAddr; diff --git a/test/unit/org/apache/cassandra/tools/nodetool/InvalidateCredentialsCacheTest.java b/test/unit/org/apache/cassandra/tools/nodetool/InvalidateCredentialsCacheTest.java index 964d1213a5..00b3cd12dc 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/InvalidateCredentialsCacheTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/InvalidateCredentialsCacheTest.java @@ -40,6 +40,9 @@ import static org.assertj.core.api.Assertions.assertThat; /** * @see InvalidateCredentialsCache + * + * TODO: CASSANDRA-XXXXX Test needs to be migrated to CQLNodetoolProtocolTester + * once nodetool supports passing authentication credentials. */ public class InvalidateCredentialsCacheTest extends CQLTester { diff --git a/test/unit/org/apache/cassandra/tools/nodetool/InvalidateJmxPermissionsCacheTest.java b/test/unit/org/apache/cassandra/tools/nodetool/InvalidateJmxPermissionsCacheTest.java index aba50a051c..c054bfe09b 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/InvalidateJmxPermissionsCacheTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/InvalidateJmxPermissionsCacheTest.java @@ -46,6 +46,9 @@ import static org.assertj.core.api.Assertions.assertThat; /** * @see InvalidateJmxPermissionsCache + * + * TODO: CASSANDRA-XXXXX Test needs to be migrated to CQLNodetoolProtocolTester + * once nodetool supports passing authentication credentials. */ public class InvalidateJmxPermissionsCacheTest extends CQLTester { diff --git a/test/unit/org/apache/cassandra/tools/nodetool/InvalidateNetworkPermissionsCacheTest.java b/test/unit/org/apache/cassandra/tools/nodetool/InvalidateNetworkPermissionsCacheTest.java index 538e97d685..f8afed062e 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/InvalidateNetworkPermissionsCacheTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/InvalidateNetworkPermissionsCacheTest.java @@ -34,6 +34,8 @@ import static org.apache.cassandra.auth.AuthTestUtils.ROLE_B; import static org.apache.cassandra.auth.AuthTestUtils.getNetworkPermissionsReadCount; import static org.assertj.core.api.Assertions.assertThat; +// TODO: CASSANDRA-XXXXX Test needs to be migrated to CQLNodetoolProtocolTester +// once nodetool supports passing authentication credentials. public class InvalidateNetworkPermissionsCacheTest extends CQLTester { @BeforeClass diff --git a/test/unit/org/apache/cassandra/tools/nodetool/InvalidatePermissionsCacheTest.java b/test/unit/org/apache/cassandra/tools/nodetool/InvalidatePermissionsCacheTest.java index de6064d877..733c1c298e 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/InvalidatePermissionsCacheTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/InvalidatePermissionsCacheTest.java @@ -50,6 +50,8 @@ import static org.apache.cassandra.auth.AuthTestUtils.ROLE_B; import static org.apache.cassandra.auth.AuthTestUtils.getRolePermissionsReadCount; import static org.assertj.core.api.Assertions.assertThat; +// TODO: CASSANDRA-XXXXX Test needs to be migrated to CQLNodetoolProtocolTester +// once nodetool supports passing authentication credentials. public class InvalidatePermissionsCacheTest extends CQLTester { @BeforeClass diff --git a/test/unit/org/apache/cassandra/tools/nodetool/InvalidateRolesCacheTest.java b/test/unit/org/apache/cassandra/tools/nodetool/InvalidateRolesCacheTest.java index 0dacba9cef..394a611e13 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/InvalidateRolesCacheTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/InvalidateRolesCacheTest.java @@ -34,6 +34,8 @@ import static org.apache.cassandra.auth.AuthTestUtils.ROLE_B; import static org.apache.cassandra.auth.AuthTestUtils.getRolesReadCount; import static org.assertj.core.api.Assertions.assertThat; +// TODO: CASSANDRA-XXXXX Test needs to be migrated to CQLNodetoolProtocolTester +// once nodetool supports passing authentication credentials. public class InvalidateRolesCacheTest extends CQLTester { @BeforeClass diff --git a/test/unit/org/apache/cassandra/tools/nodetool/ListCIDRGroupTest.java b/test/unit/org/apache/cassandra/tools/nodetool/ListCIDRGroupTest.java index 071dd80e9e..7bdc1e44bf 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/ListCIDRGroupTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/ListCIDRGroupTest.java @@ -35,6 +35,8 @@ import org.apache.cassandra.tools.ToolRunner; import static org.apache.cassandra.auth.AuthTestUtils.insertCidrsMappings; import static org.assertj.core.api.Assertions.assertThat; +// TODO: CASSANDRA-XXXXX Test needs to be migrated to CQLNodetoolProtocolTester +// once nodetool supports passing authentication credentials. public class ListCIDRGroupTest extends CQLTester { @BeforeClass diff --git a/test/unit/org/apache/cassandra/tools/nodetool/NetStatsTest.java b/test/unit/org/apache/cassandra/tools/nodetool/NetStatsTest.java index d033859477..8c2773ff77 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/NetStatsTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/NetStatsTest.java @@ -28,7 +28,7 @@ import java.util.List; import org.junit.BeforeClass; import org.junit.Test; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.locator.InetAddressAndPort; import org.apache.cassandra.net.Message; import org.apache.cassandra.net.MessagingService; @@ -44,7 +44,7 @@ import static java.util.Collections.emptyList; import static org.apache.cassandra.net.Verb.ECHO_REQ; import static org.assertj.core.api.Assertions.assertThat; -public class NetStatsTest extends CQLTester +public class NetStatsTest extends CQLNodetoolProtocolTester { @BeforeClass public static void setup() throws Exception @@ -59,9 +59,9 @@ public class NetStatsTest extends CQLTester Message echoMessageOut = Message.out(ECHO_REQ, NoPayload.noPayload); MessagingService.instance().send(echoMessageOut, FBUtilities.getBroadcastAddressAndPort()); - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("netstats"); + ToolRunner.ToolResult tool = invokeNodetool("netstats"); tool.assertOnCleanExit(); - assertThat(tool.getStdout()).contains("Gossip messages n/a 0 2 0"); + assertThat(tool.getStdout()).containsPattern("Gossip messages\\s+n/a\\s+0\\s+\\d+\\s+0"); } @Test diff --git a/test/unit/org/apache/cassandra/tools/nodetool/NodetoolClassHierarchyTest.java b/test/unit/org/apache/cassandra/tools/nodetool/NodetoolClassHierarchyTest.java index 66e764aecf..a9bd791d95 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/NodetoolClassHierarchyTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/NodetoolClassHierarchyTest.java @@ -20,6 +20,7 @@ package org.apache.cassandra.tools.nodetool; import java.lang.reflect.Field; import java.util.ArrayList; +import java.util.Arrays; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -222,6 +223,213 @@ public class NodetoolClassHierarchyTest extends CQLTester return name; } + /** + * Previously, in the Airline implementation of nodetool, the defaul values for command + * parameters were declared as values in the java field of the command class. We left this + * as-is, but with picocli we have to make sure that this is consistend with annotations + * such as {@code @Option(defaultValue = "...")}. + *

+ * When {@code @Option(defaultValue = "...")} is declared on a field, the Java field + * initializer must produce the same value. This is required because picocli only applies + * {@code defaultValue} during {@code parseArgs()}, any code that reads the field before + * parsing sees only the Java initializer, not the annotations default. + */ + @Test + public void testOptionAnnotationDefaultMatchesJavaInitializer() + { + CommandLine root = new CommandLine(NodetoolCommand.class); + Map> violations = new TreeMap<>(); + + commandTreeWalker(root, cmd -> { + List cmdViolations = collectDefaultValueMismatches(cmd); + if (!cmdViolations.isEmpty()) + violations.put(fullCommandName(cmd), cmdViolations); + }); + + assertTrue("The following commands have @Option(defaultValue) that does not match " + + "the Java field initializer. Either remove defaultValue from the annotation " + + "or align the Java field initializer with it:\n" + + buildAffectedCommandMessage(violations), + violations.isEmpty()); + } + + /** + * Commands must not declare aliases. Alias MBean registration is not supported, + * so allowing aliases would create a mismatch between the CLI (which would honor them) + * and the JMX/CQL transports (which would not). If alias support is added in the future, + * this test should be replaced with proper alias-aware registration and lookup logic. + */ + @Test + public void testNoCommandDeclaresAliases() + { + CommandLine root = new CommandLine(NodetoolCommand.class); + Map> affected = new TreeMap<>(); + + commandTreeWalker(root, cmd -> { + String[] aliases = cmd.getCommandSpec().aliases(); + if (aliases.length > 0) + affected.put(fullCommandName(cmd), Arrays.asList(aliases)); + }); + + assertTrue("The following commands declare aliases, but alias registration " + + "is not yet supported across all transports (JMX, CQL):\n" + + buildAffectedCommandMessage(affected), + affected.isEmpty()); + } + + /** + * Commands must not declare custom picocli {@code converters} on options or parameters. + *

+ * Instead, options and parameters must use plain strings, lists of strings, or the built-in Java + * types supported by {@code TypeConverterRegistry} (primitives, enums, and collections/arrays of + * those). Commands that need a richer domain type should accept one of these simple types and + * perform the conversion in the command body, as several existing commands already do. + */ + @Test + public void testNoCommandDeclaresCustomConverter() + { + CommandLine root = new CommandLine(NodetoolCommand.class); + Map> affected = new TreeMap<>(); + + commandTreeWalker(root, cmd -> { + List converters = collectCustomConverters(cmd); + if (!converters.isEmpty()) + affected.put(fullCommandName(cmd), converters); + }); + + assertTrue("The following commands declare custom picocli converters, which are not supported " + + "by the remote argument-conversion path (it would feed the whole collection's toString() " + + "to a per-element converter). Make PicocliOptionMetadata/PicocliParameterMetadata.convertValue " + + "element-aware before adding one:\n" + + buildAffectedCommandMessage(affected), + affected.isEmpty()); + } + + private static List collectCustomConverters(CommandLine cmd) + { + List converters = new ArrayList<>(); + + for (CommandLine.Model.OptionSpec option : cmd.getCommandSpec().options()) + { + if (option.usageHelp() || option.versionHelp()) + continue; + CommandLine.ITypeConverter[] optionConverters = option.converters(); + if (optionConverters != null && optionConverters.length > 0) + converters.add(String.format("option %s -> %s", + String.join("/", option.names()), + optionConverters[0].getClass().getName())); + } + + for (CommandLine.Model.PositionalParamSpec param : cmd.getCommandSpec().positionalParameters()) + { + CommandLine.ITypeConverter[] paramConverters = param.converters(); + if (paramConverters != null && paramConverters.length > 0) + converters.add(String.format("parameter index=%s (%s) -> %s", + param.index(), + param.paramLabel(), + paramConverters[0].getClass().getName())); + } + + return converters; + } + + /** + * Commands must not declare {@code @ArgGroup} fields. + * Declare the options directly on the command and validate the grouping in the command body. + */ + @Test + public void testNoCommandDeclaresArgGroups() + { + Set> ignore = Set.of(CMSAdmin.DumpClusterMetadata.class); + + CommandLine root = new CommandLine(NodetoolCommand.class); + Map> affected = new TreeMap<>(); + Set> staleExclusions = new LinkedHashSet<>(ignore); + + commandTreeWalker(root, cmd -> { + List groups = collectArgGroups(cmd); + if (groups.isEmpty()) + return; + + Object userObject = cmd.getCommandSpec().userObject(); + if (userObject != null && ignore.contains(userObject.getClass())) + staleExclusions.remove(userObject.getClass()); + else + affected.put(fullCommandName(cmd), groups); + }); + + assertTrue("The following commands declare @ArgGroup fields, which cannot be populated " + + "by the remote execution path (picocli only instantiates group objects during " + + "parseArgs(), which the server never runs). Declare the options directly on " + + "the command and validate the grouping in the command body:\n" + + buildAffectedCommandMessage(affected), + affected.isEmpty()); + + assertTrue("Commands excluded as pending @ArgGroup refactoring no longer declare @ArgGroup, " + + "remove them from the exclusion list: " + staleExclusions, + staleExclusions.isEmpty()); + } + + private static List collectArgGroups(CommandLine cmd) + { + List groups = new ArrayList<>(); + for (CommandLine.Model.ArgGroupSpec group : cmd.getCommandSpec().argGroups()) + { + List members = new ArrayList<>(); + for (CommandLine.Model.OptionSpec option : group.allOptionsNested()) + members.add(String.join("/", option.names())); + groups.add("group [" + String.join(", ", members) + "]"); + } + return groups; + } + + private static List collectDefaultValueMismatches(CommandLine cmd) + { + List violations = new ArrayList<>(); + + for (CommandLine.Model.OptionSpec optionSpec : cmd.getCommandSpec().options()) + { + if (optionSpec.usageHelp() || optionSpec.versionHelp()) + continue; + + String annotationDefault = optionSpec.defaultValue(); + if (annotationDefault == null) + continue; + + Object javaValue = optionSpec.getValue(); + String javaValueStr = javaValue == null ? "null" : String.valueOf(javaValue); + + if (!annotationDefault.equals(javaValueStr)) + { + violations.add(String.format("option %s: @Option(defaultValue=\"%s\") but Java initializer gives \"%s\"", + Arrays.toString(optionSpec.names()), + annotationDefault, + javaValueStr)); + } + } + + for (CommandLine.Model.PositionalParamSpec paramSpec : cmd.getCommandSpec().positionalParameters()) + { + String annotationDefault = paramSpec.defaultValue(); + if (annotationDefault == null) + continue; + + Object javaValue = paramSpec.getValue(); + String javaValueStr = javaValue == null ? "null" : String.valueOf(javaValue); + + if (!annotationDefault.equals(javaValueStr)) + { + violations.add(String.format("parameter index=%s (%s): @Parameters(defaultValue=\"%s\") but Java initializer gives \"%s\"", + paramSpec.index(), + paramSpec.paramLabel(), + annotationDefault, + javaValueStr)); + } + } + + return violations; + } + /** * For a given command, follows the {@code @ParentCommand} chain and collects all * options and parameters declared on each parent. diff --git a/test/unit/org/apache/cassandra/tools/nodetool/NodetoolConnectFailureTest.java b/test/unit/org/apache/cassandra/tools/nodetool/NodetoolConnectFailureTest.java new file mode 100644 index 0000000000..9c1f772f44 --- /dev/null +++ b/test/unit/org/apache/cassandra/tools/nodetool/NodetoolConnectFailureTest.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.tools.nodetool; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.BeforeClass; +import org.junit.Test; + +import org.apache.cassandra.config.CassandraRelevantProperties; +import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.distributed.shared.WithProperties; +import org.apache.cassandra.tools.NodeTool; +import org.apache.cassandra.tools.ToolRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +public class NodetoolConnectFailureTest extends CQLTester +{ + private static final String CLOSED_HOST = "127.0.0.1"; + // A port that is essentially always closed; connecting to it fails fast. + private static final String CLOSED_PORT = "2"; + + @BeforeClass + public static void setup() throws Exception + { + requireNetwork(); + } + + @Test + public void cqlConnectFailurePrintsFriendlyMessageAndExits() + { + assertConnectFailure("cql", "nodetool: Failed to connect to '" + CLOSED_HOST + ':' + CLOSED_PORT + "' via CQL"); + } + + @Test + public void staticMBeanConnectFailurePrintsFriendlyMessageAndExits() + { + assertConnectFailure("static_mbean", "nodetool: Failed to connect to '" + CLOSED_HOST + ':' + CLOSED_PORT + '\''); + } + + @Test + public void commandMBeanConnectFailurePrintsFriendlyMessageAndExits() + { + assertConnectFailure("command_mbean", "nodetool: Failed to connect to '" + CLOSED_HOST + ':' + CLOSED_PORT + '\''); + } + + private static void assertConnectFailure(String protocol, String expectedMessage) + { + try (WithProperties ignored = new WithProperties() + .set(CassandraRelevantProperties.CASSANDRA_CLI_EXECUTION_PROTOCOL, protocol)) + { + ToolRunner.ToolResult result = + ToolRunner.invokeNodetoolInJvm(NodeTool::new, NodetoolConnectFailureTest::closedTargetArgs, "status"); + + assertThat(result.getExitCode()).as("connect failure should exit 1 (clean), not 2 (stack trace)") + .isEqualTo(1); + assertThat(result.getCleanedStderr()).contains(expectedMessage) + .doesNotContain("-- StackTrace --"); + } + } + + /** Builds nodetool args with the closed host/port placed before the subcommand. */ + private static List closedTargetArgs(List commandArgs) + { + List all = new ArrayList<>(); + all.add("bin/nodetool"); + all.add("-h"); + all.add(CLOSED_HOST); + all.add("-p"); + all.add(CLOSED_PORT); + all.addAll(commandArgs); + return all; + } +} diff --git a/test/unit/org/apache/cassandra/tools/nodetool/ReloadCIDRGroupsCacheTest.java b/test/unit/org/apache/cassandra/tools/nodetool/ReloadCIDRGroupsCacheTest.java index 8c50083189..c7185aeecc 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/ReloadCIDRGroupsCacheTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/ReloadCIDRGroupsCacheTest.java @@ -38,6 +38,8 @@ import static org.apache.cassandra.auth.AuthTestUtils.ROLE_B; import static org.apache.cassandra.schema.SchemaConstants.AUTH_KEYSPACE_NAME; import static org.assertj.core.api.Assertions.assertThat; +// TODO: CASSANDRA-XXXXX Test needs to be migrated to CQLNodetoolProtocolTester +// once nodetool supports passing authentication credentials. public class ReloadCIDRGroupsCacheTest extends CQLTester { static InetSocketAddress ipAddr; diff --git a/test/unit/org/apache/cassandra/tools/nodetool/SetAuthCacheConfigTest.java b/test/unit/org/apache/cassandra/tools/nodetool/SetAuthCacheConfigTest.java index 4bcd6e1984..d9b301afa5 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/SetAuthCacheConfigTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/SetAuthCacheConfigTest.java @@ -35,6 +35,8 @@ import org.apache.cassandra.tools.ToolRunner; import static org.assertj.core.api.Assertions.assertThat; +// TODO: CASSANDRA-XXXXX Test needs to be migrated to CQLNodetoolProtocolTester +// once nodetool supports passing authentication credentials. public class SetAuthCacheConfigTest extends CQLTester { @BeforeClass diff --git a/test/unit/org/apache/cassandra/tools/nodetool/SetDefaultKeyspaceRFTest.java b/test/unit/org/apache/cassandra/tools/nodetool/SetDefaultKeyspaceRFTest.java index d7b0985f07..320defe11c 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/SetDefaultKeyspaceRFTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/SetDefaultKeyspaceRFTest.java @@ -22,12 +22,12 @@ import org.junit.BeforeClass; import org.junit.Test; import org.apache.cassandra.config.DatabaseDescriptor; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.tools.ToolRunner; import static org.assertj.core.api.Assertions.assertThat; -public class SetDefaultKeyspaceRFTest extends CQLTester +public class SetDefaultKeyspaceRFTest extends CQLNodetoolProtocolTester { @BeforeClass public static void setup() throws Exception diff --git a/test/unit/org/apache/cassandra/tools/nodetool/SetGetColumnIndexSizeTest.java b/test/unit/org/apache/cassandra/tools/nodetool/SetGetColumnIndexSizeTest.java index 9b37d0ede4..bf8eec6d15 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/SetGetColumnIndexSizeTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/SetGetColumnIndexSizeTest.java @@ -21,17 +21,16 @@ package org.apache.cassandra.tools.nodetool; import org.junit.BeforeClass; import org.junit.Test; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.service.StorageService; import static org.apache.cassandra.tools.ToolRunner.ToolResult; -import static org.apache.cassandra.tools.ToolRunner.invokeNodetool; import static org.assertj.core.api.Assertions.assertThat; /** * Tests for {@code nodetool setcolumnindexsize} and {@code nodetool getcolumnindexsize}. */ -public class SetGetColumnIndexSizeTest extends CQLTester +public class SetGetColumnIndexSizeTest extends CQLNodetoolProtocolTester { @BeforeClass public static void setup() throws Exception @@ -83,7 +82,7 @@ public class SetGetColumnIndexSizeTest extends CQLTester assertSetInvalidColumnIndexSize("value", "Invalid value for positional parameter at index 0 (column_index_size): 'value' is not an int", 1); } - private static void assertSetGetValidColumnIndexSize(int columnIndexSizeInKB) + private void assertSetGetValidColumnIndexSize(int columnIndexSizeInKB) { ToolResult tool = invokeNodetool("setcolumnindexsize", String.valueOf(columnIndexSizeInKB)); tool.assertOnCleanExit(); @@ -94,7 +93,7 @@ public class SetGetColumnIndexSizeTest extends CQLTester assertThat(StorageService.instance.getColumnIndexSizeInKiB()).isEqualTo(columnIndexSizeInKB); } - private static void assertSetInvalidColumnIndexSize(String columnIndexSizeInKB, String expectedErrorMessage, int expectedErrorCode) + private void assertSetInvalidColumnIndexSize(String columnIndexSizeInKB, String expectedErrorMessage, int expectedErrorCode) { ToolResult tool = columnIndexSizeInKB == null ? invokeNodetool("setcolumnindexsize") : invokeNodetool("setcolumnindexsize", columnIndexSizeInKB); @@ -102,7 +101,7 @@ public class SetGetColumnIndexSizeTest extends CQLTester assertThat(expectedErrorCode == 1 ? tool.getStdout() : tool.getStderr()).contains(expectedErrorMessage); } - private static void assertGetThroughput(int expected) + private void assertGetThroughput(int expected) { ToolResult tool = invokeNodetool("getcolumnindexsize"); tool.assertOnCleanExit(); diff --git a/test/unit/org/apache/cassandra/tools/nodetool/SetGetCompactionThroughputTest.java b/test/unit/org/apache/cassandra/tools/nodetool/SetGetCompactionThroughputTest.java index 5e24fb0d4a..411811fac0 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/SetGetCompactionThroughputTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/SetGetCompactionThroughputTest.java @@ -22,16 +22,15 @@ import org.junit.BeforeClass; import org.junit.Test; import org.apache.cassandra.config.DatabaseDescriptor; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import static org.apache.cassandra.tools.ToolRunner.ToolResult; -import static org.apache.cassandra.tools.ToolRunner.invokeNodetool; import static org.assertj.core.api.Assertions.assertThat; /** * Tests for {@code nodetool setcompactionthroughput} and {@code nodetool getcompactionthroughput}. */ -public class SetGetCompactionThroughputTest extends CQLTester +public class SetGetCompactionThroughputTest extends CQLNodetoolProtocolTester { private static final int MAX_INT_CONFIG_VALUE_IN_MBIT = Integer.MAX_VALUE - 1; @@ -92,7 +91,7 @@ public class SetGetCompactionThroughputTest extends CQLTester assertThat(tool.getStdout()).containsPattern("Current compaction throughput \\(15 minute\\): \\d+\\.\\d+ MiB/s"); } - private static void assertSetGetValidThroughput(int throughput) + private void assertSetGetValidThroughput(int throughput) { ToolResult tool = invokeNodetool("setcompactionthroughput", String.valueOf(throughput)); tool.assertOnCleanExit(); @@ -102,7 +101,7 @@ public class SetGetCompactionThroughputTest extends CQLTester assertGetThroughputDouble(throughput); } - private static void assertSetInvalidThroughput(String throughput, String expectedErrorMessage) + private void assertSetInvalidThroughput(String throughput, String expectedErrorMessage) { ToolResult tool = throughput == null ? invokeNodetool("setcompactionthroughput") : invokeNodetool("setcompactionthroughput", throughput); @@ -110,7 +109,7 @@ public class SetGetCompactionThroughputTest extends CQLTester assertThat(tool.getStdout()).contains(expectedErrorMessage); } - private static void assertSetInvalidThroughput() + private void assertSetInvalidThroughput() { DatabaseDescriptor.setCompactionThroughputBytesPerSec(500); ToolResult tool = invokeNodetool("getstreamthroughput"); @@ -118,7 +117,7 @@ public class SetGetCompactionThroughputTest extends CQLTester assertThat(tool.getStderr()).contains("Use the -d flag to quiet this error and get the exact throughput in megabits/s"); } - private static void assertSetInvalidThroughputMib(String throughput) + private void assertSetInvalidThroughputMib(String throughput) { ToolResult tool = invokeNodetool("setcompactionthroughput", throughput); assertThat(tool.getExitCode()).isEqualTo(1); @@ -126,7 +125,7 @@ public class SetGetCompactionThroughputTest extends CQLTester " 2147483647 in MiB/s"); } - private static void assertPreciseMibFlagNeeded() + private void assertPreciseMibFlagNeeded() { DatabaseDescriptor.setCompactionThroughputBytesPerSec(15); ToolResult tool = invokeNodetool("getcompactionthroughput"); @@ -134,7 +133,7 @@ public class SetGetCompactionThroughputTest extends CQLTester assertThat(tool.getStderr()).contains("Use the -d flag to quiet this error and get the exact throughput in MiB/s"); } - private static void assertGetThroughput(int expected) + private void assertGetThroughput(int expected) { ToolResult tool = invokeNodetool("getcompactionthroughput"); tool.assertOnCleanExit(); @@ -145,7 +144,7 @@ public class SetGetCompactionThroughputTest extends CQLTester assertThat(tool.getStdout()).contains("Current compaction throughput: 0 MiB/s"); } - private static void assertGetThroughputDouble(double expected) + private void assertGetThroughputDouble(double expected) { ToolResult tool = invokeNodetool("getcompactionthroughput", "-d"); tool.assertOnCleanExit(); diff --git a/test/unit/org/apache/cassandra/tools/nodetool/SetGetEntireSSTableInterDCStreamThroughputTest.java b/test/unit/org/apache/cassandra/tools/nodetool/SetGetEntireSSTableInterDCStreamThroughputTest.java index 7cc489aeb1..b31fdc1cc1 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/SetGetEntireSSTableInterDCStreamThroughputTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/SetGetEntireSSTableInterDCStreamThroughputTest.java @@ -21,11 +21,10 @@ package org.apache.cassandra.tools.nodetool; import org.junit.BeforeClass; import org.junit.Test; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import static org.apache.cassandra.streaming.StreamManager.StreamRateLimiter; import static org.apache.cassandra.tools.ToolRunner.ToolResult; -import static org.apache.cassandra.tools.ToolRunner.invokeNodetool; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.withPrecision; @@ -35,7 +34,7 @@ import static org.assertj.core.api.Assertions.withPrecision; * @see GetInterDCStreamThroughput * @see SetInterDCStreamThroughput */ -public class SetGetEntireSSTableInterDCStreamThroughputTest extends CQLTester +public class SetGetEntireSSTableInterDCStreamThroughputTest extends CQLNodetoolProtocolTester { private static final int MAX_INT_CONFIG_VALUE_MIB = Integer.MAX_VALUE - 1; @@ -83,7 +82,7 @@ public class SetGetEntireSSTableInterDCStreamThroughputTest extends CQLTester assertSetInvalidThroughput("value", "Invalid value for positional parameter at index 0 (inter_dc_stream_throughput): 'value' is not an int"); } - private static void assertSetGetValidThroughput(int throughput, double rateInBytes) + private void assertSetGetValidThroughput(int throughput, double rateInBytes) { ToolResult tool = invokeNodetool("setinterdcstreamthroughput", "-e", String.valueOf(throughput)); tool.assertOnCleanExit(); @@ -94,7 +93,7 @@ public class SetGetEntireSSTableInterDCStreamThroughputTest extends CQLTester assertThat(StreamRateLimiter.getEntireSSTableInterDCRateLimiterRateInBytes()).isEqualTo(rateInBytes, withPrecision(0.01)); } - private static void assertSetInvalidThroughput(String throughput, String expectedErrorMessage) + private void assertSetInvalidThroughput(String throughput, String expectedErrorMessage) { ToolResult tool = throughput == null ? invokeNodetool("setinterdcstreamthroughput", "-e") : invokeNodetool("setinterdcstreamthroughput", "-e", throughput); @@ -102,7 +101,7 @@ public class SetGetEntireSSTableInterDCStreamThroughputTest extends CQLTester assertThat(tool.getStdout()).contains(expectedErrorMessage); } - private static void assertSetInvalidEntireSStableInterDCThroughputMib(String throughput) + private void assertSetInvalidEntireSStableInterDCThroughputMib(String throughput) { ToolResult tool = invokeNodetool("setinterdcstreamthroughput", "-e", throughput); assertThat(tool.getExitCode()).isEqualTo(1); @@ -110,7 +109,7 @@ public class SetGetEntireSSTableInterDCStreamThroughputTest extends CQLTester " it should be less than 2147483647 in MiB/s"); } - private static void assertGetThroughput(double expected) + private void assertGetThroughput(double expected) { ToolResult tool = invokeNodetool("getinterdcstreamthroughput", "-e"); tool.assertOnCleanExit(); diff --git a/test/unit/org/apache/cassandra/tools/nodetool/SetGetEntireSSTableStreamThroughputTest.java b/test/unit/org/apache/cassandra/tools/nodetool/SetGetEntireSSTableStreamThroughputTest.java index d67a8637d9..a277293978 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/SetGetEntireSSTableStreamThroughputTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/SetGetEntireSSTableStreamThroughputTest.java @@ -21,18 +21,17 @@ package org.apache.cassandra.tools.nodetool; import org.junit.BeforeClass; import org.junit.Test; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import static org.apache.cassandra.streaming.StreamManager.StreamRateLimiter; import static org.apache.cassandra.tools.ToolRunner.ToolResult; -import static org.apache.cassandra.tools.ToolRunner.invokeNodetool; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.withPrecision; /** * Tests for entire SSTable {@code nodetool setstreamthroughput} and {@code nodetool getstreamthroughput}. */ -public class SetGetEntireSSTableStreamThroughputTest extends CQLTester +public class SetGetEntireSSTableStreamThroughputTest extends CQLNodetoolProtocolTester { private static final int MAX_INT_CONFIG_VALUE_MIB = Integer.MAX_VALUE - 1; @@ -80,7 +79,7 @@ public class SetGetEntireSSTableStreamThroughputTest extends CQLTester assertSetInvalidThroughput("value", "Invalid value for positional parameter at index 0 (stream_throughput): 'value' is not an int"); } - private static void assertSetGetValidThroughput(int throughput) + private void assertSetGetValidThroughput(int throughput) { ToolResult tool = invokeNodetool("setstreamthroughput", "-e", String.valueOf(throughput)); tool.assertOnCleanExit(); @@ -89,14 +88,14 @@ public class SetGetEntireSSTableStreamThroughputTest extends CQLTester assertGetThroughput(throughput); } - private static void assertSetGetValidThroughput(int throughput, double rateInBytes) + private void assertSetGetValidThroughput(int throughput, double rateInBytes) { assertSetGetValidThroughput(throughput); assertThat(StreamRateLimiter.getEntireSSTableRateLimiterRateInBytes()).isEqualTo(rateInBytes, withPrecision(0.01)); } - private static void assertSetInvalidEntireSStableThroughputMib(String throughput) + private void assertSetInvalidEntireSStableThroughputMib(String throughput) { ToolResult tool = invokeNodetool("setstreamthroughput", "-e", throughput); assertThat(tool.getExitCode()).isEqualTo(1); @@ -104,7 +103,7 @@ public class SetGetEntireSSTableStreamThroughputTest extends CQLTester "should be less than 2147483647 in MiB/s"); } - private static void assertSetInvalidThroughput(String throughput, String expectedErrorMessage) + private void assertSetInvalidThroughput(String throughput, String expectedErrorMessage) { ToolResult tool = throughput == null ? invokeNodetool("setstreamthroughput", "-e") : invokeNodetool("setstreamthroughput", "-e", throughput); @@ -112,7 +111,7 @@ public class SetGetEntireSSTableStreamThroughputTest extends CQLTester assertThat(tool.getStdout()).contains(expectedErrorMessage); } - private static void assertGetThroughput(double expected) + private void assertGetThroughput(double expected) { ToolResult tool = invokeNodetool("getstreamthroughput", "-e"); tool.assertOnCleanExit(); diff --git a/test/unit/org/apache/cassandra/tools/nodetool/SetGetInterDCStreamThroughputTest.java b/test/unit/org/apache/cassandra/tools/nodetool/SetGetInterDCStreamThroughputTest.java index c250ae2854..c90187b181 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/SetGetInterDCStreamThroughputTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/SetGetInterDCStreamThroughputTest.java @@ -22,18 +22,17 @@ import org.junit.BeforeClass; import org.junit.Test; import org.apache.cassandra.config.DataRateSpec; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import static org.apache.cassandra.streaming.StreamManager.StreamRateLimiter; import static org.apache.cassandra.tools.ToolRunner.ToolResult; -import static org.apache.cassandra.tools.ToolRunner.invokeNodetool; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.withPrecision; /** * Tests for {@code nodetool setinterdcstreamthroughput} and {@code nodetool getinterdcstreamthroughput}. */ -public class SetGetInterDCStreamThroughputTest extends CQLTester +public class SetGetInterDCStreamThroughputTest extends CQLNodetoolProtocolTester { private static final int MAX_INT_CONFIG_VALUE_IN_MBIT = Integer.MAX_VALUE - 1; private static final double BYTES_PER_MEGABIT = 125_000; @@ -107,7 +106,7 @@ public class SetGetInterDCStreamThroughputTest extends CQLTester assertDFlagNeeded(); } - private static void assertSetGetValidThroughput(int throughput, double rateInBytes) + private void assertSetGetValidThroughput(int throughput, double rateInBytes) { ToolResult tool = invokeNodetool("setinterdcstreamthroughput", String.valueOf(throughput)); tool.assertOnCleanExit(); @@ -118,7 +117,7 @@ public class SetGetInterDCStreamThroughputTest extends CQLTester assertThat(StreamRateLimiter.getInterDCRateLimiterRateInBytes()).isEqualTo(rateInBytes, withPrecision(0.04)); } - private static void assertDFlagNeeded() + private void assertDFlagNeeded() { ToolResult tool = invokeNodetool("setstreamthroughput", "-m", String.valueOf(1)); tool.assertOnCleanExit(); @@ -129,7 +128,7 @@ public class SetGetInterDCStreamThroughputTest extends CQLTester assertThat(tool.getStderr()).contains("Use the -d flag to quiet this error and get the exact throughput in megabits/s"); } - private static void assertSetGetValidThroughputMiB(int throughput, double rateInBytes) + private void assertSetGetValidThroughputMiB(int throughput, double rateInBytes) { ToolResult tool = invokeNodetool("setinterdcstreamthroughput", "-m", String.valueOf(throughput)); tool.assertOnCleanExit(); @@ -140,7 +139,7 @@ public class SetGetInterDCStreamThroughputTest extends CQLTester assertThat(StreamRateLimiter.getInterDCRateLimiterRateInBytes()).isEqualTo(rateInBytes, withPrecision(0.01)); } - private static void assertSetMbitGetMibValidThroughput(int throughput, double rateInBytes) + private void assertSetMbitGetMibValidThroughput(int throughput, double rateInBytes) { ToolResult tool = invokeNodetool("setinterdcstreamthroughput", String.valueOf(throughput)); tool.assertOnCleanExit(); @@ -151,7 +150,7 @@ public class SetGetInterDCStreamThroughputTest extends CQLTester assertThat(StreamRateLimiter.getInterDCRateLimiterRateInBytes()).isEqualTo(rateInBytes, withPrecision(0.01)); } - private static void assertSetInvalidThroughput(String throughput, String expectedErrorMessage) + private void assertSetInvalidThroughput(String throughput, String expectedErrorMessage) { ToolResult tool = throughput == null ? invokeNodetool("setinterdcstreamthroughput") : invokeNodetool("setinterdcstreamthroughput", throughput); @@ -159,7 +158,7 @@ public class SetGetInterDCStreamThroughputTest extends CQLTester assertThat(tool.getStdout()).contains(expectedErrorMessage); } - private static void assertSetInvalidThroughputMib(String throughput) + private void assertSetInvalidThroughputMib(String throughput) { ToolResult tool = invokeNodetool("setinterdcstreamthroughput", "-m", throughput); assertThat(tool.getExitCode()).isEqualTo(1); @@ -167,7 +166,7 @@ public class SetGetInterDCStreamThroughputTest extends CQLTester " less than 2147483647 in megabits/s"); } - private static void assertSetInvalidThroughputMbit(String throughput) + private void assertSetInvalidThroughputMbit(String throughput) { ToolResult tool = invokeNodetool("setinterdcstreamthroughput", throughput); assertThat(tool.getExitCode()).isEqualTo(1); @@ -176,7 +175,7 @@ public class SetGetInterDCStreamThroughputTest extends CQLTester "megabits per second"); } - private static void assertSetGetMoreFlagsIsInvalid() + private void assertSetGetMoreFlagsIsInvalid() { ToolResult tool = invokeNodetool("setinterdcstreamthroughput", "-m", "5", "-e", "6"); assertThat(tool.getExitCode()).isEqualTo(1); @@ -199,7 +198,7 @@ public class SetGetInterDCStreamThroughputTest extends CQLTester assertThat(tool.getStdout()).contains("You cannot use more than one flag with this command"); } - private static void assertGetThroughput(int expected) + private void assertGetThroughput(int expected) { ToolResult tool = invokeNodetool("getinterdcstreamthroughput"); tool.assertOnCleanExit(); @@ -210,7 +209,7 @@ public class SetGetInterDCStreamThroughputTest extends CQLTester assertThat(tool.getStdout()).contains("Current inter-datacenter stream throughput: unlimited"); } - private static void assertGetThroughputMiB(double expected) + private void assertGetThroughputMiB(double expected) { ToolResult tool = invokeNodetool("getinterdcstreamthroughput", "-m"); tool.assertOnCleanExit(); diff --git a/test/unit/org/apache/cassandra/tools/nodetool/SetGetStreamThroughputTest.java b/test/unit/org/apache/cassandra/tools/nodetool/SetGetStreamThroughputTest.java index 3844f03c13..c2f0f1d405 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/SetGetStreamThroughputTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/SetGetStreamThroughputTest.java @@ -22,11 +22,10 @@ import org.junit.BeforeClass; import org.junit.Test; import org.apache.cassandra.config.DataRateSpec; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import static org.apache.cassandra.streaming.StreamManager.StreamRateLimiter; import static org.apache.cassandra.tools.ToolRunner.ToolResult; -import static org.apache.cassandra.tools.ToolRunner.invokeNodetool; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.withPrecision; @@ -36,7 +35,7 @@ import static org.assertj.core.api.Assertions.withPrecision; * @see GetStreamThroughput * @see SetStreamThroughput */ -public class SetGetStreamThroughputTest extends CQLTester +public class SetGetStreamThroughputTest extends CQLNodetoolProtocolTester { private static final int MAX_INT_CONFIG_VALUE_IN_MBIT = Integer.MAX_VALUE - 1; private static final double BYTES_PER_MEGABIT = 125_000; @@ -111,7 +110,7 @@ public class SetGetStreamThroughputTest extends CQLTester assertSetGetMoreFlagsIsInvalid(); } - private static void assertSetGetValidThroughput(int throughput, double rateInBytes) + private void assertSetGetValidThroughput(int throughput, double rateInBytes) { ToolResult tool = invokeNodetool("setstreamthroughput", String.valueOf(throughput)); tool.assertOnCleanExit(); @@ -122,7 +121,7 @@ public class SetGetStreamThroughputTest extends CQLTester assertThat(StreamRateLimiter.getRateLimiterRateInBytes()).isEqualTo(rateInBytes, withPrecision(0.04)); } - private static void assertSetGetValidThroughputMiB(int throughput, double rateInBytes) + private void assertSetGetValidThroughputMiB(int throughput, double rateInBytes) { ToolResult tool = invokeNodetool("setstreamthroughput", "-m", String.valueOf(throughput)); tool.assertOnCleanExit(); @@ -133,7 +132,7 @@ public class SetGetStreamThroughputTest extends CQLTester assertThat(StreamRateLimiter.getRateLimiterRateInBytes()).isEqualTo(rateInBytes, withPrecision(0.01)); } - private static void assertDFlagNeeded() + private void assertDFlagNeeded() { ToolResult tool = invokeNodetool("setstreamthroughput", "-m", String.valueOf(1)); tool.assertOnCleanExit(); @@ -144,7 +143,7 @@ public class SetGetStreamThroughputTest extends CQLTester assertThat(tool.getStderr()).contains("Use the -d flag to quiet this error and get the exact throughput in megabits/s"); } - private static void assertSetMbitGetMibValidThroughput(int throughput, double rateInBytes) + private void assertSetMbitGetMibValidThroughput(int throughput, double rateInBytes) { ToolResult tool = invokeNodetool("setstreamthroughput", String.valueOf(throughput)); tool.assertOnCleanExit(); @@ -155,7 +154,7 @@ public class SetGetStreamThroughputTest extends CQLTester assertThat(StreamRateLimiter.getRateLimiterRateInBytes()).isEqualTo(rateInBytes, withPrecision(0.01)); } - private static void assertSetInvalidThroughput(String throughput, String expectedErrorMessage) + private void assertSetInvalidThroughput(String throughput, String expectedErrorMessage) { ToolResult tool = throughput == null ? invokeNodetool("setstreamthroughput") : invokeNodetool("setstreamthroughput", throughput); @@ -163,7 +162,7 @@ public class SetGetStreamThroughputTest extends CQLTester assertThat(tool.getStdout()).contains(expectedErrorMessage); } - private static void assertSetInvalidThroughputMib(String throughput) + private void assertSetInvalidThroughputMib(String throughput) { ToolResult tool = invokeNodetool("setstreamthroughput", "-m", throughput); assertThat(tool.getExitCode()).isEqualTo(1); @@ -171,7 +170,7 @@ public class SetGetStreamThroughputTest extends CQLTester "than 2147483647 in megabits/s"); } - private static void assertSetInvalidThroughputMbit(String throughput) + private void assertSetInvalidThroughputMbit(String throughput) { ToolResult tool = invokeNodetool("setstreamthroughput", throughput); assertThat(tool.getExitCode()).isEqualTo(1); @@ -179,7 +178,7 @@ public class SetGetStreamThroughputTest extends CQLTester "and inter_dc_stream_throughput_outbound should be between 0 and 2147483646 in megabits per second"); } - private static void assertSetGetMoreFlagsIsInvalid() + private void assertSetGetMoreFlagsIsInvalid() { ToolResult tool = invokeNodetool("setstreamthroughput", "-m", "5", "-e", "6"); assertThat(tool.getExitCode()).isEqualTo(1); @@ -202,7 +201,7 @@ public class SetGetStreamThroughputTest extends CQLTester assertThat(tool.getStdout()).contains("You cannot use more than one flag with this command"); } - private static void assertGetThroughput(int expected) + private void assertGetThroughput(int expected) { ToolResult tool = invokeNodetool("getstreamthroughput"); tool.assertOnCleanExit(); @@ -213,7 +212,7 @@ public class SetGetStreamThroughputTest extends CQLTester assertThat(tool.getStdout()).contains("Current stream throughput: unlimited"); } - private static void assertGetThroughputMiB(double expected) + private void assertGetThroughputMiB(double expected) { ToolResult tool = invokeNodetool("getstreamthroughput", "-m"); tool.assertOnCleanExit(); diff --git a/test/unit/org/apache/cassandra/tools/nodetool/ShowExecutionIdOutputTest.java b/test/unit/org/apache/cassandra/tools/nodetool/ShowExecutionIdOutputTest.java new file mode 100644 index 0000000000..26a53109fb --- /dev/null +++ b/test/unit/org/apache/cassandra/tools/nodetool/ShowExecutionIdOutputTest.java @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.tools.nodetool; + +import org.junit.Assume; +import org.junit.BeforeClass; +import org.junit.Test; + +import org.apache.cassandra.config.CassandraRelevantProperties; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; +import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.distributed.shared.WithProperties; +import org.apache.cassandra.tools.NodeTool; +import org.apache.cassandra.tools.ToolRunner; +import org.apache.cassandra.tools.nodetool.strategy.CommandExecutionStrategy; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ShowExecutionIdOutputTest extends CQLNodetoolProtocolTester +{ + @BeforeClass + public static void setup() throws Exception + { + requireNetwork(); + startJMXServer(); + } + + @Override + public ToolRunner.ToolResult invokeNodetool(String... args) + { + try (WithProperties with = new WithProperties() + .set(CassandraRelevantProperties.CASSANDRA_CLI_EXECUTION_PROTOCOL, strategy.name().toLowerCase()) + .set(CassandraRelevantProperties.CASSANDRA_CLI_EXECUTION_SHOW_EXECUTION_ID, true)) + { + return ToolRunner.invokeNodetoolInJvm(NodeTool::new, + strategy == CommandExecutionStrategy.Type.CQL ? + CQLTester::buildNodetoolCqlArgs : + CQLTester::buildNodetoolArgs, + args); + } + } + + @Test + public void testExecutionIdInOutputWhenShowExecutionIdEnabled() + { + Assume.assumeFalse("STATIC_MBEAN does not print execution ID", + strategy == CommandExecutionStrategy.Type.STATIC_MBEAN); + + ToolRunner.ToolResult tool = invokeNodetool("status"); + tool.assertOnCleanExit(); + assertThat(EXECUTION_ID_UUID_PATTERN.matcher(tool.getStdout()).find()) + .as("When CASSANDRA_CLI_EXECUTION_SHOW_EXECUTION_ID is true, output should contain 'Command execution id: '") + .isTrue(); + } +} \ No newline at end of file diff --git a/test/unit/org/apache/cassandra/tools/nodetool/SnapshotTest.java b/test/unit/org/apache/cassandra/tools/nodetool/SnapshotTest.java index 9742972d15..ffe669e845 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/SnapshotTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/SnapshotTest.java @@ -18,20 +18,22 @@ package org.apache.cassandra.tools.nodetool; +import java.util.List; + +import org.junit.After; import org.junit.BeforeClass; import org.junit.Test; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.io.util.File; import org.apache.cassandra.tools.ToolRunner; -import static org.apache.cassandra.tools.ToolRunner.invokeNodetool; import static org.assertj.core.api.Assertions.assertThat; /**o * Tests for the {@code nodetool snapshot} command */ -public class SnapshotTest extends CQLTester +public class SnapshotTest extends CQLNodetoolProtocolTester { @BeforeClass public static void setup() throws Exception @@ -40,6 +42,14 @@ public class SnapshotTest extends CQLTester startJMXServer(); } + @After + public void tearDown() + { + // Clear all named snaphsots created by tests. + for (String tag : List.of("custom_snapshot_name", "skip_flush", "ttl", "table", "kt_option")) + invokeNodetool("clearsnapshot", "-t", tag); + } + @Test public void testSnapshotAllKeyspaces() { diff --git a/test/unit/org/apache/cassandra/tools/nodetool/StatusTest.java b/test/unit/org/apache/cassandra/tools/nodetool/StatusTest.java index b62398e455..bae99c93e8 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/StatusTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/StatusTest.java @@ -23,6 +23,7 @@ import java.util.regex.Pattern; import org.junit.BeforeClass; import org.junit.Test; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.cql3.CQLTester; import org.apache.cassandra.locator.SimpleLocationProvider; import org.apache.cassandra.schema.SchemaConstants; @@ -32,7 +33,7 @@ import org.apache.cassandra.utils.FBUtilities; import static org.assertj.core.api.Assertions.assertThat; -public class StatusTest extends CQLTester +public class StatusTest extends CQLNodetoolProtocolTester { private static final Pattern PATTERN = Pattern.compile("\\R"); private static String localHostId; @@ -76,7 +77,7 @@ public class StatusTest extends CQLTester schemaChange("DROP KEYSPACE " + CQLTester.KEYSPACE); schemaChange("DROP KEYSPACE " + CQLTester.KEYSPACE_PER_TEST); - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("status"); + ToolRunner.ToolResult tool = invokeNodetool("status"); tool.assertOnCleanExit(); String[] lines = PATTERN.split(tool.getStdout()); @@ -135,7 +136,7 @@ public class StatusTest extends CQLTester private void validateStatusOutput(String hostForm, String... args) { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool(args); + ToolRunner.ToolResult tool = invokeNodetool(args); tool.assertOnCleanExit(); /* Datacenter: datacenter1 diff --git a/test/unit/org/apache/cassandra/tools/nodetool/TableHistogramsTest.java b/test/unit/org/apache/cassandra/tools/nodetool/TableHistogramsTest.java index 95f47332c5..3b4720769b 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/TableHistogramsTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/TableHistogramsTest.java @@ -22,7 +22,7 @@ import org.junit.BeforeClass; import org.junit.Test; import org.apache.cassandra.auth.AuthKeyspace; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.db.SystemKeyspace; import org.apache.cassandra.schema.SchemaConstants; import org.apache.cassandra.schema.SchemaKeyspace; @@ -31,14 +31,13 @@ import org.apache.cassandra.service.accord.AccordKeyspace; import org.apache.cassandra.tools.ToolRunner; import org.apache.cassandra.tracing.TraceKeyspace; -import static org.apache.cassandra.tools.ToolRunner.invokeNodetool; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertNotEquals; /** * @see TableHistograms */ -public class TableHistogramsTest extends CQLTester +public class TableHistogramsTest extends CQLNodetoolProtocolTester { private static final String INFO_ROW = "Percentile Read Latency Write Latency SSTables Partition Size Cell Count"; private final int ALL_TABLE_SIZE = SystemKeyspace.TABLE_NAMES.size() + @@ -91,16 +90,16 @@ public class TableHistogramsTest extends CQLTester //format 1 : ks1.tb1 ks2.tb2 ToolRunner.ToolResult tool = invokeNodetool("tablehistograms", "system.local", "system.paxos"); assertNotEquals(0, tool.getExitCode()); - assertThat(tool.getStdout()).contains("nodetool: tablehistograms requires

or format argument"); + assertThat(tool.getStdout()).contains("tablehistograms requires
or format argument"); // format 2 : ks1 tb1 ks2 tb2 tool = invokeNodetool("tablehistograms", "system", "local", "system", "paxos"); assertNotEquals(0, tool.getExitCode()); - assertThat(tool.getStdout()).contains("nodetool: Unmatched arguments from index 7: 'system', 'paxos'"); + assertThat(tool.getStdout()).contains("Unmatched arguments from index 7: 'system', 'paxos'"); // format 3 : ks1.tb1 ks2 tool = invokeNodetool("tablehistograms", "system.local", "system"); assertNotEquals(0, tool.getExitCode()); - assertThat(tool.getStdout()).contains("nodetool: tablehistograms requires
or format argument"); + assertThat(tool.getStdout()).contains("tablehistograms requires
or format argument"); } } diff --git a/test/unit/org/apache/cassandra/tools/nodetool/TableStatsTest.java b/test/unit/org/apache/cassandra/tools/nodetool/TableStatsTest.java index 7fe5e41a36..9ea7a141ee 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/TableStatsTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/TableStatsTest.java @@ -29,7 +29,7 @@ import org.junit.BeforeClass; import org.junit.Test; import org.yaml.snakeyaml.Yaml; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.tools.ToolRunner; import org.apache.cassandra.utils.JsonUtils; @@ -39,7 +39,7 @@ import static org.assertj.core.api.Assertions.assertThatCode; /** * @see TableStats */ -public class TableStatsTest extends CQLTester +public class TableStatsTest extends CQLNodetoolProtocolTester { @BeforeClass public static void setup() throws Exception @@ -51,12 +51,12 @@ public class TableStatsTest extends CQLTester @Test public void testTableStats() { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("tablestats"); + ToolRunner.ToolResult tool = invokeNodetool("tablestats"); tool.assertOnCleanExit(); assertThat(tool.getStdout()).contains("Keyspace: system_schema"); assertThat(StringUtils.countMatches(tool.getStdout(), "Table:")).isGreaterThan(1); - tool = ToolRunner.invokeNodetool("tablestats", "system_distributed"); + tool = invokeNodetool("tablestats", "system_distributed"); tool.assertOnCleanExit(); assertThat(tool.getStdout()).contains("Keyspace: system_distributed"); assertThat(tool.getStdout()).doesNotContain("Keyspace : system_schema"); @@ -66,7 +66,7 @@ public class TableStatsTest extends CQLTester @Test public void testTableIgnoreArg() { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("tablestats", "-i", "system_schema.aggregates"); + ToolRunner.ToolResult tool = invokeNodetool("tablestats", "-i", "system_schema.aggregates"); tool.assertOnCleanExit(); assertThat(tool.getStdout()).contains("Keyspace: system_schema"); assertThat(tool.getStdout()).doesNotContain("Table: system_schema.aggregates"); @@ -77,7 +77,7 @@ public class TableStatsTest extends CQLTester public void testHumanReadableArg() { Arrays.asList("-H", "--human-readable").forEach(arg -> { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("tablestats", arg); + ToolRunner.ToolResult tool = invokeNodetool("tablestats", arg); tool.assertOnCleanExit(); assertThat(tool.getStdout()).contains(" KiB"); }); @@ -89,13 +89,13 @@ public class TableStatsTest extends CQLTester Pattern regExp = Pattern.compile("((?m)Table: .*$)"); Arrays.asList("-s", "--sort").forEach(arg -> { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("tablestats", arg, "table_name"); + ToolRunner.ToolResult tool = invokeNodetool("tablestats", arg, "table_name"); Matcher m = regExp.matcher(tool.getStdout()); ArrayList orig = new ArrayList<>(); while (m.find()) orig.add(m.group(1)); - tool = ToolRunner.invokeNodetool("tablestats", arg, "sstable_count"); + tool = invokeNodetool("tablestats", arg, "sstable_count"); tool.assertOnCleanExit(); m = regExp.matcher(tool.getStdout()); ArrayList sorted = new ArrayList<>(); @@ -108,7 +108,7 @@ public class TableStatsTest extends CQLTester assertThat(sorted).isEqualTo(orig); }); - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("tablestats", "-s", "wrongSort"); + ToolRunner.ToolResult tool = invokeNodetool("tablestats", "-s", "wrongSort"); assertThat(tool.getStdout()).contains("argument for sort must be one of"); tool.assertCleanStdErr(); assertThat(tool.getExitCode()).isEqualTo(1); @@ -118,12 +118,12 @@ public class TableStatsTest extends CQLTester public void testTopArg() { Arrays.asList("-t", "--top").forEach(arg -> { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("tablestats", "-s", "table_name", arg, "1"); + ToolRunner.ToolResult tool = invokeNodetool("tablestats", "-s", "table_name", arg, "1"); tool.assertOnCleanExit(); assertThat(StringUtils.countMatches(tool.getStdout(), "Table:")).isEqualTo(1); }); - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("tablestats", "-s", "table_name", "-t", "-1"); + ToolRunner.ToolResult tool = invokeNodetool("tablestats", "-s", "table_name", "-t", "-1"); tool.assertCleanStdErr(); assertThat(tool.getExitCode()).isEqualTo(1); assertThat(tool.getStdout()).contains("argument for top must be a positive integer"); @@ -133,12 +133,12 @@ public class TableStatsTest extends CQLTester public void testSSTableLocationCheckArg() { Arrays.asList("-l", "--sstable-location-check").forEach(arg -> { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("tablestats", arg, "system.local"); + ToolRunner.ToolResult tool = invokeNodetool("tablestats", arg, "system.local"); tool.assertOnCleanExit(); assertThat(StringUtils.countMatches(tool.getStdout(), "SSTables in correct location: ")).isEqualTo(1); }); - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("tablestats", "system.local"); + ToolRunner.ToolResult tool = invokeNodetool("tablestats", "system.local"); tool.assertCleanStdErr(); assertThat(tool.getStdout()).doesNotContain("SSTables in correct location: "); } @@ -147,7 +147,7 @@ public class TableStatsTest extends CQLTester public void testFormatJson() { Arrays.asList("-F", "--format").forEach(arg -> { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("tablestats", arg, "json"); + ToolRunner.ToolResult tool = invokeNodetool("tablestats", arg, "json"); tool.assertOnCleanExit(); String json = tool.getStdout(); assertThatCode(() -> JsonUtils.JSON_OBJECT_MAPPER.readTree(json)).doesNotThrowAnyException(); @@ -160,7 +160,7 @@ public class TableStatsTest extends CQLTester public void testFormatYaml() { Arrays.asList("-F", "--format").forEach(arg -> { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("tablestats", arg, "yaml"); + ToolRunner.ToolResult tool = invokeNodetool("tablestats", arg, "yaml"); tool.assertOnCleanExit(); String yaml = tool.getStdout(); org.yaml.snakeyaml.LoaderOptions loaderOptions = new org.yaml.snakeyaml.LoaderOptions(); diff --git a/test/unit/org/apache/cassandra/tools/nodetool/TpStatsTest.java b/test/unit/org/apache/cassandra/tools/nodetool/TpStatsTest.java index ad0c1188c9..42cd9d488f 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/TpStatsTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/TpStatsTest.java @@ -22,6 +22,8 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -30,7 +32,7 @@ import org.junit.BeforeClass; import org.junit.Test; import org.yaml.snakeyaml.Yaml; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.net.Message; import org.apache.cassandra.net.MessagingService; import org.apache.cassandra.net.NoPayload; @@ -40,8 +42,9 @@ import org.apache.cassandra.utils.JsonUtils; import static org.apache.cassandra.net.Verb.ECHO_REQ; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertTrue; -public class TpStatsTest extends CQLTester +public class TpStatsTest extends CQLNodetoolProtocolTester { @BeforeClass @@ -54,7 +57,7 @@ public class TpStatsTest extends CQLTester @Test public void testTpStats() throws Throwable { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("tpstats"); + ToolRunner.ToolResult tool = invokeNodetool("tpstats"); tool.assertOnCleanExit(); String stdout = tool.getStdout(); assertThat(stdout).containsPattern("Pool Name \\s+ Active Pending Completed Blocked All time blocked"); @@ -69,7 +72,7 @@ public class TpStatsTest extends CQLTester execute("INSERT INTO %s (pk, c) VALUES (?, ?)", 1, 1); flush(); - tool = ToolRunner.invokeNodetool("tpstats"); + tool = invokeNodetool("tpstats"); tool.assertOnCleanExit(); stdout = tool.getStdout(); ArrayList newStats = getAllGroupMatches(nonZeroedThreadsRegExp, stdout); @@ -77,29 +80,35 @@ public class TpStatsTest extends CQLTester assertThat(origStats).isNotEqualTo(newStats); - // Does sending a message alter Gossip & ECHO stats? + // Does sending a message alter GossipStage stats? + // Use relative comparison since stats are cumulative across parameterized runs. String origGossip = getAllGroupMatches("((?m)GossipStage.*)", stdout).get(0); - assertThat(stdout).doesNotContainPattern("ECHO_REQ\\D.*[1-9].*"); - assertThat(stdout).doesNotContainPattern("ECHO_RSP\\D.*[1-9].*"); + CountDownLatch latch = new CountDownLatch(1); Message echoMessageOut = Message.out(ECHO_REQ, NoPayload.noPayload); - MessagingService.instance().send(echoMessageOut, FBUtilities.getBroadcastAddressAndPort()); + MessagingService.instance().sendWithCallback(echoMessageOut, FBUtilities.getBroadcastAddressAndPort(), + (msg) -> latch.countDown()); + assertTrue(latch.await(10, TimeUnit.SECONDS)); - tool = ToolRunner.invokeNodetool("tpstats"); + tool = invokeNodetool("tpstats"); tool.assertOnCleanExit(); stdout = tool.getStdout(); String newGossip = getAllGroupMatches("((?m)GossipStage.*)", stdout).get(0); + // We intentionally do not assert on ECHO_REQ changes here. The ECHO_REQ line + // in tpstats reports histogram percentiles (DecayingEstimatedHistogramReservoir) which + // use fixed bucket boundaries. Adding samples that fall into the same buckets as existing + // ones does not change the reported percentile values, making string comparison unreliable. + // The GossipStage completed count (a monotonic counter) is enough to verify that + // ECHO_REQ messages were processed, since ECHO_REQ is handled on the GOSSIP stage. assertThat(origGossip).isNotEqualTo(newGossip); - assertThat(stdout).containsPattern("ECHO_REQ\\D.*[1-9].*"); - assertThat(stdout).containsPattern("ECHO_RSP\\D.*[0-9].*"); } @Test public void testFormatArg() { Arrays.asList(Pair.of("-F", "json"), Pair.of("--format", "json")).forEach(arg -> { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("tpstats", arg.getLeft(), arg.getRight()); + ToolRunner.ToolResult tool = invokeNodetool("tpstats", arg.getLeft(), arg.getRight()); tool.assertOnCleanExit(); String json = tool.getStdout(); assertThat(isJSONString(json)).isTrue(); @@ -107,7 +116,7 @@ public class TpStatsTest extends CQLTester }); Arrays.asList( Pair.of("-F", "yaml"), Pair.of("--format", "yaml")).forEach(arg -> { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("tpstats", arg.getLeft(), arg.getRight()); + ToolRunner.ToolResult tool = invokeNodetool("tpstats", arg.getLeft(), arg.getRight()); tool.assertOnCleanExit(); String yaml = tool.getStdout(); assertThat(isYAMLString(yaml)).isTrue(); diff --git a/test/unit/org/apache/cassandra/tools/nodetool/TrainCompressionDictionaryTest.java b/test/unit/org/apache/cassandra/tools/nodetool/TrainCompressionDictionaryTest.java index cbe9bd41cb..9b1b6a09ce 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/TrainCompressionDictionaryTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/TrainCompressionDictionaryTest.java @@ -21,14 +21,13 @@ package org.apache.cassandra.tools.nodetool; import org.junit.BeforeClass; import org.junit.Test; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.io.compress.IDictionaryCompressor; import org.apache.cassandra.tools.ToolRunner; -import static org.apache.cassandra.tools.ToolRunner.invokeNodetool; import static org.assertj.core.api.Assertions.assertThat; -public class TrainCompressionDictionaryTest extends CQLTester +public class TrainCompressionDictionaryTest extends CQLNodetoolProtocolTester { @BeforeClass public static void setup() throws Throwable @@ -136,8 +135,7 @@ public class TrainCompressionDictionaryTest extends CQLTester "train", keyspace(), table); - assertThat(result.getStderr()) - .contains("Failed to trigger training: No SSTables available for training", "after flush"); + assertThat(result.getStdout()).contains("No SSTables available for training", "after flush"); } @Test @@ -147,9 +145,7 @@ public class TrainCompressionDictionaryTest extends CQLTester "train", "nonexistent_keyspace", "nonexistent_table"); - result.asserts() - .failure() - .errorContains("Failed to trigger training"); + assertThat(result.getStdout()).contains("Failed to trigger training"); } @Test @@ -159,10 +155,7 @@ public class TrainCompressionDictionaryTest extends CQLTester "train", keyspace(), "nonexistent_table"); - result.asserts() - .failure() - .errorContains("Failed to trigger training") - .errorContains("does not exist or does not support dictionary compression"); + assertThat(result.getStdout()).contains("Failed to trigger training"); } @Test @@ -175,9 +168,9 @@ public class TrainCompressionDictionaryTest extends CQLTester "train", keyspace(), table); - result.asserts() - .failure() - .errorContains("does not support dictionary compression"); + result.asserts().failure(); + assertThat(result.getStdout()) + .contains("is not enabled or SSTable compressor is not a dictionary compressor"); } @Test @@ -190,9 +183,9 @@ public class TrainCompressionDictionaryTest extends CQLTester "train", keyspace(), table); - result.asserts() - .failure() - .errorContains("does not support dictionary compression"); + result.asserts().failure(); + assertThat(result.getStdout()) + .contains("is not enabled or SSTable compressor is not a dictionary compressor"); } @@ -204,18 +197,17 @@ public class TrainCompressionDictionaryTest extends CQLTester // Training should fail on LZ4 table ToolRunner.ToolResult result = invokeNodetool("compressiondictionary", "train", keyspace(), table); - result.asserts() - .failure() - .errorContains("Failed to trigger training") - .errorContains("does not exist or does not support dictionary compression"); + result.asserts().failure(); + assertThat(result.getStdout()) + .contains("is not enabled or SSTable compressor is not a dictionary compressor"); // Alter table to use ZstdDictionaryCompressor execute("ALTER TABLE %s WITH compression = {'class': 'ZstdDictionaryCompressor'}"); // Training should fail with no sstables result = invokeNodetool("compressiondictionary", "train", keyspace(), table); - assertThat(result.getStderr()) - .contains("Failed to trigger training: No SSTables available for training", "after flush"); + assertThat(result.getStdout()) + .contains("No SSTables available for training", "after flush"); // Write sstables createSSTables(true); diff --git a/test/unit/org/apache/cassandra/tools/nodetool/UninitializedServerTest.java b/test/unit/org/apache/cassandra/tools/nodetool/UninitializedServerTest.java index 2ddd27059c..710eb672fe 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/UninitializedServerTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/UninitializedServerTest.java @@ -21,12 +21,12 @@ package org.apache.cassandra.tools.nodetool; import org.junit.BeforeClass; import org.junit.Test; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.tools.ToolRunner; import static org.assertj.core.api.Assertions.assertThat; -public class UninitializedServerTest extends CQLTester +public class UninitializedServerTest extends CQLNodetoolProtocolTester { @BeforeClass public static void setup() throws Exception @@ -39,7 +39,7 @@ public class UninitializedServerTest extends CQLTester { // CASSANDRA-11537 // fails, not finished initializing node because test never calls requireNetwork() - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("status"); + ToolRunner.ToolResult tool = invokeNodetool("status"); assertThat(tool.getException() instanceof IllegalArgumentException); assertThat(tool.getStderr().contains("Server is not initialized yet, cannot run nodetool.")); } diff --git a/test/unit/org/apache/cassandra/tools/nodetool/UpdateCIDRGroupTest.java b/test/unit/org/apache/cassandra/tools/nodetool/UpdateCIDRGroupTest.java index f32c4b1240..26a9ff0215 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/UpdateCIDRGroupTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/UpdateCIDRGroupTest.java @@ -30,6 +30,8 @@ import static org.apache.cassandra.auth.AuthKeyspace.CIDR_GROUPS; import static org.apache.cassandra.schema.SchemaConstants.AUTH_KEYSPACE_NAME; import static org.assertj.core.api.Assertions.assertThat; +// TODO: CASSANDRA-XXXXX Test needs to be migrated to CQLNodetoolProtocolTester +// once nodetool supports passing authentication credentials. public class UpdateCIDRGroupTest extends CQLTester { @BeforeClass diff --git a/test/unit/org/apache/cassandra/tools/nodetool/VerifyTest.java b/test/unit/org/apache/cassandra/tools/nodetool/VerifyTest.java index 6b40c0cdb5..082a197747 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/VerifyTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/VerifyTest.java @@ -28,7 +28,7 @@ import com.google.common.collect.Iterables; import org.junit.BeforeClass; import org.junit.Test; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.db.ColumnFamilyStore; import org.apache.cassandra.index.sai.StorageAttachedIndex; import org.apache.cassandra.index.sai.StorageAttachedIndexGroup; @@ -37,12 +37,11 @@ import org.apache.cassandra.io.sstable.format.SSTableReader; import org.apache.cassandra.io.util.File; import org.apache.cassandra.tools.ToolRunner; -import static org.apache.cassandra.tools.ToolRunner.invokeNodetool; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; -public class VerifyTest extends CQLTester +public class VerifyTest extends CQLNodetoolProtocolTester { @BeforeClass diff --git a/test/unit/org/apache/cassandra/tools/nodetool/VersionTest.java b/test/unit/org/apache/cassandra/tools/nodetool/VersionTest.java index 81fcb3ba9b..3e3127bd78 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/VersionTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/VersionTest.java @@ -20,12 +20,12 @@ package org.apache.cassandra.tools.nodetool; import org.junit.BeforeClass; import org.junit.Test; -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.cql3.CQLNodetoolProtocolTester; import org.apache.cassandra.tools.ToolRunner; import static org.assertj.core.api.Assertions.assertThat; -public class VersionTest extends CQLTester +public class VersionTest extends CQLNodetoolProtocolTester { @BeforeClass @@ -38,7 +38,7 @@ public class VersionTest extends CQLTester @Test public void testBasic() { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("version"); + ToolRunner.ToolResult tool = invokeNodetool("version"); tool.assertOnExitCode(); String stdout = tool.getStdout(); assertThat(stdout).containsPattern("ReleaseVersion:\\s+\\S+"); @@ -47,7 +47,7 @@ public class VersionTest extends CQLTester @Test public void testVOption() { - ToolRunner.ToolResult tool = ToolRunner.invokeNodetool("version", "-v"); + ToolRunner.ToolResult tool = invokeNodetool("version", "-v"); tool.assertOnExitCode(); String stdout = tool.getStdout(); assertThat(stdout).containsPattern("ReleaseVersion:\\s+\\S+"); diff --git a/test/unit/org/apache/cassandra/tools/nodetool/strategy/CqlCommandExecutionStrategyTest.java b/test/unit/org/apache/cassandra/tools/nodetool/strategy/CqlCommandExecutionStrategyTest.java new file mode 100644 index 0000000000..42c032c776 --- /dev/null +++ b/test/unit/org/apache/cassandra/tools/nodetool/strategy/CqlCommandExecutionStrategyTest.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.tools.nodetool.strategy; + +import org.junit.Test; + +import org.apache.cassandra.cql3.CQLStatement; +import org.apache.cassandra.cql3.QueryProcessor; +import org.apache.cassandra.cql3.statements.ExecuteCommandStatement; +import org.apache.cassandra.db.compaction.OperationType; +import org.apache.cassandra.management.CommandExecutionArgsSerde; +import org.apache.cassandra.management.api.CommandExecutionArgs; +import org.apache.cassandra.management.picocli.PicocliCommandArgsConverter; +import org.apache.cassandra.management.picocli.PicocliCommandMetadata; +import org.apache.cassandra.tools.NodeProbe; +import org.apache.cassandra.tools.nodetool.AbstractCommand; +import org.apache.cassandra.tools.nodetool.Stop; + +import picocli.CommandLine.Command; +import picocli.CommandLine.Parameters; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CqlCommandExecutionStrategyTest +{ + @Test + public void testStopDefaultOperationTypeSurvivesCqlRoundTrip() + { + String cql = CqlCommandExecutionStrategy.buildCqlCommandString("stop", PicocliCommandArgsConverter.fromCommand(new Stop())); + + assertThat(cql).contains("'UNKNOWN'") + .doesNotContain(OperationType.UNKNOWN.toString()); + + CommandExecutionArgs serverArgs = serverSideArgs(cql, new Stop()); + assertThat(serverArgs.parameters().values()).contains(OperationType.UNKNOWN); + } + + @Test + public void testAllOperationTypesSurviveCqlRoundTrip() + { + for (OperationType type : OperationType.values()) + { + EnumParamCommand client = new EnumParamCommand(); + client.compactionType = type; + String cql = CqlCommandExecutionStrategy.buildCqlCommandString("stop", PicocliCommandArgsConverter.fromCommand(client)); + + EnumParamCommand server = new EnumParamCommand(); + PicocliCommandArgsConverter.toCommand(serverSideArgs(cql, server), server); + assertThat(server.compactionType).as("round trip of %s via: %s", type, cql).isEqualTo(type); + } + } + + /** + * Parse the statement and convert its raw args the same way {@link ExecuteCommandStatement} does. + */ + private static CommandExecutionArgs serverSideArgs(String cql, AbstractCommand command) + { + CQLStatement.Raw stmt = QueryProcessor.parseStatement(cql); + assertThat(stmt).isInstanceOf(ExecuteCommandStatement.Raw.class); + ExecuteCommandStatement.Raw raw = (ExecuteCommandStatement.Raw) stmt; + return CommandExecutionArgsSerde.fromMap(raw.args(), PicocliCommandMetadata.from(command)); + } + + @Command(name = "stop") + static class EnumParamCommand extends AbstractCommand + { + @Parameters(paramLabel = "compaction_type", arity = "0..1") + OperationType compactionType = OperationType.UNKNOWN; + + @Override + protected void execute(NodeProbe probe) + { + } + } +} diff --git a/test/unit/org/apache/cassandra/transport/ErrorMessageTest.java b/test/unit/org/apache/cassandra/transport/ErrorMessageTest.java index 7c708e7f8e..0a42d67de2 100644 --- a/test/unit/org/apache/cassandra/transport/ErrorMessageTest.java +++ b/test/unit/org/apache/cassandra/transport/ErrorMessageTest.java @@ -21,6 +21,7 @@ package org.apache.cassandra.transport; import java.net.UnknownHostException; import java.util.HashMap; import java.util.Map; +import java.util.UUID; import org.junit.BeforeClass; import org.junit.Test; @@ -29,6 +30,7 @@ import org.apache.cassandra.db.ConsistencyLevel; import org.apache.cassandra.db.WriteType; import org.apache.cassandra.exceptions.CasWriteTimeoutException; import org.apache.cassandra.exceptions.CasWriteUnknownResultException; +import org.apache.cassandra.exceptions.CommandRequestExecutionException; import org.apache.cassandra.exceptions.ReadFailureException; import org.apache.cassandra.exceptions.RequestFailureReason; import org.apache.cassandra.exceptions.WriteFailureException; @@ -39,6 +41,7 @@ import org.apache.cassandra.transport.messages.ErrorMessage; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; public class ErrorMessageTest extends EncodeAndDecodeTestBase @@ -191,6 +194,72 @@ public class ErrorMessageTest extends EncodeAndDecodeTestBase assertEquals(failureReasonMap1, wfe.failureReasonByEndpoint); } + @Test + public void testV5CommandFailedSerDeser() + { + UUID executionId = UUID.randomUUID(); + String errorMessage = "Command execution failed: test command"; + Throwable cause = new RuntimeException("Underlying cause"); + CommandRequestExecutionException ex = new CommandRequestExecutionException(executionId, errorMessage, cause); + + ErrorMessage deserialized = encodeThenDecode(ErrorMessage.fromExceptionNoStreamId(ex), ProtocolVersion.V5); + assertTrue(deserialized.error instanceof CommandRequestExecutionException); + CommandRequestExecutionException deserializedEx = (CommandRequestExecutionException) deserialized.error; + + assertEquals(executionId, deserializedEx.getCommandExecutionId()); + assertEquals(errorMessage, deserializedEx.getMessage()); + assertNull(deserializedEx.getCause()); + } + + @Test + public void testV4CommandFailedSerDeser() + { + UUID executionId = UUID.randomUUID(); + String errorMessage = "Command execution failed: test command"; + Throwable cause = new RuntimeException("Underlying cause"); + CommandRequestExecutionException ex = new CommandRequestExecutionException(executionId, errorMessage, cause); + + ErrorMessage deserialized = encodeThenDecode(ErrorMessage.fromExceptionNoStreamId(ex), ProtocolVersion.V4); + assertTrue(deserialized.error instanceof ServerError); + ServerError deserializedEx = (ServerError) deserialized.error; + + // The executionId should be included in the message for backward compatibility. + assertTrue(deserializedEx.getMessage().contains(executionId.toString())); + assertTrue(deserializedEx.getMessage().contains(errorMessage)); + } + + @Test + public void testV5CommandFailedWithoutCause() + { + UUID executionId = UUID.randomUUID(); + String errorMessage = "Command execution failed: test command"; + CommandRequestExecutionException ex = new CommandRequestExecutionException(executionId, errorMessage); + + ErrorMessage deserialized = encodeThenDecode(ErrorMessage.fromExceptionNoStreamId(ex), ProtocolVersion.V5); + assertTrue(deserialized.error instanceof CommandRequestExecutionException); + CommandRequestExecutionException deserializedEx = (CommandRequestExecutionException) deserialized.error; + + assertEquals(executionId, deserializedEx.getCommandExecutionId()); + assertEquals(errorMessage, deserializedEx.getMessage()); + assertNull(deserializedEx.getCause()); + } + + @Test + public void testV3CommandFailedSerDeser() + { + UUID executionId = UUID.randomUUID(); + String errorMessage = "Command execution failed: test command"; + CommandRequestExecutionException ex = new CommandRequestExecutionException(executionId, errorMessage); + + ErrorMessage deserialized = encodeThenDecode(ErrorMessage.fromExceptionNoStreamId(ex), ProtocolVersion.V3); + assertTrue(deserialized.error instanceof ServerError); + ServerError deserializedEx = (ServerError) deserialized.error; + + // The executionId should be included in the message for backward compatibility. + assertTrue(deserializedEx.getMessage().contains(executionId.toString())); + assertTrue(deserializedEx.getMessage().contains(errorMessage)); + } + protected Message.Codec getCodec() { return ErrorMessage.codec; diff --git a/test/unit/org/apache/cassandra/transport/MessageDispatcherTest.java b/test/unit/org/apache/cassandra/transport/MessageDispatcherTest.java index 5f73c5d544..a3df84158c 100644 --- a/test/unit/org/apache/cassandra/transport/MessageDispatcherTest.java +++ b/test/unit/org/apache/cassandra/transport/MessageDispatcherTest.java @@ -172,7 +172,7 @@ public class MessageDispatcherTest { public AuthTestDispatcher() { - super(false); + super(false, false); } @Override diff --git a/test/unit/org/apache/cassandra/transport/MessageManagementDispatcherTest.java b/test/unit/org/apache/cassandra/transport/MessageManagementDispatcherTest.java new file mode 100644 index 0000000000..5310f8caaa --- /dev/null +++ b/test/unit/org/apache/cassandra/transport/MessageManagementDispatcherTest.java @@ -0,0 +1,621 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.transport; + +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import com.google.common.util.concurrent.Uninterruptibles; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.Mockito; + +import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.cql3.QueryOptions; +import org.apache.cassandra.metrics.ClientMetrics; +import org.apache.cassandra.service.QueryState; +import org.apache.cassandra.transport.messages.QueryMessage; +import org.apache.cassandra.utils.Clock; + +import io.netty.channel.Channel; + +import static java.lang.String.format; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class MessageManagementDispatcherTest +{ + private static ManagementTestDispatcher dispatch; + private static int maxManagementThreadsBeforeTests; + + @BeforeClass + public static void init() throws Exception + { + DatabaseDescriptor.daemonInitialization(); + ClientMetrics.instance.init(null); + maxManagementThreadsBeforeTests = DatabaseDescriptor.getNativeTransportManagementMaxThreads(); + dispatch = new ManagementTestDispatcher(); + } + + @AfterClass + public static void restoreManagementSize() + { + DatabaseDescriptor.setNativeTransportManagementMaxThreads(maxManagementThreadsBeforeTests); + } + + @Test + public void testManagementExecutorRouting() throws Exception + { + long startRequests = completedRequests(); + long startAuth = completedAuth(); + + DatabaseDescriptor.setNativeTransportManagementMaxThreads(1); + + long managementTasks = tryRequest(this::completedManagement, queryMessage("INVOKE COMMAND status;")); + assertEquals("Management request should be routed to management executor", + 1, managementTasks); + assertEquals("No auth requests should be processed", startAuth, completedAuth()); + assertEquals("Regular requests should not increase", startRequests, completedRequests()); + } + + @Test + public void testManagementExecutorIsolation() throws Exception + { + long startManagement = completedManagement(); + + DatabaseDescriptor.setNativeTransportManagementMaxThreads(1); + + // Test that regular (non-management) requests don't use management executor + for (Message.Type type : Message.Type.values()) + { + if (type.direction != Message.Direction.REQUEST) + continue; + + long requests = tryRequest(() -> Message.Type.CREDENTIALS == type || Message.Type.AUTH_RESPONSE == type + ? completedAuth() + : completedRequests(), + createRegularRequest(type)); + + assertEquals("No management tasks should be processed", startManagement, completedManagement()); + assertEquals(format("Request should be processed for type: %s", type), 1, requests); + } + } + + @Test + public void testManagementConnectionAllMessageTypes() throws Exception + { + DatabaseDescriptor.setNativeTransportManagementMaxThreads(1); + + for (Message.Type type : Message.Type.values()) + { + if (type.direction != Message.Direction.REQUEST) + continue; + + Message.Request request = createManagementRequest(type); + long managementTasks = tryRequest(() -> Message.Type.CREDENTIALS == type || Message.Type.AUTH_RESPONSE == type + ? completedAuth() + : completedManagement(), + request); + assertEquals(format("Management %s request should route to management executor", type), + 1, managementTasks); + } + } + + @Test + public void testNonServerConnectionNotRoutedToManagement() throws Exception + { + DatabaseDescriptor.setNativeTransportManagementMaxThreads(1); + + // Create a connection that is not a ServerConnection + Connection nonServerConnection = connectionMock(); + + Message.Request request = new Message.Request(Message.Type.QUERY) + { + @Override + public Connection connection() + { + return nonServerConnection; + } + + @Override + public Response execute(QueryState queryState, Dispatcher.RequestTime requestTime, boolean traceRequest) + { + return null; + } + }; + + long startManagement = completedManagement(); + long regularTasks = tryRequest(this::completedRequests, request); + + assertEquals("Non-server connection should use regular executor", 1, regularTasks); + assertEquals("Non-server connection should not use management executor", + startManagement, completedManagement()); + } + + @Test + public void testCommandStatementAllowed() + { + assertTrue("INVOKE COMMAND statements should be allowed", + Dispatcher.isManagementRequestAllowed(queryMessage("INVOKE COMMAND status;"))); + } + + @Test + public void testCommandWithParamsAllowed() + { + assertTrue("INVOKE COMMAND with params should be allowed", + Dispatcher.isManagementRequestAllowed( + queryMessage("INVOKE COMMAND forcecompact WITH \"keyspace\" = 'ks' AND \"table\" = 'tbl';"))); + } + + @Test + public void testSelectSystemLocalAllowed() + { + assertTrue("SELECT from system.local should be allowed", + Dispatcher.isManagementRequestAllowed( + queryMessage("SELECT * FROM system.local WHERE key = 'local';"))); + } + + @Test + public void testSelectSystemPeersAllowed() + { + assertTrue("SELECT from system.peers should be allowed", + Dispatcher.isManagementRequestAllowed( + queryMessage("SELECT * FROM system.peers;"))); + } + + @Test + public void testSelectSystemSchemaKeyspacesAllowed() + { + assertTrue("SELECT from system_schema.keyspaces should be allowed", + Dispatcher.isManagementRequestAllowed( + queryMessage("SELECT * FROM system_schema.keyspaces;"))); + } + + @Test + public void testSelectSystemSchemaTablesAllowed() + { + assertTrue("SELECT from system_schema.tables should be allowed", + Dispatcher.isManagementRequestAllowed( + queryMessage("SELECT * FROM system_schema.tables;"))); + } + + @Test + public void testSelectSystemSchemaColumnsAllowed() + { + assertTrue("SELECT from system_schema.columns should be allowed", + Dispatcher.isManagementRequestAllowed( + queryMessage("SELECT * FROM system_schema.columns;"))); + } + + @Test + public void testSelectUserKeyspaceRejected() + { + assertFalse("SELECT from user keyspace should be rejected", + Dispatcher.isManagementRequestAllowed( + queryMessage("SELECT * FROM my_keyspace.my_table;"))); + } + + @Test + public void testSelectSystemAuthRejected() + { + assertFalse("SELECT from system_auth.roles should be rejected", + Dispatcher.isManagementRequestAllowed( + queryMessage("SELECT * FROM system_auth.roles;"))); + assertFalse("SELECT from system_auth.role_permissions should be rejected", + Dispatcher.isManagementRequestAllowed( + queryMessage("SELECT * FROM system_auth.role_permissions;"))); + } + + @Test + public void testUseSystemAuthRejected() + { + assertFalse("USE system_auth should be rejected", + Dispatcher.isManagementRequestAllowed(queryMessage("USE system_auth;"))); + } + + @Test + public void testSelectSystemDistributedAllowed() + { + assertTrue("SELECT from system_distributed should be allowed", + Dispatcher.isManagementRequestAllowed( + queryMessage("SELECT * FROM system_distributed.repair_history;"))); + } + + @Test + public void testInsertRejected() + { + assertFalse("INSERT should be rejected", + Dispatcher.isManagementRequestAllowed(queryMessage( + "INSERT INTO system.local (key) VALUES ('test');"))); + } + + @Test + public void testCreateTableRejected() + { + assertFalse("DDL should be rejected", + Dispatcher.isManagementRequestAllowed(queryMessage( + "CREATE TABLE system.foo (k text PRIMARY KEY);"))); + } + + @Test + public void testUnqualifiedSelectRejected() + { + assertFalse("Unqualified SELECT should be rejected", + Dispatcher.isManagementRequestAllowed(queryMessage("SELECT * FROM local;"))); + } + + @Test + public void testInvalidSyntaxRejected() + { + assertFalse("Invalid syntax should be rejected", + Dispatcher.isManagementRequestAllowed(queryMessage("NOT VALID CQL AT ALL;"))); + } + + @Test + public void testProtocolMessagesAllowed() + { + Message.Request startup = createManagementRequest(Message.Type.STARTUP); + assertTrue("STARTUP should be allowed", Dispatcher.isManagementRequestAllowed(startup)); + + Message.Request options = createManagementRequest(Message.Type.OPTIONS); + assertTrue("OPTIONS should be allowed", Dispatcher.isManagementRequestAllowed(options)); + + Message.Request register = createManagementRequest(Message.Type.REGISTER); + assertTrue("REGISTER should be allowed", Dispatcher.isManagementRequestAllowed(register)); + } + + @Test + public void testPrepareRejected() + { + Message.Request prepare = createManagementRequest(Message.Type.PREPARE); + assertFalse("PREPARE should be rejected", Dispatcher.isManagementRequestAllowed(prepare)); + } + + @Test + public void testBatchRejected() + { + Message.Request batch = createManagementRequest(Message.Type.BATCH); + assertFalse("BATCH should be rejected", Dispatcher.isManagementRequestAllowed(batch)); + } + + @Test + public void testExecuteRejected() + { + Message.Request execute = createManagementRequest(Message.Type.EXECUTE); + assertFalse("EXECUTE should be rejected", Dispatcher.isManagementRequestAllowed(execute)); + } + + @Test + public void testUseSystemKeyspaceAllowed() + { + assertTrue("USE system should be allowed", + Dispatcher.isManagementRequestAllowed(queryMessage("USE system;"))); + } + + @Test + public void testUseSystemSchemaAllowed() + { + assertTrue("USE system_schema should be allowed", + Dispatcher.isManagementRequestAllowed(queryMessage("USE system_schema;"))); + } + + @Test + public void testUseVirtualKeyspaceAllowed() + { + assertTrue("USE system_views should be allowed", + Dispatcher.isManagementRequestAllowed(queryMessage("USE system_views;"))); + } + + @Test + public void testUseUserKeyspaceRejected() + { + assertFalse("USE user keyspace should be rejected", + Dispatcher.isManagementRequestAllowed(queryMessage("USE my_keyspace;"))); + } + + @Test + public void testIsDoneTracksManagementExecutor() throws Exception + { + Dispatcher managementDispatcher = new ManagementTestDispatcher(true); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + Dispatcher.managementExecutor.submit(() -> { + started.countDown(); + Uninterruptibles.awaitUninterruptibly(release); + }); + try + { + assertTrue(started.await(10, TimeUnit.SECONDS)); + assertFalse("An in-flight management task should block the management drain", + managementDispatcher.isDone()); + awaitTrue("The regular dispatcher drain should ignore the management executor", + dispatch::isDone); + } + finally + { + release.countDown(); + } + awaitTrue("The management drain should complete once its task finishes", + managementDispatcher::isDone); + } + + @Test + public void testIsDoneIgnoresRequestExecutorBacklog() throws Exception + { + Dispatcher managementDispatcher = new ManagementTestDispatcher(true); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + Dispatcher.requestExecutor.submit(() -> { + started.countDown(); + Uninterruptibles.awaitUninterruptibly(release); + }); + try + { + assertTrue(started.await(10, TimeUnit.SECONDS)); + assertFalse("An in-flight regular task should block the regular drain", + dispatch.isDone()); + assertTrue("The management drain should ignore the regular request executor", + managementDispatcher.isDone()); + } + finally + { + release.countDown(); + } + awaitTrue("The regular drain should complete once its task finishes", + dispatch::isDone); + } + + /** + * A management command may itself initiate the server stop (e.g. INVOKE COMMAND stopdaemon), in which + * case the drain-wait runs on the very thread executing the command and must not wait for its own task. + */ + @Test + public void testIsDoneExcludesStopInitiatingManagementTask() throws Exception + { + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicBoolean isDoneInsideTask = new AtomicBoolean(); + + Dispatcher managementDispatcher = new ManagementTestDispatcher(true) + { + @Override +

void processRequest(Channel channel, + Message.Request request, + FlushItemConverter

forFlusher, + P param, + ClientResourceLimits.Overload backpressure, + RequestTime requestTime) + { + isDoneInsideTask.set(isDone()); + entered.countDown(); + Uninterruptibles.awaitUninterruptibly(release); + } + }; + + Message.Request request = createManagementRequest(Message.Type.STARTUP); + managementDispatcher.dispatch(request.connection().channel(), request, (param, channel, req, response) -> null, + null, ClientResourceLimits.Overload.NONE); + try + { + assertTrue(entered.await(10, TimeUnit.SECONDS)); + assertTrue("The drain must not wait for the management task that initiated it", + isDoneInsideTask.get()); + assertFalse("Other threads should still see the in-flight management task", + managementDispatcher.isDone()); + } + finally + { + release.countDown(); + } + awaitTrue("The management drain should complete once its task finishes", + managementDispatcher::isDone); + } + + /** + * Queue-time backpressure must be keyed to the executor serving the connection: a backlog on the + * management pool should trigger backpressure for management connections only, and must stay + * invisible to regular client connections (and vice versa). + */ + @Test + public void testHasQueueCapacityTracksOwnExecutor() throws Exception + { + Dispatcher managementDispatcher = new ManagementTestDispatcher(true); + double thresholdBefore = DatabaseDescriptor.getRawConfig().native_transport_queue_max_item_age_threshold; + DatabaseDescriptor.getRawConfig().native_transport_queue_max_item_age_threshold = 1e-9; + + int poolSize = Dispatcher.managementExecutor.getMaximumPoolSize(); + CountDownLatch saturated = new CountDownLatch(poolSize); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch processed = new CountDownLatch(1); + try + { + assertTrue("Both drains should report capacity while idle", managementDispatcher.hasQueueCapacity()); + assertTrue(dispatch.hasQueueCapacity()); + + for (int i = 0; i < poolSize; i++) + { + Dispatcher.managementExecutor.submit(() -> { + saturated.countDown(); + Uninterruptibles.awaitUninterruptibly(release); + }); + } + assertTrue(saturated.await(10, TimeUnit.SECONDS)); + + Dispatcher queuedProcessor = new ManagementTestDispatcher(true) + { + @Override +

void processRequest(Channel channel, + Message.Request request, + FlushItemConverter

forFlusher, + P param, + ClientResourceLimits.Overload backpressure, + RequestTime requestTime) + { + processed.countDown(); + } + }; + Message.Request request = createManagementRequest(Message.Type.STARTUP); + queuedProcessor.dispatch(request.connection().channel(), request, (param, channel, req, response) -> null, + null, ClientResourceLimits.Overload.NONE); + + awaitTrue("A queued management request should trigger management backpressure", + () -> !managementDispatcher.hasQueueCapacity()); + assertTrue("A management backlog should be invisible to the regular drain", + dispatch.hasQueueCapacity()); + } + finally + { + release.countDown(); + DatabaseDescriptor.getRawConfig().native_transport_queue_max_item_age_threshold = thresholdBefore; + } + assertTrue(processed.await(10, TimeUnit.SECONDS)); + awaitTrue("The management pool should drain once its tasks finish", + managementDispatcher::isDone); + } + + private static void awaitTrue(String message, Callable condition) throws Exception + { + long timeout = Clock.Global.currentTimeMillis(); + while (!condition.call() && Clock.Global.currentTimeMillis() - timeout < 10_000) + Uninterruptibles.sleepUninterruptibly(10, TimeUnit.MILLISECONDS); + assertTrue(message, condition.call()); + } + + private long completedRequests() + { + return Dispatcher.requestExecutor.getCompletedTaskCount(); + } + + private long completedAuth() + { + return Dispatcher.authExecutor.getCompletedTaskCount(); + } + + private long completedManagement() + { + return Dispatcher.managementExecutor.getCompletedTaskCount(); + } + + private long tryRequest(Callable check, Message.Request request) throws Exception + { + long start = check.call(); + dispatch.dispatch(request.connection().channel(), request, (param, channel, req, response) -> null, + null, ClientResourceLimits.Overload.NONE); + + long timeout = Clock.Global.currentTimeMillis(); + while (start == check.call() && Clock.Global.currentTimeMillis() - timeout < 1000) + Uninterruptibles.sleepUninterruptibly(10, TimeUnit.MILLISECONDS); + return check.call() - start; + } + + private static ServerConnection managementConnectionMock() + { + Connection.Tracker tracker = Mockito.mock(Connection.Tracker.class); + Mockito.when(tracker.isRunning()).thenAnswer(invocation -> true); + + Channel channel = Mockito.mock(Channel.class); + ServerConnection connection = Mockito.mock(ServerConnection.class); + Mockito.when(connection.getTracker()).thenAnswer(invocation -> tracker); + Mockito.when(connection.isManagementConnection()).thenReturn(true); + Mockito.when(connection.getVersion()).thenReturn(ProtocolVersion.CURRENT); + Mockito.when(connection.channel()).thenReturn(channel); + + return connection; + } + + private static Connection connectionMock() + { + Connection.Tracker tracker = Mockito.mock(Connection.Tracker.class); + Mockito.when(tracker.isRunning()).thenAnswer(invocation -> true); + Connection c = Mockito.mock(Connection.class); + Mockito.when(c.getTracker()).thenAnswer(invocation -> tracker); + return c; + } + + private static Message.Request createManagementRequest(Message.Type type) + { + return createRequest(type, managementConnectionMock()); + } + + private static Message.Request createRegularRequest(Message.Type type) + { + return createRequest(type, connectionMock()); + } + + private static Message.Request createRequest(Message.Type type, Connection conn) + { + return new Message.Request(type) + { + @Override + public Connection connection() + { + return conn; + } + + @Override + public Response execute(QueryState queryState, Dispatcher.RequestTime requestTime, boolean traceRequest) + { + return null; + } + }; + } + + private static QueryMessage queryMessage(String cql) + { + QueryMessage msg = new QueryMessage(cql, QueryOptions.DEFAULT) + { + @Override + public Connection connection() + { + return managementConnectionMock(); + } + }; + msg.setSource(new Envelope(Envelope.Header.dummy(1, msg.type), null)); + return msg; + } + + public static class ManagementTestDispatcher extends Dispatcher + { + public ManagementTestDispatcher() + { + this(false); + } + + public ManagementTestDispatcher(boolean isManagementDispatcher) + { + super(false, isManagementDispatcher); + } + + @Override +

void processRequest(Channel channel, + Message.Request request, + FlushItemConverter

forFlusher, + P param, + ClientResourceLimits.Overload backpressure, + RequestTime requestTime) + { + // noop - just for testing routing + } + } +} \ No newline at end of file diff --git a/test/unit/org/apache/cassandra/transport/QueueBackpressureTest.java b/test/unit/org/apache/cassandra/transport/QueueBackpressureTest.java index b76739f1a5..b3fca67bfd 100644 --- a/test/unit/org/apache/cassandra/transport/QueueBackpressureTest.java +++ b/test/unit/org/apache/cassandra/transport/QueueBackpressureTest.java @@ -57,4 +57,25 @@ public class QueueBackpressureTest backpressure = backpressure.mark(backpressure.appliedAt() + TimeUnit.MILLISECONDS.toNanos(1001)); Assert.assertEquals(backpressure.minDelayNanos(), backpressure.delay(TimeUnit.NANOSECONDS)); } + + /** + * The client and management transports use separate {@link QueueBackpressure} instances, so an overload + * incident on one must not inflate the delay applied by the other. + */ + @Test + public void testIncidentStateIsPerInstance() + { + long minDelayNanos = TimeUnit.MILLISECONDS.toNanos(10); + long maxDelayNanos = TimeUnit.MILLISECONDS.toNanos(100); + QueueBackpressure client = QueueBackpressure.newDefault(() -> minDelayNanos, () -> maxDelayNanos); + QueueBackpressure management = QueueBackpressure.newDefault(() -> minDelayNanos, () -> maxDelayNanos); + + long clientDelay = 0; + for (int i = 0; i < 50; i++) + clientDelay = client.markAndGetDelay(TimeUnit.NANOSECONDS); + Assert.assertTrue("Client incident should have ramped past the minimum delay", clientDelay > minDelayNanos); + + Assert.assertEquals("A first management overload must start its own incident at the minimum delay", + minDelayNanos, management.markAndGetDelay(TimeUnit.NANOSECONDS)); + } } diff --git a/test/unit/org/apache/cassandra/transport/SimpleClientTimeoutTest.java b/test/unit/org/apache/cassandra/transport/SimpleClientTimeoutTest.java new file mode 100644 index 0000000000..560c883ad3 --- /dev/null +++ b/test/unit/org/apache/cassandra/transport/SimpleClientTimeoutTest.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.transport; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.InetAddress; +import java.net.ServerSocket; + +import org.junit.BeforeClass; +import org.junit.Test; + +import org.apache.cassandra.config.DatabaseDescriptor; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class SimpleClientTimeoutTest +{ + @BeforeClass + public static void setup() + { + DatabaseDescriptor.toolInitialization(); + } + + @Test(timeout = 30000) + public void testConnectionClosedDetectedWithIndefiniteTimeout() throws Exception + { + try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) + { + Thread closer = new Thread(() -> { + try + { + server.accept().close(); + } + catch (IOException e) + { + throw new UncheckedIOException(e); + } + }); + closer.start(); + + try (SimpleClient client = SimpleClient.builder(server.getInetAddress().getHostAddress(), server.getLocalPort()) + .requestTimeoutSeconds(0) + .build()) + { + assertThatThrownBy(() -> client.connect(false)) + .isInstanceOf(SimpleClient.ConnectionClosedException.class); + } + closer.join(); + } + } + + @Test + public void testConfigurableRequestTimeout() throws Exception + { + try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) + { + try (SimpleClient client = SimpleClient.builder(server.getInetAddress().getHostAddress(), server.getLocalPort()) + .requestTimeoutSeconds(1) + .build()) + { + assertThatThrownBy(() -> client.connect(false)) + .isInstanceOf(SimpleClient.TimeoutException.class) + .hasMessageContaining("1 seconds"); + } + } + } +} diff --git a/tools/stress/src/org/apache/cassandra/stress/util/JmxCollector.java b/tools/stress/src/org/apache/cassandra/stress/util/JmxCollector.java index 24cb4c788b..bb440550a7 100644 --- a/tools/stress/src/org/apache/cassandra/stress/util/JmxCollector.java +++ b/tools/stress/src/org/apache/cassandra/stress/util/JmxCollector.java @@ -18,7 +18,6 @@ */ package org.apache.cassandra.stress.util; -import java.io.IOException; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -30,6 +29,7 @@ import java.util.concurrent.Future; import org.apache.cassandra.concurrent.NamedThreadFactory; import org.apache.cassandra.stress.settings.SettingsJMX; import org.apache.cassandra.tools.NodeProbe; +import org.apache.cassandra.tools.RemoteJmxMBeanAccessor; public class JmxCollector implements Callable { @@ -91,17 +91,10 @@ public class JmxCollector implements Callable private static NodeProbe connect(String host, int port, SettingsJMX jmx) { - try - { - if (jmx.user != null && jmx.password != null) - return new NodeProbe(host, port, jmx.user, jmx.password); - else - return new NodeProbe(host, port); - } - catch (IOException e) - { - throw new RuntimeException(e); - } + if (jmx.user != null && jmx.password != null) + return new NodeProbe(new RemoteJmxMBeanAccessor(host, port, jmx.user, jmx.password)); + else + return new NodeProbe(new RemoteJmxMBeanAccessor(host, port)); } public GcStats call() throws Exception