diff --git a/.build/build-rat.xml b/.build/build-rat.xml index 27e8f63ae43d..5ce5e0dbd72c 100644 --- a/.build/build-rat.xml +++ b/.build/build-rat.xml @@ -55,6 +55,7 @@ + diff --git a/.build/cassandra-deps-template.xml b/.build/cassandra-deps-template.xml index 437c0c31b5a9..32c7e4a2c0af 100644 --- a/.build/cassandra-deps-template.xml +++ b/.build/cassandra-deps-template.xml @@ -120,6 +120,14 @@ io.airlift airline + + info.picocli + picocli + + + io.github.java-diff-utils + java-diff-utils + io.dropwizard.metrics metrics-core diff --git a/.build/parent-pom-template.xml b/.build/parent-pom-template.xml index 0c0e5d4ccc9e..23caee6fd61c 100644 --- a/.build/parent-pom-template.xml +++ b/.build/parent-pom-template.xml @@ -714,6 +714,16 @@ jbcrypt 0.4 + + info.picocli + picocli + 4.7.5 + + + io.github.java-diff-utils + java-diff-utils + 4.12 + io.airlift airline diff --git a/bin/nodetool b/bin/nodetool index f78b02e34418..b1fb4f5a091d 100755 --- a/bin/nodetool +++ b/bin/nodetool @@ -98,13 +98,19 @@ if [ "x$MAX_HEAP_SIZE" = "x" ]; then MAX_HEAP_SIZE="128m" fi +if [ "x$NODETOOL_RUNNER" = "x" ]; then + NODETOOL_RUNNER="org.apache.cassandra.tools.NodeTool" +fi + +# shellcheck disable=SC2116 CMD=$(echo "$JAVA" $JAVA_AGENT -ea -cp "$CLASSPATH" $JVM_OPTS -Xmx$MAX_HEAP_SIZE \ -XX:ParallelGCThreads=1 \ -Dcassandra.storagedir="$cassandra_storagedir" \ -Dcassandra.logdir="$CASSANDRA_LOG_DIR" \ -Dlogback.configurationFile=logback-tools.xml \ - $JVM_ARGS \ - org.apache.cassandra.tools.NodeTool -p $JMX_PORT $ARGS) + "$JVM_ARGS" \ + $NODETOOL_RUNNER \ + -p "$JMX_PORT" "$ARGS") if [ "x$ARCHIVE_COMMAND" != "x" ] then diff --git a/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java b/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java index e1f6d28d86a9..cfec7274f1f8 100644 --- a/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java +++ b/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java @@ -77,6 +77,7 @@ 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"), + CASSANDRA_CLI_PICOCLI_LAYOUT("cassandra.cli.picocli.layout", "false"), /** The classpath storage configuration file. */ CASSANDRA_CONFIG("cassandra.config", "cassandra.yaml"), /** diff --git a/src/java/org/apache/cassandra/management/BaseCommand.java b/src/java/org/apache/cassandra/management/BaseCommand.java new file mode 100644 index 000000000000..71534320a7bb --- /dev/null +++ b/src/java/org/apache/cassandra/management/BaseCommand.java @@ -0,0 +1,62 @@ +/* + * 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 javax.inject.Inject; + +import org.apache.cassandra.tools.Output; + +/** + * Base class for all nodetool commands. + */ +public abstract class BaseCommand implements Runnable +{ + @Inject + protected Output logger; + + /** The ServiceBridge instance to interact with the Cassandra node. */ + protected ServiceMBeanBridge bridge; + + /** + * The ServiceBridge instance is injected by the picocli framework during command execution and is used to + * interact with the Cassandra node. This method is called by picocli and used depending on the execution strategy. + * @param bridge The ServiceBridge instance to inject. + */ + public void setBridge(ServiceMBeanBridge bridge) + { + this.bridge = bridge; + } + + /** + * Returns the ServiceBridge instance used to interact with the Cassandra node. + * @return The ServiceBridge instance. + */ + public ServiceMBeanBridge getBridge() + { + return bridge; + } + + @Override + public void run() + { + execute(bridge); + } + + protected abstract void execute(ServiceMBeanBridge probe); +} diff --git a/src/java/org/apache/cassandra/management/CassandraHelpCommand.java b/src/java/org/apache/cassandra/management/CassandraHelpCommand.java new file mode 100644 index 000000000000..23ab472d19b9 --- /dev/null +++ b/src/java/org/apache/cassandra/management/CassandraHelpCommand.java @@ -0,0 +1,152 @@ +/* + * 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.PrintWriter; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.google.common.base.Preconditions; + +import picocli.CommandLine; + +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_COMMAND_LIST; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_COMMAND_LIST_HEADING; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_EXIT_CODE_LIST; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_EXIT_CODE_LIST_HEADING; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_FOOTER; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_FOOTER_HEADING; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_HEADER; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_HEADER_HEADING; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_SYNOPSIS; + +@CommandLine.Command(name = "help", + header = "Display help information about the specified command.", + synopsisHeading = "%nUsage: ", + helpCommand = true, + description = { "%nWhen no COMMAND is given, the usage help for the main command is displayed.", + "If a COMMAND is specified, the help for that command is shown.%n" }) +public class CassandraHelpCommand implements CommandLine.IHelpCommandInitializable2, Runnable +{ + @CommandLine.Option(names = { "--help" }, usageHelp = true, descriptionKey = "helpCommand.help", + description = "Show usage help for the help command and exit.") + private boolean helpRequested; + + @CommandLine.Parameters(paramLabel = "command", arity = "0..1", descriptionKey = "helpCommand.command", + description = "The COMMAND to display the usage help message for.") + private String commands; + + private CommandLine self; + private PrintWriter out; + private CommandLine.Help.ColorScheme colorScheme; + + /** + * Invokes {@code #usage(PrintStream, CommandLine.Help.ColorScheme) usage} for the specified command, + * or for the parent command. + */ + public void run() + { + CommandLine parent = self == null ? null : self.getParent(); + if (parent == null) + return; + + CommandLine.Help.ColorScheme colors = colorScheme == null ? + CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.AUTO) : + colorScheme; + + if (commands == null) + { + // If the parent command is the top-level command, print help for the top-level command. + printTopCommandUsage(parent, colors, out); + return; + } + + Map parentSubcommands = parent.getCommandSpec().subcommands(); + CommandLine subcommand = parentSubcommands.get(commands); + + if (parent.isAbbreviatedSubcommandsAllowed()) + throw new CommandLine.ParameterException(parent, "Abbreviated subcommands are not allowed.", null, commands); + + if (subcommand == null) + throw new CommandLine.ParameterException(parent, "Unknown subcommand '" + commands + "'.", null, commands); + + subcommand.usage(out, colors); + } + + public static void printTopCommandUsage(CommandLine command, CommandLine.Help.ColorScheme colors, PrintWriter writer) + { + if (command == null) + return; + + StringBuilder sb = new StringBuilder(); + CommandLine.Help help = command.getHelpFactory().create(command.getCommandSpec(), colors); + if (!(help instanceof CassandraHelpLayout)) + { + command.usage(writer, colors); + return; + } + + Map helpSectionMap = cassandraTopLevelHelpSectionKeys((CassandraHelpLayout) help); + for (String key : command.getHelpSectionKeys()) + { + CommandLine.IHelpSectionRenderer renderer = helpSectionMap.get(key); + if (renderer == null) + continue; + sb.append(renderer.render(help)); + } + + writer.println(sb); + writer.flush(); + } + + /** + * Top-level help command (includes all the available nodetool commands) has a different layout, so we need to + * provide a different set of keys for the help sections. + * @param layout The help class layout. + * @return Map of supported keys for the help sections. + */ + public static Map cassandraTopLevelHelpSectionKeys(CassandraHelpLayout layout) + { + Map sectionMap = new LinkedHashMap<>(); + sectionMap.put(SECTION_KEY_HEADER_HEADING, CommandLine.Help::headerHeading); + sectionMap.put(SECTION_KEY_HEADER, CommandLine.Help::header); + sectionMap.put(SECTION_KEY_SYNOPSIS, layout::topLevelSynopsis); + sectionMap.put(SECTION_KEY_COMMAND_LIST_HEADING, layout::topLevelCommandListHeading); + sectionMap.put(SECTION_KEY_COMMAND_LIST, CommandLine.Help::commandList); + sectionMap.put(SECTION_KEY_EXIT_CODE_LIST_HEADING, CommandLine.Help::exitCodeListHeading); + sectionMap.put(SECTION_KEY_EXIT_CODE_LIST, CommandLine.Help::exitCodeList); + sectionMap.put(SECTION_KEY_FOOTER_HEADING, CommandLine.Help::footerHeading); + sectionMap.put(SECTION_KEY_FOOTER, CommandLine.Help::footer); + return sectionMap; + } + + /** + * The printHelpIfRequested method calls the init method on commands marked + * as helpCommand before the help command's run or call method is called. + */ + public void init(CommandLine helpCommandLine, + CommandLine.Help.ColorScheme colorScheme, + PrintWriter out, + PrintWriter err) + { + this.self = Preconditions.checkNotNull(helpCommandLine, "helpCommandLine"); + this.colorScheme = Preconditions.checkNotNull(colorScheme, "colorScheme"); + this.out = Preconditions.checkNotNull(out, "outWriter"); + } +} diff --git a/src/java/org/apache/cassandra/management/CassandraHelpLayout.java b/src/java/org/apache/cassandra/management/CassandraHelpLayout.java new file mode 100644 index 000000000000..c292ee152889 --- /dev/null +++ b/src/java/org/apache/cassandra/management/CassandraHelpLayout.java @@ -0,0 +1,547 @@ +/* + * 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.Collections; +import java.util.Comparator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import org.apache.cassandra.utils.Pair; +import picocli.CommandLine; + +import static org.apache.cassandra.management.CommandUtils.findBackwardCompatibleArgument; +import static org.apache.cassandra.management.CommandUtils.leadingSpaces; +import static org.apache.cassandra.management.CommandUtils.sortShortestFirst; +import static org.apache.commons.lang3.ArrayUtils.isEmpty; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_COMMAND_LIST; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_COMMAND_LIST_HEADING; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_DESCRIPTION; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_DESCRIPTION_HEADING; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_END_OF_OPTIONS; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_EXIT_CODE_LIST; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_EXIT_CODE_LIST_HEADING; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_FOOTER; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_FOOTER_HEADING; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_HEADER; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_HEADER_HEADING; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_OPTION_LIST; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_OPTION_LIST_HEADING; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_PARAMETER_LIST; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_PARAMETER_LIST_HEADING; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_SYNOPSIS; +import static picocli.CommandLine.Model.UsageMessageSpec.SECTION_KEY_SYNOPSIS_HEADING; + +/** + * Help facotry for the Cassandra nodetool to generate the help output. This class is used to match + * the command output with the previously available nodetool help output format. + */ +public class CassandraHelpLayout extends CommandLine.Help +{ + public static final int DEFAULT_USAGE_HELP_WIDTH = 85; + private static final String DESCRIPTION_HEADING = "NAME%n"; + private static final String SYNOPSIS_HEADING = "SYNOPSIS%n"; + private static final String OPTIONS_HEADING = "OPTIONS%n"; + private static final String FOOTER_HEADING = "%n"; + private static final int DESCRIPTION_INDENT = 4; + public static final int COLUMN_INDENT = 8; + public static final int SUBCOMMANDS_INDENT = 4; + private static final CommandLine.Model.OptionSpec CASSANDRA_END_OF_OPTIONS_OPTION = + CommandLine.Model.OptionSpec.builder("--") + .description("This option can be used to separate command-line options from the " + + "list of argument, (useful when arguments might be mistaken for " + + "command-line options") + .arity("0") + .build(); + public static final String TOP_LEVEL_SYNOPSIS_LIST_PREFIX = "usage:"; + public static final String TOP_LEVEL_COMMAND_HEADING = "The most commonly used nodetool commands are:"; + public static final String USAGE_HELP_FOOTER = "See 'nodetool help ' for more information on a specific command."; + public static final String SYNOPSIS_SUBCOMMANDS_LABEL = " []"; + private static final String[] EMPTY_FOOTER = new String[0]; + + public CassandraHelpLayout(CommandLine.Model.CommandSpec spec, ColorScheme scheme) + { + super(spec, scheme); + } + + @Override + public String descriptionHeading(Object... params) + { + return createHeading(DESCRIPTION_HEADING, params); + } + + /** + * @param params Arguments referenced by the format specifiers in the header strings + * @return the header string. + */ + @Override + public String description(Object... params) { + CommandLine.Model.CommandSpec spec = commandSpec(); + String fullName = spec.qualifiedName(); + + TextTable table = TextTable.forColumns(colorScheme(), + new Column(spec.usageMessage().width() - COLUMN_INDENT, COLUMN_INDENT, + Column.Overflow.WRAP)); + table.setAdjustLineBreaksForWideCJKCharacters(spec.usageMessage().adjustLineBreaksForWideCJKCharacters()); + table.indentWrappedLines = 0; + + table.addRowValues(colorScheme().commandText(fullName) + .concat(" - ") + .concat(colorScheme().text(String.join(" ", spec.usageMessage().description())))); + table.addRowValues(Ansi.OFF.new Text("", colorScheme())); + return table.toString(new StringBuilder()).toString(); + } + + @Override + public String synopsisHeading(Object... params) + { + return createHeading(SYNOPSIS_HEADING, params); + } + + @Override + public String synopsis(int synopsisHeadingLength) + { + return printDetailedSynopsis("", COLUMN_INDENT, true); + } + + private Ansi.Text createCassandraSynopsisCommandText() + { + Ansi.Text commandText = ansi().new Text(0); + if (!commandSpec().subcommands().isEmpty()) + return commandText.concat(SYNOPSIS_SUBCOMMANDS_LABEL); + return commandText; + } + + private String printDetailedSynopsis(String synopsisPrefix, int columnIndent, boolean showEndOfOptionsDelimiter) + { + // Cassandra uses end of options delimiter in usage help. + commandSpec().usageMessage().showEndOfOptionsDelimiterInUsageHelp(showEndOfOptionsDelimiter); + + CommandLine.Model.CommandSpec commandSpec = commandSpec(); + ColorScheme colorScheme = colorScheme(); + + List parentOptionsList = createCassandraSynopsisOptionsText(parentCommandOptions(commandSpec)); + List commandOptionsList = createCassandraSynopsisOptionsText(commandSpec.options()); + + Ansi.Text positionalParamText = createCassandraSynopsisPositionalsText(commandSpec, colorScheme); + Ansi.Text endOfOptionsText = positionalParamText.plainString().isEmpty() ? + colorScheme.text("") : + colorScheme.text("[") + .concat(commandSpec.parser().endOfOptionsDelimiter()) + .concat("]"); + Ansi.Text commandText = createCassandraSynopsisCommandText(); + + int width = commandSpec.usageMessage().width(); + boolean isEmptyParent = commandSpec.parent() == null; + Ansi.Text mainCommandText = isEmptyParent ? colorScheme.commandText(commandSpec.name()) : + colorScheme.commandText(commandSpec.parent().qualifiedName()); + TextTable textTable = TextTable.forColumns(colorScheme, new Column(width, columnIndent, Column.Overflow.WRAP)); + textTable.indentWrappedLines = COLUMN_INDENT; + textTable.setAdjustLineBreaksForWideCJKCharacters(commandSpec.usageMessage().adjustLineBreaksForWideCJKCharacters()); + + // List + new LineBreakingLayout(colorScheme, width, textTable) + .concatItem(synopsisPrefix.isEmpty() ? mainCommandText : colorScheme.text(synopsisPrefix).concat(" ").concat(mainCommandText)) + .concatItems(parentOptionsList) + .concatItem(isEmptyParent ? colorScheme.text("") : colorScheme.text(commandSpec.name())) + .concatItems(commandOptionsList) + .concatItem(endOfOptionsText) + // All other fields added to the synopsis are left-adjusted, so we don't need to add them one by one. + .flush(positionalParamText.concat(commandText)); + + textTable.addEmptyRow(); + return textTable.toString(); + } + + private static Ansi.Text createCassandraSynopsisPositionalsText(CommandLine.Model.CommandSpec spec, + ColorScheme colorScheme) + { + List positionals = cassandraPositionals(spec); + + Pair commandArgumensSpec = findBackwardCompatibleArgument(spec.userObject()); + Ansi.Text text = colorScheme.text(""); + // If the command has a backward compatible argument, use it to generate the synopsis based on the old format. + if (commandArgumensSpec != null) + return colorScheme.parameterText(commandArgumensSpec.left); + + IParamLabelRenderer parameterLabelRenderer = CassandraStyleParamLabelRender.create(); + for (CommandLine.Model.PositionalParamSpec positionalParam : positionals) + { + Ansi.Text label = parameterLabelRenderer.renderParameterLabel(positionalParam, colorScheme.ansi(), colorScheme.parameterStyles()); + text = text.plainString().isEmpty() ? label : text.concat(" ").concat(label); + } + return text; + } + + private static List parentCommandOptions(CommandLine.Model.CommandSpec commandSpec) + { + List hierarhy = new LinkedList<>(); + CommandLine.Model.CommandSpec curr; + while ((curr = commandSpec.parent()) != null) + { + hierarhy.add(curr); + commandSpec = curr; + } + Collections.reverse(hierarhy); + List options = new ArrayList<>(); + for (CommandLine.Model.CommandSpec spec : hierarhy) + options.addAll(spec.options()); + return options; + } + + private List createCassandraSynopsisOptionsText(List options) + { + // Cassandra uses alphabetical order for options, ordered by short name. + List optionList = new ArrayList<>(options); + optionList.sort(createShortOptionNameComparator()); + List result = new ArrayList<>(); + + ColorScheme colorScheme = colorScheme(); + IParamLabelRenderer parameterLabelRenderer = CassandraStyleParamLabelRender.create(); + + for (CommandLine.Model.OptionSpec option : optionList) + { + if (option.hidden()) + continue; + + Ansi.Text text = ansi().new Text(0); + String[] names = sortShortestFirst(option.names()); + if (names.length == 1) + { + text = text.concat("[").concat(colorScheme.optionText(names[0])) + .concat(spacedParamLabel(option, parameterLabelRenderer, colorScheme)) + .concat("]"); + } + else + { + Ansi.Text shortName = colorScheme.optionText(option.shortestName()); + Ansi.Text fullName = colorScheme.optionText(option.longestName()); + text = text.concat("[(") + .concat(shortName) + .concat(spacedParamLabel(option, parameterLabelRenderer, colorScheme)) + .concat(" | ") + .concat(fullName) + .concat(spacedParamLabel(option, parameterLabelRenderer, colorScheme)) + .concat(")]"); + } + + result.add(text); + } + return result; + } + + @Override + public String optionListHeading(Object... params) + { + return createHeading(OPTIONS_HEADING, params); + } + + @Override + public String optionList() + { + Comparator comparator = createShortOptionNameComparator(); + List options = new LinkedList<>(parentCommandOptions(commandSpec())); + options.addAll(commandSpec().options()); + options.sort(comparator); + + Layout layout = cassandraSingleColumnOptionsParametersLayout(); + layout.addAllOptions(options, CassandraStyleParamLabelRender.create()); + return layout.toString(); + } + + @Override + public String endOfOptionsList() + { + List positionals = cassandraPositionals(commandSpec()); + if (positionals.isEmpty()) + return ""; + Layout layout = cassandraSingleColumnOptionsParametersLayout(); + layout.addOption(CASSANDRA_END_OF_OPTIONS_OPTION, CassandraStyleParamLabelRender.create()); + return layout.toString(); + } + + private Layout cassandraSingleColumnOptionsParametersLayout() + { + return new Layout(colorScheme(), configureLayoutTextTable(), new CassandraStyleOptionRenderer(), new CassandraStyleParameterRenderer()); + } + + private TextTable configureLayoutTextTable() + { + TextTable table = TextTable.forColumns(colorScheme(), new Column(commandSpec().usageMessage().width() - COLUMN_INDENT, + COLUMN_INDENT, Column.Overflow.WRAP)); + table.setAdjustLineBreaksForWideCJKCharacters(commandSpec().usageMessage().adjustLineBreaksForWideCJKCharacters()); + table.indentWrappedLines = DESCRIPTION_INDENT; + return table; + } + + @Override + public String parameterList() + { + Pair cassandraArgument = findBackwardCompatibleArgument(commandSpec().userObject()); + List positionalParams = cassandraPositionals(commandSpec()); + TextTable table = configureLayoutTextTable(); + Layout layout = cassandraArgument == null ? + cassandraSingleColumnOptionsParametersLayout() : + new Layout(colorScheme(), + table, + new CassandraStyleOptionRenderer(), + new CassandraStyleParameterRenderer()) + { + // If the command has a backward compatible argument, use it to generate the synopsis + // based on the old format. + @Override + public void layout(CommandLine.Model.ArgSpec argSpec, Ansi.Text[][] cellValues) + { + Ansi.Text descPadding = Ansi.OFF.new Text(leadingSpaces(DESCRIPTION_INDENT), colorScheme); + cellValues[0] = new Ansi.Text[]{ colorScheme.parameterText(cassandraArgument.left) }; + cellValues[1] = new Ansi.Text[]{ descPadding.concat(colorScheme.parameterText(cassandraArgument.right)) }; + cellValues[2] = new Ansi.Text[]{ Ansi.OFF.new Text("", colorScheme) }; + for (Ansi.Text[] oneRow : cellValues) + table.addRowValues(oneRow); + } + + @Override + public void addAllPositionalParameters(List params, + IParamLabelRenderer paramLabelRenderer) + { + layout(null, new Ansi.Text[3][]); + } + }; + + layout.addAllPositionalParameters(positionalParams, CassandraStyleParamLabelRender.create()); + table.addEmptyRow(); + return layout.toString(); + } + + @Override + public String commandList(Map subcommands) + { + if (subcommands.isEmpty()) + return ""; + int width = commandSpec().usageMessage().width(); + int commandLength = Math.min(CommandUtils.maxLength(subcommands.keySet()), width / 2); + int leadinColumnWidth = commandLength + SUBCOMMANDS_INDENT; + CommandLine.Help.TextTable table = TextTable.forColumns(colorScheme(), + new CommandLine.Help.Column(leadinColumnWidth, SUBCOMMANDS_INDENT, + CommandLine.Help.Column.Overflow.SPAN), + new CommandLine.Help.Column(width - leadinColumnWidth, SUBCOMMANDS_INDENT, + CommandLine.Help.Column.Overflow.WRAP)); + table.setAdjustLineBreaksForWideCJKCharacters(commandSpec().usageMessage().adjustLineBreaksForWideCJKCharacters()); + + for (Map.Entry entry : subcommands.entrySet()) + { + CommandLine.Help help = entry.getValue(); + CommandLine.Model.UsageMessageSpec usage = help.commandSpec().usageMessage(); + String header = isEmpty(usage.header()) ? (isEmpty(usage.description()) ? "" : usage.description()[0]) : usage.header()[0]; + Ansi.Text[] lines = colorScheme().text(header).splitLines(); + for (int i = 0; i < lines.length; i++) + table.addRowValues(i == 0 ? help.commandNamesText(", ") : Ansi.OFF.new Text(0), lines[i]); + } + return table.toString(); + } + + @Override + public String footerHeading(Object... params) + { + return createHeading(FOOTER_HEADING, params); + } + + @Override + public String footer(Object... params) + { + String[] footer; + if (commandSpec().parent() == null) + footer = isEmpty(commandSpec().usageMessage().footer()) ? new String[]{ USAGE_HELP_FOOTER } : + commandSpec().usageMessage().footer(); + else + footer = EMPTY_FOOTER; + + TextTable table = TextTable.forColumns(colorScheme(), new Column(commandSpec().usageMessage().width(), 0, Column.Overflow.WRAP)); + table.setAdjustLineBreaksForWideCJKCharacters(commandSpec().usageMessage().adjustLineBreaksForWideCJKCharacters()); + table.indentWrappedLines = 0; + + for (String summaryLine : footer) + table.addRowValues(String.format(summaryLine, params)); + table.addEmptyRow(); + return table.toString(); + } + + public String topLevelCommandListHeading(Object... params) + { + return createHeading(TOP_LEVEL_COMMAND_HEADING + "%n", params); + } + + public String topLevelSynopsis(Object... params) + { + return printDetailedSynopsis(TOP_LEVEL_SYNOPSIS_LIST_PREFIX, 0, false); + } + + private static List cassandraPositionals(CommandLine.Model.CommandSpec commandSpec) + { + List positionals = new ArrayList<>(commandSpec.positionalParameters()); + positionals.removeIf(CommandLine.Model.ArgSpec::hidden); + return positionals; + } + + /** + * Layout for cassandra help CLI output. + * @return List of keys for the help sections. + */ + public static List cassandraHelpSectionKeys() + { + List result = new LinkedList<>(); + result.add(SECTION_KEY_HEADER_HEADING); + result.add(SECTION_KEY_HEADER); + result.add(SECTION_KEY_DESCRIPTION_HEADING); + result.add(SECTION_KEY_DESCRIPTION); + result.add(SECTION_KEY_SYNOPSIS_HEADING); + result.add(SECTION_KEY_SYNOPSIS); + result.add(SECTION_KEY_OPTION_LIST_HEADING); + result.add(SECTION_KEY_OPTION_LIST); + result.add(SECTION_KEY_END_OF_OPTIONS); + result.add(SECTION_KEY_PARAMETER_LIST_HEADING); + result.add(SECTION_KEY_PARAMETER_LIST); + result.add(SECTION_KEY_COMMAND_LIST_HEADING); + result.add(SECTION_KEY_COMMAND_LIST); + result.add(SECTION_KEY_EXIT_CODE_LIST_HEADING); + result.add(SECTION_KEY_EXIT_CODE_LIST); + result.add(SECTION_KEY_FOOTER_HEADING); + result.add(SECTION_KEY_FOOTER); + return result; + } + + private static Ansi.Text spacedParamLabel(CommandLine.Model.OptionSpec optionSpec, + IParamLabelRenderer parameterLabelRenderer, + ColorScheme scheme) + { + return optionSpec.typeInfo().isBoolean() ? scheme.text("") : + scheme.text(" ").concat(parameterLabelRenderer.renderParameterLabel(optionSpec, scheme.ansi(), scheme.optionParamStyles())); + } + + private static class CassandraStyleParamLabelRender implements IParamLabelRenderer + { + public static IParamLabelRenderer create() + { + return new CassandraStyleParamLabelRender(); + } + + @Override + public Ansi.Text renderParameterLabel(CommandLine.Model.ArgSpec argSpec, Ansi ansi, List styles) + { + ColorScheme colorScheme = CommandLine.Help.defaultColorScheme(ansi); + if (argSpec.equals(CASSANDRA_END_OF_OPTIONS_OPTION)) + return colorScheme.text(""); + if (argSpec instanceof CommandLine.Model.OptionSpec && argSpec.typeInfo().isBoolean()) + return colorScheme.text(""); + return argSpec.isOption() ? colorScheme.optionText(argSpec.paramLabel()) : + colorScheme.parameterText(argSpec.paramLabel()); + } + + @Override + public String separator() { return ""; } + } + + private static class CassandraStyleOptionRenderer implements IOptionRenderer + { + public Ansi.Text[][] render(CommandLine.Model.OptionSpec option, IParamLabelRenderer parameterLabelRenderer, ColorScheme scheme) + { + Ansi.Text optionText = scheme.optionText(""); + for (int i = 0; i < option.names().length; i++) + { + String name = option.names()[i]; + optionText = optionText.concat(scheme.optionText(name)) + .concat(spacedParamLabel(option, parameterLabelRenderer, scheme)) + .concat(i == option.names().length - 1 ? "" : ", "); + } + + Ansi.Text descPadding = Ansi.OFF.new Text(leadingSpaces(DESCRIPTION_INDENT), scheme); + Ansi.Text desc = scheme.optionText(option.description().length == 0 ? "" : option.description()[0]); + + Ansi.Text[][] result = new Ansi.Text[3][]; + result[0] = new Ansi.Text[]{ optionText }; + result[1] = new Ansi.Text[]{ descPadding.concat(desc) }; + result[2] = new Ansi.Text[]{ Ansi.OFF.new Text("", scheme) }; + return result; + } + } + + private static class CassandraStyleParameterRenderer implements IParameterRenderer + { + @Override + public Ansi.Text[][] render(CommandLine.Model.PositionalParamSpec param, IParamLabelRenderer parameterLabelRenderer, ColorScheme scheme) + { + String descriptionString = param.description()[0]; + Ansi.Text descPadding = Ansi.OFF.new Text(leadingSpaces(DESCRIPTION_INDENT), scheme); + Ansi.Text[][] result = new Ansi.Text[3][]; + result[0] = new Ansi.Text[]{ parameterLabelRenderer.renderParameterLabel(param, scheme.ansi(), scheme.parameterStyles()) }; + result[1] = new Ansi.Text[]{ descPadding.concat(scheme.parameterText(descriptionString)) }; + result[2] = new Ansi.Text[]{ Ansi.OFF.new Text("", scheme) }; + return result; + } + } + + private static class LineBreakingLayout + { + private static final int spaceWidth = 1; + private final int width; + private final TextTable textTable; + private final Ansi.Text padding; + /** Current line being built, always less than width. */ + private Ansi.Text current; + + public LineBreakingLayout(ColorScheme colorScheme, int width, TextTable textTable) + { + this.width = width - textTable.columns()[0].indent; + this.padding = colorScheme.text(leadingSpaces(textTable.indentWrappedLines)); + this.textTable = textTable; + current = colorScheme.text(""); + } + + public LineBreakingLayout concatItems(List items) + { + for (Ansi.Text item : items) + concatItem(item); + return this; + } + + public LineBreakingLayout concatItem(Ansi.Text item) + { + if (item.plainString().isEmpty()) + return this; + + if (current.plainString().length() + spaceWidth + item.plainString().length() >= width) + { + textTable.addRowValues(current); + current = padding.concat(item); + } + else + current = current == padding || current.plainString().isEmpty() ? + current.concat(item) : + current.concat(" ").concat(item); + return this; + } + + public void flush(Ansi.Text end) + { + textTable.addRowValues(current == padding ? end : current.concat(" ").concat(end)); + } + } +} diff --git a/src/java/org/apache/cassandra/management/CommandUtils.java b/src/java/org/apache/cassandra/management/CommandUtils.java new file mode 100644 index 000000000000..5363c941b298 --- /dev/null +++ b/src/java/org/apache/cassandra/management/CommandUtils.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.management; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; + +import org.apache.cassandra.utils.Pair; + +/** + * Utility methods for nodetool commands. + */ +public final class CommandUtils +{ + /** + * Returns a string with the given number of leading spaces. + * + * @param num the number of leading spaces + * @return the string with the given number of leading spaces + */ + public static String leadingSpaces(int num) + { + char[] buff = new char[num]; + Arrays.fill(buff, ' '); + return new String(buff); + } + + public static int maxLength(Collection any) + { + int result = 0; + for (Object value : any) + result = Math.max(result, String.valueOf(value).length()); + return result; + } + + public static Pair findBackwardCompatibleArgument(Object userObject) + { + Class clazz = userObject.getClass(); + for (Field field : clazz.getFields()) + { + if (field.isAnnotationPresent(ParameterUsage.class)) + { + ParameterUsage ann = field.getAnnotation(ParameterUsage.class); + return Pair.create(ann.usage(), ann.description()); + } + } + return null; + } + + public static String[] sortShortestFirst(String[] names) + { + Arrays.sort(names, Comparator.comparing(String::length)); + return names; + } +} diff --git a/src/java/org/apache/cassandra/management/ParameterUsage.java b/src/java/org/apache/cassandra/management/ParameterUsage.java new file mode 100644 index 000000000000..1f541dfe58f1 --- /dev/null +++ b/src/java/org/apache/cassandra/management/ParameterUsage.java @@ -0,0 +1,36 @@ +/* + * 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.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Argument annotation for Cassandra commands, used to provide a message + * for command-line argument for backward compatibility when help is requested. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.FIELD }) +public @interface ParameterUsage +{ + String description() default ""; + String usage() default ""; +} diff --git a/src/java/org/apache/cassandra/management/ServiceMBeanBridge.java b/src/java/org/apache/cassandra/management/ServiceMBeanBridge.java new file mode 100644 index 000000000000..d37cbaf8d4df --- /dev/null +++ b/src/java/org/apache/cassandra/management/ServiceMBeanBridge.java @@ -0,0 +1,41 @@ +/* + * 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.apache.cassandra.db.compaction.CompactionManagerMBean; +import org.apache.cassandra.service.StorageServiceMBean; + +/** + * Service bridge for Cassandra management commands to access MBeans and other service-related functionality. + * This interface is used to bridge the gap between the nodetool commands and the Cassandra service MBeans. + */ +public interface ServiceMBeanBridge +{ + T getMBean(Class clazz); + + default StorageServiceMBean ssProxy() + { + return getMBean(StorageServiceMBean.class); + } + + default CompactionManagerMBean cmProxy() + { + return getMBean(CompactionManagerMBean.class); + } +} diff --git a/src/java/org/apache/cassandra/tools/nodetool/AbortBootstrap.java b/src/java/org/apache/cassandra/management/api/AbortBootstrap.java similarity index 61% rename from src/java/org/apache/cassandra/tools/nodetool/AbortBootstrap.java rename to src/java/org/apache/cassandra/management/api/AbortBootstrap.java index 4c180ad81cff..a9c1f7ddec27 100644 --- a/src/java/org/apache/cassandra/tools/nodetool/AbortBootstrap.java +++ b/src/java/org/apache/cassandra/management/api/AbortBootstrap.java @@ -15,34 +15,32 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.cassandra.tools.nodetool; +package org.apache.cassandra.management.api; - -import io.airlift.airline.Command; -import io.airlift.airline.Option; -import org.apache.cassandra.tools.NodeProbe; -import org.apache.cassandra.tools.NodeTool.NodeToolCmd; +import org.apache.cassandra.management.BaseCommand; +import org.apache.cassandra.management.ServiceMBeanBridge; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; import static org.apache.commons.lang3.StringUtils.EMPTY; import static org.apache.commons.lang3.StringUtils.isEmpty; @Command(name = "abortbootstrap", description = "Abort a failed bootstrap") -public class AbortBootstrap extends NodeToolCmd +public class AbortBootstrap extends BaseCommand { - @Option(title = "node id", name = "--node", description = "Node ID of the node that failed bootstrap", required = false) - private String nodeId = EMPTY; - - @Option(title = "ip", name = "--ip", description = "IP of the node that failed bootstrap", required = false) - private String endpoint = EMPTY; + @Option(names = "--node", description = "Node ID of the node that failed bootstrap") + public String nodeId = EMPTY; + @Option(names = "--ip", description = "IP of the node that failed bootstrap") + public String ip = EMPTY; @Override - public void execute(NodeProbe probe) + public void execute(ServiceMBeanBridge probe) { - if (isEmpty(nodeId) && isEmpty(endpoint)) + if (isEmpty(nodeId) && isEmpty(ip)) throw new IllegalArgumentException("Either --node or --ip needs to be set"); - if (!isEmpty(nodeId) && !isEmpty(endpoint)) + if (!isEmpty(nodeId) && !isEmpty(ip)) throw new IllegalArgumentException("Only one of --node or --ip need to be set"); - probe.abortBootstrap(nodeId, endpoint); + probe.ssProxy().abortBootstrap(nodeId, ip); } } diff --git a/src/java/org/apache/cassandra/tools/nodetool/Assassinate.java b/src/java/org/apache/cassandra/management/api/Assassinate.java similarity index 51% rename from src/java/org/apache/cassandra/tools/nodetool/Assassinate.java rename to src/java/org/apache/cassandra/management/api/Assassinate.java index 2639ec81b32f..1da182a0ca10 100644 --- a/src/java/org/apache/cassandra/tools/nodetool/Assassinate.java +++ b/src/java/org/apache/cassandra/management/api/Assassinate.java @@ -15,33 +15,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.cassandra.tools.nodetool; +package org.apache.cassandra.management.api; -import static org.apache.commons.lang3.StringUtils.EMPTY; -import io.airlift.airline.Arguments; -import io.airlift.airline.Command; - -import java.net.UnknownHostException; +import org.apache.cassandra.management.BaseCommand; +import org.apache.cassandra.management.ServiceMBeanBridge; +import picocli.CommandLine; -import org.apache.cassandra.tools.NodeProbe; -import org.apache.cassandra.tools.NodeTool.NodeToolCmd; +import static org.apache.commons.lang3.StringUtils.EMPTY; -@Command(name = "assassinate", description = "Forcefully remove a dead node without re-replicating any data. Use as a last resort if you cannot removenode") -public class Assassinate extends NodeToolCmd +@CommandLine.Command(name = "assassinate", description = "Forcefully remove a dead node without re-replicating any data. Use as a last resort if you cannot removenode") +public class Assassinate extends BaseCommand { - @Arguments(title = "ip address", usage = "", description = "IP address of the endpoint to assassinate", required = true) - private String endpoint = EMPTY; + @CommandLine.Parameters(description = "IP address of the endpoint to assassinate", arity = "1") + public String ipAddress = EMPTY; @Override - public void execute(NodeProbe probe) + public void execute(ServiceMBeanBridge probe) { - try - { - probe.assassinateEndpoint(endpoint); - } - catch (UnknownHostException e) - { - throw new RuntimeException(e); - } + probe.ssProxy().assassinateEndpoint(ipAddress); } } diff --git a/src/java/org/apache/cassandra/management/api/Compact.java b/src/java/org/apache/cassandra/management/api/Compact.java new file mode 100644 index 000000000000..078c3f7b8fba --- /dev/null +++ b/src/java/org/apache/cassandra/management/api/Compact.java @@ -0,0 +1,115 @@ +/* + * 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.ArrayList; +import java.util.List; + +import org.apache.cassandra.management.BaseCommand; +import org.apache.cassandra.management.ParameterUsage; +import org.apache.cassandra.management.ServiceMBeanBridge; +import picocli.CommandLine; + +import static org.apache.cassandra.tools.NodeTool.NodeToolCmd.parseOptionalKeyspace; +import static org.apache.cassandra.tools.NodeTool.NodeToolCmd.parseOptionalTables; +import static org.apache.commons.lang3.StringUtils.EMPTY; + +@CommandLine.Command(name = "compact", description = "Force a (major) compaction on one or more tables or user-defined compaction on given SSTables") +public class Compact extends BaseCommand +{ + @ParameterUsage(usage = "[ ...] or ...", + description = "The keyspace followed by one or many tables or list of SSTable data files when using --user-defined") + @CommandLine.Parameters(index = "0..*", description = "The keyspace followed by one or many tables or " + + "list of SSTable data files when using --user-defined") + public List args = new ArrayList<>(); + + @CommandLine.Option(names = { "-s", "--split-output" }, description = "Use -s to not create a single big file") + public boolean splitOutput = false; + + @CommandLine.Option(names = { "--user-defined" }, + description = "Use --user-defined to submit listed files for user-defined compaction") + public boolean userDefined = false; + + @CommandLine.Option(names = { "-st", "--start-token" }, + description = "Use -st to specify a token at which the compaction range starts (inclusive)") + public String startToken = EMPTY; + + @CommandLine.Option(names = { "-et", "--end-token" }, + description = "Use -et to specify a token at which compaction range ends (inclusive)") + public String endToken = EMPTY; + + @CommandLine.Option(names = { "--partition" }, + description = "String representation of the partition key") + public String partitionKey = EMPTY; + + @Override + public void execute(ServiceMBeanBridge probe) + { + final boolean startEndTokenProvided = !(startToken.isEmpty() && endToken.isEmpty()); + final boolean partitionKeyProvided = !partitionKey.isEmpty(); + final boolean tokenProvided = startEndTokenProvided || partitionKeyProvided; + if (splitOutput && (userDefined || tokenProvided)) + { + throw new RuntimeException("Invalid option combination: Can not use split-output here"); + } + if (userDefined && tokenProvided) + { + throw new RuntimeException("Invalid option combination: Can not provide tokens when using user-defined"); + } + + if (userDefined) + { + try + { + String userDefinedFiles = String.join(",", args); + probe.cmProxy().forceUserDefinedCompaction(userDefinedFiles); + } + catch (Exception e) + { + throw new RuntimeException("Error occurred during user defined compaction", e); + } + return; + } + + List keyspaces = parseOptionalKeyspace(args, probe); + String[] tableNames = parseOptionalTables(args); + + for (String keyspace : keyspaces) + { + try + { + if (startEndTokenProvided) + { + probe.ssProxy().forceKeyspaceCompactionForTokenRange(keyspace, startToken, endToken, tableNames); + } + else if (partitionKeyProvided) + { + probe.ssProxy().forceKeyspaceCompactionForPartitionKey(keyspace, partitionKey, tableNames); + } + else + { + probe.ssProxy().forceKeyspaceCompaction(splitOutput, keyspace, tableNames); + } + } + catch (Exception e) + { + throw new RuntimeException("Error occurred during compaction", e); + } + } + } +} diff --git a/src/java/org/apache/cassandra/tools/nodetool/ForceCompact.java b/src/java/org/apache/cassandra/management/api/ForceCompact.java similarity index 53% rename from src/java/org/apache/cassandra/tools/nodetool/ForceCompact.java rename to src/java/org/apache/cassandra/management/api/ForceCompact.java index 99265e7bf0fa..cba5148f3e89 100644 --- a/src/java/org/apache/cassandra/tools/nodetool/ForceCompact.java +++ b/src/java/org/apache/cassandra/management/api/ForceCompact.java @@ -16,28 +16,40 @@ * limitations under the License. */ -package org.apache.cassandra.tools.nodetool; +package org.apache.cassandra.management.api; -import io.airlift.airline.Arguments; -import io.airlift.airline.Command; - -import java.util.ArrayList; import java.util.List; -import org.apache.cassandra.tools.NodeProbe; -import org.apache.cassandra.tools.NodeTool.NodeToolCmd; +import com.google.common.collect.Lists; + +import org.apache.cassandra.management.BaseCommand; +import org.apache.cassandra.management.ParameterUsage; +import org.apache.cassandra.management.ServiceMBeanBridge; +import picocli.CommandLine; import static com.google.common.base.Preconditions.checkArgument; +import static org.apache.cassandra.tools.NodeTool.NodeToolCmd.parsePartitionKeys; -@Command(name = "forcecompact", description = "Force a (major) compaction on a table") -public class ForceCompact extends NodeToolCmd +@CommandLine.Command(name = "forcecompact", description = "Force a (major) compaction on a table") +public class ForceCompact extends BaseCommand { - @Arguments(usage = "[ ]", description = "The keyspace, table, and a list of partition keys ignoring the gc_grace_seconds") - private List args = new ArrayList<>(); + @ParameterUsage(usage = "[
]", + description = "The keyspace, table, and a list of partition keys ignoring the gc_grace_seconds") + public List args; + + @CommandLine.Parameters(index = "0", arity = "1", description = "The keyspace name to compact") + public String keyspace; + + @CommandLine.Parameters(index = "1", arity = "1", description = "The table name to compact") + public String table; + + @CommandLine.Parameters(index = "2..*", arity = "1", description = "The partition keys to compact") + public String[] keys; @Override - public void execute(NodeProbe probe) + public void execute(ServiceMBeanBridge probe) { + args = Lists.asList(keyspace, table, keys); // Check if the input has valid size checkArgument(args.size() >= 3, "forcecompact requires keyspace, table and keys args"); @@ -48,11 +60,11 @@ public void execute(NodeProbe probe) try { - probe.forceCompactionKeysIgnoringGcGrace(keyspaceName, tableName, partitionKeysIgnoreGcGrace); + probe.ssProxy().forceCompactionKeysIgnoringGcGrace(keyspaceName, tableName, partitionKeysIgnoreGcGrace); } catch (Exception e) { throw new RuntimeException("Error occurred during compaction keys", e); } } -} \ No newline at end of file +} diff --git a/src/java/org/apache/cassandra/management/api/JmxConnect.java b/src/java/org/apache/cassandra/management/api/JmxConnect.java new file mode 100644 index 000000000000..142e418eb6d8 --- /dev/null +++ b/src/java/org/apache/cassandra/management/api/JmxConnect.java @@ -0,0 +1,175 @@ +/* + * 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.io.IOException; +import javax.inject.Inject; + +import com.google.common.base.Throwables; + +import org.apache.cassandra.management.BaseCommand; +import org.apache.cassandra.management.ServiceMBeanBridge; +import org.apache.cassandra.tools.INodeProbeFactory; +import picocli.CommandLine; + +import static java.lang.Integer.parseInt; +import static org.apache.cassandra.tools.NodeTool.NodeToolCmd.promptAndReadPassword; +import static org.apache.cassandra.tools.NodeTool.NodeToolCmd.readUserPasswordFromFile; +import static org.apache.cassandra.tools.NodeToolV2.lastExecutableSubcommandWithSameParent; +import static org.apache.commons.lang3.StringUtils.EMPTY; +import static org.apache.commons.lang3.StringUtils.isEmpty; +import static org.apache.commons.lang3.StringUtils.isNotEmpty; + +/** + * Command options for NodeTool commands that are executed via JMX. + */ +@CommandLine.Command(name = "connect", description = "Connect to a Cassandra node via JMX") +public class JmxConnect extends BaseCommand implements AutoCloseable +{ + public static final String MIXIN_KEY = "jmx"; + + /** The command specification, used to access command-specific properties. */ + @CommandLine.Spec + protected CommandLine.Model.CommandSpec spec; // injected by picocli + + @CommandLine.Option(names = { "-h", "--host" }, description = "Node hostname or ip address") + public String host = "127.0.0.1"; + + @CommandLine.Option(names = { "-p", "--port" }, description = "Remote jmx agent port number") + public String port = "7199"; + + @CommandLine.Option(names = { "-u", "--username" }, description = "Remote jmx agent username") + public String username = EMPTY; + + @CommandLine.Option(names = { "-pw", "--password" }, description = "Remote jmx agent password") + public String password = EMPTY; + + @CommandLine.Option(names = { "-pwf", "--password-file" }, description = "Path to the JMX password file") + public String passwordFilePath = EMPTY; + + @CommandLine.Option(names = { "-pp", "--print-port" }, description = "Operate in 4.0 mode with hosts disambiguated by port number") + public boolean printPort = false; + + @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(CommandLine.ParseResult parseResult) + { + CommandLine.Model.CommandSpec jmx = parseResult.commandSpec().mixins().get(MIXIN_KEY); + if (jmx == null) + throw new CommandLine.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. + */ + @Override + protected void execute(ServiceMBeanBridge probe) + { + assert probe == null; + try + { + if (isNotEmpty(username)) { + if (isNotEmpty(passwordFilePath)) + password = readUserPasswordFromFile(username, passwordFilePath); + + if (isEmpty(password)) + password = promptAndReadPassword(); + } + + bridge = username.isEmpty() ? nodeProbeFactory.create(host, parseInt(port)) + : nodeProbeFactory.create(host, parseInt(port), username, password); + } + catch (IOException | SecurityException e) + { + Throwable rootCause = Throwables.getRootCause(e); + logger.error("nodetool: Failed to connect to '%s:%s' - %s: '%s'.%n", host, port, + rootCause.getClass().getSimpleName(), rootCause.getMessage()); + throw new CommandLine.ExecutionException(spec.commandLine(), "Failed to connect to JMX", e); + } + } + + @Override + public void close() throws Exception + { + if (bridge instanceof AutoCloseable) + ((AutoCloseable) bridge).close(); + } + + private static class JmxConnectionCommandInvoker implements CommandLine.IExecutionStrategy, AutoCloseable + { + private final JmxConnect connect; + + public JmxConnectionCommandInvoker(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 BaseCommand) + { + connect.run(); + ((BaseCommand) lastParent.userObject()).setBridge(connect.getBridge()); + } + return new CommandLine.RunLast().execute(parseResult); + } + + @Override + public void close() throws CloseException + { + try + { + if (connect.getBridge() instanceof AutoCloseable) + ((AutoCloseable) connect.getBridge()).close(); + } + catch (Exception e) + { + throw new CloseException("Failed to close JMX connection", e); + } + } + + private static class CloseException extends RuntimeException + { + public CloseException(String message, Throwable cause) + { + super(message, cause); + } + } + } +} diff --git a/src/java/org/apache/cassandra/management/api/TopLevelCommand.java b/src/java/org/apache/cassandra/management/api/TopLevelCommand.java new file mode 100644 index 000000000000..54eb6b628a5c --- /dev/null +++ b/src/java/org/apache/cassandra/management/api/TopLevelCommand.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 org.apache.cassandra.management.CassandraHelpCommand; +import picocli.CommandLine; + +import static org.apache.cassandra.management.CassandraHelpCommand.printTopCommandUsage; + +@CommandLine.Command(name = "nodetool", + description = "Manage your Cassandra cluster", + subcommands = { CassandraHelpCommand.class, + AbortBootstrap.class, + Assassinate.class, + ForceCompact.class, + Compact.class, + Version.class}) +public class TopLevelCommand implements Runnable +{ + @CommandLine.Spec + public CommandLine.Model.CommandSpec spec; + + public void run() + { + printTopCommandUsage(spec.commandLine(), + spec.commandLine().getColorScheme(), + spec.commandLine().getOut()); + } +} diff --git a/src/java/org/apache/cassandra/tools/nodetool/Version.java b/src/java/org/apache/cassandra/management/api/Version.java similarity index 56% rename from src/java/org/apache/cassandra/tools/nodetool/Version.java rename to src/java/org/apache/cassandra/management/api/Version.java index 6556a046272f..fc8d24a009bc 100644 --- a/src/java/org/apache/cassandra/tools/nodetool/Version.java +++ b/src/java/org/apache/cassandra/management/api/Version.java @@ -15,26 +15,26 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.cassandra.tools.nodetool; +package org.apache.cassandra.management.api; -import io.airlift.airline.Command; -import io.airlift.airline.Option; -import org.apache.cassandra.tools.NodeProbe; -import org.apache.cassandra.tools.NodeTool.NodeToolCmd; +import org.apache.cassandra.management.BaseCommand; +import org.apache.cassandra.management.ServiceMBeanBridge; +import picocli.CommandLine; +import picocli.CommandLine.Option; -@Command(name = "version", description = "Print cassandra version") -public class Version extends NodeToolCmd +@CommandLine.Command(name = "version", description = "Print cassandra version") +public class Version extends BaseCommand { - @Option(title = "verbose", - name = {"-v", "--verbose"}, - description = "Include additional information") + @Option(names = {"-v", "--verbose"}, description = "Include addtitional information") private boolean verbose = false; @Override - public void execute(NodeProbe probe) + protected void execute(ServiceMBeanBridge probe) { - probe.output().out.println("ReleaseVersion: " + probe.getReleaseVersion()); - if (verbose) - probe.output().out.println("GitSHA: " + probe.getGitSHA()); + logger.out.println("ReleaseVersion: " + probe.ssProxy().getReleaseVersion()); + if (verbose) { + logger.out.println("GitSHA: " + probe.ssProxy().getGitSHA()); + } + } } diff --git a/src/java/org/apache/cassandra/tools/NodeProbe.java b/src/java/org/apache/cassandra/tools/NodeProbe.java index b121cb310091..65c21a5f0b8b 100644 --- a/src/java/org/apache/cassandra/tools/NodeProbe.java +++ b/src/java/org/apache/cassandra/tools/NodeProbe.java @@ -37,11 +37,12 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.Optional; import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; - +import java.util.function.BiConsumer; import javax.annotation.Nullable; import javax.management.JMX; import javax.management.MBeanServerConnection; @@ -55,12 +56,20 @@ 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.collect.HashMultimap; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterables; +import com.google.common.collect.Maps; +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 com.google.common.collect.ImmutableMap; import org.apache.cassandra.auth.AuthCache; import org.apache.cassandra.auth.AuthCacheMBean; import org.apache.cassandra.auth.CIDRGroupsMappingManager; @@ -97,12 +106,12 @@ import org.apache.cassandra.metrics.StorageMetrics; import org.apache.cassandra.metrics.TableMetrics; import org.apache.cassandra.metrics.ThreadPoolMetrics; +import org.apache.cassandra.management.ServiceMBeanBridge; import org.apache.cassandra.net.MessagingService; import org.apache.cassandra.net.MessagingServiceMBean; import org.apache.cassandra.service.ActiveRepairServiceMBean; import org.apache.cassandra.service.CacheService; import org.apache.cassandra.service.CacheServiceMBean; -import org.apache.cassandra.tcm.CMSOperationsMBean; import org.apache.cassandra.service.GCInspector; import org.apache.cassandra.service.GCInspectorMXBean; import org.apache.cassandra.service.StorageProxy; @@ -111,19 +120,10 @@ import org.apache.cassandra.streaming.StreamManagerMBean; import org.apache.cassandra.streaming.StreamState; import org.apache.cassandra.streaming.management.StreamStateCompositeData; -import org.apache.cassandra.tools.nodetool.formatter.TableBuilder; - -import com.google.common.base.Function; -import com.google.common.base.Strings; -import com.google.common.collect.HashMultimap; -import com.google.common.collect.Iterables; -import com.google.common.collect.Maps; -import com.google.common.collect.Multimap; -import com.google.common.collect.Sets; -import com.google.common.util.concurrent.Uninterruptibles; - import org.apache.cassandra.tcm.CMSOperations; +import org.apache.cassandra.tcm.CMSOperationsMBean; import org.apache.cassandra.tools.nodetool.GetTimeout; +import org.apache.cassandra.tools.nodetool.formatter.TableBuilder; import org.apache.cassandra.utils.NativeLibrary; import static org.apache.cassandra.config.CassandraRelevantProperties.NODETOOL_JMX_NOTIFICATION_POLL_INTERVAL_SECONDS; @@ -132,7 +132,7 @@ /** * JMX client operations for Cassandra. */ -public class NodeProbe implements AutoCloseable +public class NodeProbe implements AutoCloseable, ServiceMBeanBridge { private static final String fmtUrl = "service:jmx:rmi:///jndi/rmi://%s:%d/jmxrmi"; private static final String ssObjName = "org.apache.cassandra.db:type=StorageService"; @@ -175,6 +175,7 @@ public class NodeProbe implements AutoCloseable private boolean failed; protected CIDRFilteringMetricsTableMBean cfmProxy; + private final Map cachedMBeans = new HashMap<>(); /** * Creates a NodeProbe using the specified JMX host, port, username, and password. @@ -233,6 +234,24 @@ protected NodeProbe() this.output = Output.CONSOLE; } + private static T cachedNewMBeanProxy(BiConsumer cache, + MBeanServerConnection connection, + ObjectName objectName, + Class clazz) + { + + T proxy = JMX.newMBeanProxy(connection, objectName, clazz); + cache.accept(clazz.getName(), proxy); + return proxy; + } + + @Override + public T getMBean(Class clazz) + { + return clazz.cast(Optional.ofNullable(cachedMBeans.get(clazz.getName())) + .orElseThrow(() -> new IllegalArgumentException("No MBean found for " + clazz.getName()))); + } + /** * Create a connection to the JMX agent and setup the M[X]Bean proxies. * @@ -262,55 +281,49 @@ protected void connect() throws IOException try { ObjectName name = new ObjectName(ssObjName); - ssProxy = JMX.newMBeanProxy(mbeanServerConn, name, StorageServiceMBean.class); + ssProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, StorageServiceMBean.class); name = new ObjectName(CMSOperations.MBEAN_OBJECT_NAME); - cmsProxy = JMX.newMBeanProxy(mbeanServerConn, name, CMSOperationsMBean.class); + cmsProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, CMSOperationsMBean.class); name = new ObjectName(MessagingService.MBEAN_NAME); - msProxy = JMX.newMBeanProxy(mbeanServerConn, name, MessagingServiceMBean.class); + msProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, MessagingServiceMBean.class); name = new ObjectName(StreamManagerMBean.OBJECT_NAME); - streamProxy = JMX.newMBeanProxy(mbeanServerConn, name, StreamManagerMBean.class); + streamProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, StreamManagerMBean.class); name = new ObjectName(CompactionManager.MBEAN_OBJECT_NAME); - compactionProxy = JMX.newMBeanProxy(mbeanServerConn, name, CompactionManagerMBean.class); + compactionProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, CompactionManagerMBean.class); name = new ObjectName(FailureDetector.MBEAN_NAME); - fdProxy = JMX.newMBeanProxy(mbeanServerConn, name, FailureDetectorMBean.class); + fdProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, FailureDetectorMBean.class); name = new ObjectName(CacheService.MBEAN_NAME); - cacheService = JMX.newMBeanProxy(mbeanServerConn, name, CacheServiceMBean.class); + cacheService = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, CacheServiceMBean.class); name = new ObjectName(StorageProxy.MBEAN_NAME); - spProxy = JMX.newMBeanProxy(mbeanServerConn, name, StorageProxyMBean.class); + spProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, StorageProxyMBean.class); name = new ObjectName(HintsService.MBEAN_NAME); - hsProxy = JMX.newMBeanProxy(mbeanServerConn, name, HintsServiceMBean.class); + hsProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, HintsServiceMBean.class); name = new ObjectName(GCInspector.MBEAN_NAME); - gcProxy = JMX.newMBeanProxy(mbeanServerConn, name, GCInspectorMXBean.class); + gcProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, GCInspectorMXBean.class); name = new ObjectName(Gossiper.MBEAN_NAME); - gossProxy = JMX.newMBeanProxy(mbeanServerConn, name, GossiperMBean.class); + gossProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, GossiperMBean.class); name = new ObjectName(BatchlogManager.MBEAN_NAME); - bmProxy = JMX.newMBeanProxy(mbeanServerConn, name, BatchlogManagerMBean.class); + bmProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, BatchlogManagerMBean.class); name = new ObjectName(ActiveRepairServiceMBean.MBEAN_NAME); - arsProxy = JMX.newMBeanProxy(mbeanServerConn, name, ActiveRepairServiceMBean.class); + arsProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, ActiveRepairServiceMBean.class); name = new ObjectName(AuditLogManager.MBEAN_NAME); - almProxy = JMX.newMBeanProxy(mbeanServerConn, name, AuditLogManagerMBean.class); + almProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, AuditLogManagerMBean.class); name = new ObjectName(AuthCache.MBEAN_NAME_BASE + PasswordAuthenticator.CredentialsCacheMBean.CACHE_NAME); - ccProxy = JMX.newMBeanProxy(mbeanServerConn, name, PasswordAuthenticator.CredentialsCacheMBean.class); + ccProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, PasswordAuthenticator.CredentialsCacheMBean.class); name = new ObjectName(AuthCache.MBEAN_NAME_BASE + AuthorizationProxy.JmxPermissionsCacheMBean.CACHE_NAME); - jpcProxy = JMX.newMBeanProxy(mbeanServerConn, name, AuthorizationProxy.JmxPermissionsCacheMBean.class); - + jpcProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, AuthorizationProxy.JmxPermissionsCacheMBean.class); name = new ObjectName(AuthCache.MBEAN_NAME_BASE + NetworkPermissionsCache.CACHE_NAME); - npcProxy = JMX.newMBeanProxy(mbeanServerConn, name, NetworkPermissionsCacheMBean.class); - + npcProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, NetworkPermissionsCacheMBean.class); name = new ObjectName(AuthCache.MBEAN_NAME_BASE + PermissionsCache.CACHE_NAME); - pcProxy = JMX.newMBeanProxy(mbeanServerConn, name, PermissionsCacheMBean.class); - + pcProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, PermissionsCacheMBean.class); name = new ObjectName(AuthCache.MBEAN_NAME_BASE + RolesCache.CACHE_NAME); - rcProxy = JMX.newMBeanProxy(mbeanServerConn, name, RolesCacheMBean.class); - + rcProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, RolesCacheMBean.class); name = new ObjectName(CIDRPermissionsManager.MBEAN_NAME); - cpbProxy = JMX.newMBeanProxy(mbeanServerConn, name, CIDRPermissionsManagerMBean.class); - + cpbProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, CIDRPermissionsManagerMBean.class); name = new ObjectName(CIDRGroupsMappingManager.MBEAN_NAME); - cmbProxy = JMX.newMBeanProxy(mbeanServerConn, name, CIDRGroupsMappingManagerMBean.class); - + cmbProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, CIDRGroupsMappingManagerMBean.class); name = new ObjectName(CIDRFilteringMetricsTable.MBEAN_NAME); - cfmProxy = JMX.newMBeanProxy(mbeanServerConn, name, CIDRFilteringMetricsTableMBean.class); + cfmProxy = cachedNewMBeanProxy(cachedMBeans::put, mbeanServerConn, name, CIDRFilteringMetricsTableMBean.class); } catch (MalformedObjectNameException e) { diff --git a/src/java/org/apache/cassandra/tools/NodeTool.java b/src/java/org/apache/cassandra/tools/NodeTool.java index bd1e302ba066..8df3a1625b8c 100644 --- a/src/java/org/apache/cassandra/tools/NodeTool.java +++ b/src/java/org/apache/cassandra/tools/NodeTool.java @@ -17,20 +17,7 @@ */ package org.apache.cassandra.tools; -import static com.google.common.base.Throwables.getStackTraceAsString; -import static com.google.common.collect.Iterables.toArray; -import static com.google.common.collect.Lists.newArrayList; -import static java.lang.Integer.parseInt; -import static java.lang.String.format; -import static org.apache.cassandra.io.util.File.WriteMode.APPEND; -import static org.apache.commons.lang3.ArrayUtils.EMPTY_STRING_ARRAY; -import static org.apache.commons.lang3.StringUtils.EMPTY; -import static org.apache.commons.lang3.StringUtils.isEmpty; -import static org.apache.commons.lang3.StringUtils.isNotEmpty; - import java.io.Console; -import org.apache.cassandra.io.util.File; -import org.apache.cassandra.io.util.FileWriter; import java.io.FileNotFoundException; import java.io.IOError; import java.io.IOException; @@ -44,16 +31,13 @@ import java.util.Map.Entry; import java.util.Scanner; import java.util.SortedMap; - +import java.util.TreeMap; +import java.util.function.Consumer; import javax.management.InstanceNotFoundException; import com.google.common.base.Joiner; import com.google.common.base.Throwables; - -import org.apache.cassandra.locator.EndpointSnitchInfoMBean; -import org.apache.cassandra.tools.nodetool.*; -import org.apache.cassandra.utils.FBUtilities; - +import com.google.common.collect.ImmutableList; import com.google.common.collect.Maps; import io.airlift.airline.Cli; @@ -67,6 +51,33 @@ import io.airlift.airline.ParseOptionConversionException; import io.airlift.airline.ParseOptionMissingException; import io.airlift.airline.ParseOptionMissingValueException; +import io.airlift.airline.UsageHelper; +import io.airlift.airline.UsagePrinter; +import io.airlift.airline.model.CommandGroupMetadata; +import io.airlift.airline.model.CommandMetadata; +import io.airlift.airline.model.GlobalMetadata; +import org.apache.cassandra.io.util.File; +import org.apache.cassandra.io.util.FileWriter; +import org.apache.cassandra.locator.EndpointSnitchInfoMBean; +import org.apache.cassandra.management.CassandraHelpLayout; +import org.apache.cassandra.management.ServiceMBeanBridge; +import org.apache.cassandra.tools.nodetool.*; +import org.apache.cassandra.utils.FBUtilities; +import picocli.CommandLine; + +import static com.google.common.base.MoreObjects.firstNonNull; +import static com.google.common.base.Throwables.getStackTraceAsString; +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.collect.Iterables.toArray; +import static com.google.common.collect.Lists.newArrayList; +import static java.lang.Integer.parseInt; +import static java.lang.String.format; +import static java.util.stream.Collectors.toList; +import static org.apache.cassandra.io.util.File.WriteMode.APPEND; +import static org.apache.commons.lang3.ArrayUtils.EMPTY_STRING_ARRAY; +import static org.apache.commons.lang3.StringUtils.EMPTY; +import static org.apache.commons.lang3.StringUtils.isEmpty; +import static org.apache.commons.lang3.StringUtils.isNotEmpty; public class NodeTool { @@ -75,6 +86,7 @@ public class NodeTool FBUtilities.preventIllegalAccessWarnings(); } + private static final int FALLBACK_NODETOOL_V1 = -100; private static final String HISTORYFILE = "nodetool.history"; private final INodeProbeFactory nodeProbeFactory; @@ -94,14 +106,11 @@ public NodeTool(INodeProbeFactory nodeProbeFactory, Output output) public int execute(String... args) { List> commands = newArrayList( - AbortBootstrap.class, - Assassinate.class, CassHelp.class, CIDRFilteringStats.class, Cleanup.class, ClearSnapshot.class, ClientStats.class, - Compact.class, CompactionHistory.class, CompactionStats.class, DataPaths.class, @@ -232,9 +241,7 @@ public int execute(String... args) UpdateCIDRGroup.class, UpgradeSSTable.class, Verify.class, - Version.class, - ViewBuildStatus.class, - ForceCompact.class + ViewBuildStatus.class ); Cli.CliBuilder builder = Cli.builder("nodetool"); @@ -272,9 +279,28 @@ public int execute(String... args) int status = 0; try { - NodeToolCmdRunnable parse = parser.parse(args); - printHistory(args); - parse.run(nodeProbeFactory, output); + // Try to run the command with the new parser, if it у fails, fallback to the old parser. + int result = new NodeToolV2(nodeProbeFactory, output) + // Filter out the help command, and nodetool command, and fallback to the old help message. + .withCommandNameFilter(cmd -> cmd.equals("help") || cmd.equals("nodetool"), FALLBACK_NODETOOL_V1) + .withParameterExceptionHandler((ex, arg) -> { + if (ex instanceof CommandLine.UnmatchedArgumentException) + return FALLBACK_NODETOOL_V1; + badUse(ex); + return 1; + }) + .withExecutionExceptionHandler((ex, c, arg) -> { + err(ex); + return 2; + }).execute(args); + + if (result >= 0) + return result; + + // Fallback to the old parser, and run the command. + assert result == FALLBACK_NODETOOL_V1; + NodeToolCmdRunnable cmd = parser.parse(args); + cmd.run(nodeProbeFactory, output); } catch (IllegalArgumentException | IllegalStateException | ParseArgumentsMissingException | @@ -296,7 +322,7 @@ public int execute(String... args) return status; } - private static void printHistory(String... args) + public static void printHistory(String... args) { //don't bother to print if no args passed (meaning, nodetool is just printing out the sub-commands list) if (args.length == 0) @@ -316,32 +342,96 @@ private static void printHistory(String... args) } } + public static void badUse(Consumer out, Throwable e) + { + out.accept("nodetool: " + e.getMessage()); + out.accept("See 'nodetool help' or 'nodetool help '."); + } + protected void badUse(Exception e) { - output.out.println("nodetool: " + e.getMessage()); - output.out.println("See 'nodetool help' or 'nodetool help '."); + badUse(output.out::println, e); } - protected void err(Throwable e) + public static void err(Consumer out, 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)); + out.accept("error: " + e.getMessage()); + out.accept("-- StackTrace --"); + out.accept(getStackTraceAsString(e)); + } + + protected void err(Throwable e) + { + err(output.err::println, e); } public static class CassHelp extends Help implements NodeToolCmdRunnable { public void run(INodeProbeFactory nodeProbeFactory, Output output) { - run(); + StringBuilder sb = new StringBuilder(); + NodeToolV2 cmd = new NodeToolV2(nodeProbeFactory, output); + if (command.isEmpty()) + { + usage(global, cmd.getCommandsDescription(), sb); + } + else + { + if (cmd.isCommandPresent(command.get(0))) + cmd.execute("help", command.get(0)); + else + help(global, command, sb); + } + + output.out.println(sb); + } + + public static void usage(GlobalMetadata global, Map extraCommands, StringBuilder sb) + { + UsagePrinter out = new UsagePrinter(sb, CassandraHelpLayout.DEFAULT_USAGE_HELP_WIDTH); + List commandArguments = global.getOptions().stream() + .filter(option -> !option.isHidden()) + .map(UsageHelper::toUsage) + .collect(toImmutableList()); + + out.newPrinterWithHangingIndent(CassandraHelpLayout.COLUMN_INDENT) + .append(CassandraHelpLayout.TOP_LEVEL_SYNOPSIS_LIST_PREFIX) + .append(global.getName()) + .appendWords(commandArguments) + .append(CassandraHelpLayout.SYNOPSIS_SUBCOMMANDS_LABEL) + .newline() + .newline(); + + Map commands = getCommandsDescription(global); + // Remove the help command from the list of extra commands if exists, as it's not applicable for backward compatibility. + extraCommands.remove("help"); + commands.putAll(extraCommands); + + out.append(CassandraHelpLayout.TOP_LEVEL_COMMAND_HEADING).newline(); + out.newIndentedPrinter(CassandraHelpLayout.SUBCOMMANDS_INDENT) + .appendTable(commands.entrySet().stream() + .map(entry -> ImmutableList.of(entry.getKey(), firstNonNull(entry.getValue(), ""))) + .collect(toList())); + out.newline(); + out.append(CassandraHelpLayout.USAGE_HELP_FOOTER); + } + + private static Map getCommandsDescription(GlobalMetadata global) + { + Map commands = new TreeMap<>(); + for (CommandMetadata meta : global.getDefaultGroupCommands()) + commands.put(meta.getName(), meta.getDescription()); + for (CommandGroupMetadata grp : global.getCommandGroups()) + commands.put(grp.getName(), grp.getDescription()); + return commands; } } - interface NodeToolCmdRunnable + public interface NodeToolCmdRunnable { void run(INodeProbeFactory nodeProbeFactory, Output output); } @@ -401,7 +491,7 @@ public void runInternal() } - private String readUserPasswordFromFile(String username, String passwordFilePath) { + public static String readUserPasswordFromFile(String username, String passwordFilePath) { String password = EMPTY; File passwordFile = new File(passwordFilePath); @@ -429,7 +519,7 @@ private String readUserPasswordFromFile(String username, String passwordFilePath return password; } - private String promptAndReadPassword() + public static String promptAndReadPassword() { String password = EMPTY; @@ -469,12 +559,12 @@ protected enum KeyspaceSet ALL, NON_SYSTEM, NON_LOCAL_STRATEGY } - protected List parseOptionalKeyspace(List cmdArgs, NodeProbe nodeProbe) + public static List parseOptionalKeyspace(List cmdArgs, ServiceMBeanBridge nodeProbe) { return parseOptionalKeyspace(cmdArgs, nodeProbe, KeyspaceSet.ALL); } - protected List parseOptionalKeyspace(List cmdArgs, NodeProbe nodeProbe, KeyspaceSet defaultKeyspaceSet) + public static List parseOptionalKeyspace(List cmdArgs, ServiceMBeanBridge nodeProbe, KeyspaceSet defaultKeyspaceSet) { List keyspaces = new ArrayList<>(); @@ -482,11 +572,11 @@ protected List parseOptionalKeyspace(List cmdArgs, NodeProbe nod if (cmdArgs == null || cmdArgs.isEmpty()) { if (defaultKeyspaceSet == KeyspaceSet.NON_LOCAL_STRATEGY) - keyspaces.addAll(keyspaces = nodeProbe.getNonLocalStrategyKeyspaces()); + keyspaces.addAll(keyspaces = nodeProbe.ssProxy().getNonLocalStrategyKeyspaces()); else if (defaultKeyspaceSet == KeyspaceSet.NON_SYSTEM) - keyspaces.addAll(keyspaces = nodeProbe.getNonSystemKeyspaces()); + keyspaces.addAll(keyspaces = nodeProbe.ssProxy().getNonSystemKeyspaces()); else - keyspaces.addAll(nodeProbe.getKeyspaces()); + keyspaces.addAll(nodeProbe.ssProxy().getKeyspaces()); } else { @@ -495,19 +585,19 @@ else if (defaultKeyspaceSet == KeyspaceSet.NON_SYSTEM) for (String keyspace : keyspaces) { - if (!nodeProbe.getKeyspaces().contains(keyspace)) + if (!nodeProbe.ssProxy().getKeyspaces().contains(keyspace)) throw new IllegalArgumentException("Keyspace [" + keyspace + "] does not exist."); } return Collections.unmodifiableList(keyspaces); } - protected String[] parseOptionalTables(List cmdArgs) + public static String[] parseOptionalTables(List cmdArgs) { return cmdArgs.size() <= 1 ? EMPTY_STRING_ARRAY : toArray(cmdArgs.subList(1, cmdArgs.size()), String.class); } - protected String[] parsePartitionKeys(List cmdArgs) + public static String[] parsePartitionKeys(List cmdArgs) { return cmdArgs.size() <= 2 ? EMPTY_STRING_ARRAY : toArray(cmdArgs.subList(2, cmdArgs.size()), String.class); } diff --git a/src/java/org/apache/cassandra/tools/NodeToolV2.java b/src/java/org/apache/cassandra/tools/NodeToolV2.java new file mode 100644 index 000000000000..a19404729e4c --- /dev/null +++ b/src/java/org/apache/cassandra/tools/NodeToolV2.java @@ -0,0 +1,225 @@ +/* + * 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.PrintWriter; +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.function.Predicate; +import javax.inject.Inject; + +import com.google.common.base.Throwables; + +import org.apache.cassandra.config.CassandraRelevantProperties; +import org.apache.cassandra.management.CassandraHelpLayout; +import org.apache.cassandra.management.api.JmxConnect; +import org.apache.cassandra.management.api.TopLevelCommand; +import org.apache.cassandra.utils.FBUtilities; +import picocli.CommandLine; + +import static org.apache.cassandra.tools.NodeTool.badUse; +import static org.apache.cassandra.tools.NodeTool.err; +import static org.apache.cassandra.tools.NodeTool.printHistory; + +public class NodeToolV2 +{ + static + { + FBUtilities.preventIllegalAccessWarnings(); + } + + private final INodeProbeFactory nodeProbeFactory; + private final Output output; + private CommandLine.IParameterExceptionHandler parameterExceptionHandler = (ex, arg) -> { + badUse(ex.getCommandLine().getOut()::println, Throwables.getRootCause(ex)); + return 1; + }; + + private CommandLine.IExecutionExceptionHandler executionExceptionHandler = (ex, cmdLine, parseResult) -> { + err(cmdLine.getErr()::println, Throwables.getRootCause(ex)); + return 2; + }; + + private CommandLine.IExecutionStrategy strategy; + + public static void main(String... args) + { + System.exit(new NodeToolV2(new NodeProbeFactory(), Output.CONSOLE).execute(args)); + } + + public NodeToolV2(INodeProbeFactory nodeProbeFactory, Output output) + { + this.nodeProbeFactory = nodeProbeFactory; + this.output = output; + } + + /** + * Execute the command line utility with the given arguments via the JMX connection. + * @param args command line arguments + * @return 0 on success, 1 on bad use, 2 on execution error + */ + public int execute(String... args) + { + return execute(createCommandLine(new CassandraCliFactory(nodeProbeFactory, output)), args); + } + + protected int execute(CommandLine commandLine, String... args) + { + try + { + configureCliLayout(commandLine); + commandLine.setExecutionStrategy(strategy == null ? JmxConnect::executionStrategy : strategy) + .setExecutionExceptionHandler(executionExceptionHandler) + .setParameterExceptionHandler(parameterExceptionHandler); + + printHistory(args); + return commandLine.execute(args); + } + catch (Exception e) + { + err(output.err::println, e); + return 2; + } + } + + /** + * Filter the command by its name and return the given exit code when the filter is matched. + * @param commandPredicate the predicate to filter the command name. + * @param exitCodeWhenMatched the exit code to return when the filter is matched. + * @return this instance. + */ + public NodeToolV2 withCommandNameFilter(Predicate commandPredicate, int exitCodeWhenMatched) + { + strategy = parsed -> { + CommandLine.Model.CommandSpec spec = lastExecutableSubcommandWithSameParent(parsed.asCommandLineList()); + if (commandPredicate.test(spec.name())) + return exitCodeWhenMatched; + return JmxConnect.executionStrategy(parsed); + }; + return this; + } + + public NodeToolV2 withParameterExceptionHandler(CommandLine.IParameterExceptionHandler handler) + { + parameterExceptionHandler = handler; + return this; + } + + public NodeToolV2 withExecutionExceptionHandler(CommandLine.IExecutionExceptionHandler handler) + { + executionExceptionHandler = handler; + return this; + } + + public boolean isCommandPresent(String commandName) + { + CommandLine commandLine = createCommandLine(new CassandraCliFactory(nodeProbeFactory, output)); + return commandLine.getSubcommands().values().stream() + .anyMatch(sub -> sub.getCommandName().equals(commandName)); + } + + public Map getCommandsDescription() + { + Map commands = new TreeMap<>(); + CommandLine commandLine = createCommandLine(new CassandraCliFactory(nodeProbeFactory, output)); + commandLine.getSubcommands() + .values() + .forEach(sub -> commands.put(sub.getCommandName(), + CommandLine.Help.join(commandLine.getColorScheme().ansi(), 1000, + sub.getCommandSpec().usageMessage().adjustLineBreaksForWideCJKCharacters(), + sub.getCommandSpec().usageMessage().description(), new StringBuilder()).toString())); + // Remove the help command from the list of commands, as it's not applicable for backward compatibility. + return commands; + } + + public 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 CommandLine createCommandLine(CassandraCliFactory factory) + { + return new CommandLine(new TopLevelCommand(), factory) + .addMixin(JmxConnect.MIXIN_KEY, factory.create(JmxConnect.class)) + .setOut(new PrintWriter(factory.output.out, true)) + .setErr(new PrintWriter(factory.output.err, true)); + } + + private static void configureCliLayout(CommandLine commandLine) + { + if (CassandraRelevantProperties.CASSANDRA_CLI_PICOCLI_LAYOUT.getBoolean()) + return; + + commandLine.setHelpFactory(CassandraHelpLayout::new) + .setUsageHelpWidth(CassandraHelpLayout.DEFAULT_USAGE_HELP_WIDTH) + .setHelpSectionKeys(CassandraHelpLayout.cassandraHelpSectionKeys()); + } + + private static class CassandraCliFactory implements CommandLine.IFactory + { + private final CommandLine.IFactory fallback; + private final INodeProbeFactory nodeProbeFactory; + private final Output output; + + public CassandraCliFactory(INodeProbeFactory nodeProbeFactory, Output output) + { + this.fallback = CommandLine.defaultFactory(); + this.nodeProbeFactory = nodeProbeFactory; + 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(INodeProbeFactory.class)) + field.set(bean, nodeProbeFactory); + else if (field.getType().equals(Output.class)) + field.set(bean, output); + } + } + 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/tools/Output.java b/src/java/org/apache/cassandra/tools/Output.java index 1d2fcb3a6325..db0b22fad652 100644 --- a/src/java/org/apache/cassandra/tools/Output.java +++ b/src/java/org/apache/cassandra/tools/Output.java @@ -32,4 +32,14 @@ public Output(PrintStream out, PrintStream err) this.out = out; this.err = err; } + + public void info(String msg, Object... args) + { + out.printf(msg, args); + } + + public void error(String msg, Object... args) + { + err.printf(msg, args); + } } diff --git a/src/java/org/apache/cassandra/tools/nodetool/Compact.java b/src/java/org/apache/cassandra/tools/nodetool/Compact.java deleted file mode 100644 index f5a83ed90475..000000000000 --- a/src/java/org/apache/cassandra/tools/nodetool/Compact.java +++ /dev/null @@ -1,106 +0,0 @@ -/* - * 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 static org.apache.commons.lang3.StringUtils.EMPTY; - -import io.airlift.airline.Arguments; -import io.airlift.airline.Command; -import io.airlift.airline.Option; - -import java.util.ArrayList; -import java.util.List; - -import org.apache.cassandra.tools.NodeProbe; -import org.apache.cassandra.tools.NodeTool.NodeToolCmd; - -@Command(name = "compact", description = "Force a (major) compaction on one or more tables or user-defined compaction on given SSTables") -public class Compact extends NodeToolCmd -{ - @Arguments(usage = "[ ...] or ...", description = "The keyspace followed by one or many tables or list of SSTable data files when using --user-defined") - private List args = new ArrayList<>(); - - @Option(title = "split_output", name = {"-s", "--split-output"}, description = "Use -s to not create a single big file") - private boolean splitOutput = false; - - @Option(title = "user-defined", name = {"--user-defined"}, description = "Use --user-defined to submit listed files for user-defined compaction") - private boolean userDefined = false; - - @Option(title = "start_token", name = {"-st", "--start-token"}, description = "Use -st to specify a token at which the compaction range starts (inclusive)") - private String startToken = EMPTY; - - @Option(title = "end_token", name = {"-et", "--end-token"}, description = "Use -et to specify a token at which compaction range ends (inclusive)") - private String endToken = EMPTY; - - @Option(title = "partition_key", name = {"--partition"}, description = "String representation of the partition key") - private String partitionKey = EMPTY; - - - @Override - public void execute(NodeProbe probe) - { - final boolean startEndTokenProvided = !(startToken.isEmpty() && endToken.isEmpty()); - final boolean partitionKeyProvided = !partitionKey.isEmpty(); - final boolean tokenProvided = startEndTokenProvided || partitionKeyProvided; - if (splitOutput && (userDefined || tokenProvided)) - { - throw new RuntimeException("Invalid option combination: Can not use split-output here"); - } - if (userDefined && tokenProvided) - { - throw new RuntimeException("Invalid option combination: Can not provide tokens when using user-defined"); - } - - if (userDefined) - { - try - { - String userDefinedFiles = String.join(",", args); - probe.forceUserDefinedCompaction(userDefinedFiles); - } catch (Exception e) { - throw new RuntimeException("Error occurred during user defined compaction", e); - } - return; - } - - List keyspaces = parseOptionalKeyspace(args, probe); - String[] tableNames = parseOptionalTables(args); - - for (String keyspace : keyspaces) - { - try - { - if (startEndTokenProvided) - { - probe.forceKeyspaceCompactionForTokenRange(keyspace, startToken, endToken, tableNames); - } - else if (partitionKeyProvided) - { - probe.forceKeyspaceCompactionForPartitionKey(keyspace, partitionKey, tableNames); - } - else - { - probe.forceKeyspaceCompaction(splitOutput, keyspace, tableNames); - } - } catch (Exception e) - { - throw new RuntimeException("Error occurred during compaction", e); - } - } - } -} diff --git a/test/resources/nodetool/help/abortbootstrap b/test/resources/nodetool/help/abortbootstrap new file mode 100644 index 000000000000..c6493ba31d82 --- /dev/null +++ b/test/resources/nodetool/help/abortbootstrap @@ -0,0 +1,34 @@ +NAME + nodetool abortbootstrap - Abort a failed bootstrap + +SYNOPSIS + nodetool [(-h | --host )] [(-p | --port )] + [(-pp | --print-port)] [(-pw | --password )] + [(-pwf | --password-file )] + [(-u | --username )] abortbootstrap [--ip ] + [--node ] + +OPTIONS + -h , --host + Node hostname or ip address + + --ip + IP of the node that failed bootstrap + + --node + Node ID of the node that failed bootstrap + + -p , --port + Remote jmx agent port number + + -pp, --print-port + Operate in 4.0 mode with hosts disambiguated by port number + + -pw , --password + Remote jmx agent password + + -pwf , --password-file + Path to the JMX password file + + -u , --username + Remote jmx agent username \ No newline at end of file diff --git a/test/resources/nodetool/help/assassinate b/test/resources/nodetool/help/assassinate new file mode 100644 index 000000000000..c8c34e401154 --- /dev/null +++ b/test/resources/nodetool/help/assassinate @@ -0,0 +1,38 @@ +NAME + nodetool assassinate - Forcefully remove a dead node without + re-replicating any data. Use as a last resort if you cannot + removenode + +SYNOPSIS + nodetool [(-h | --host )] [(-p | --port )] + [(-pp | --print-port)] [(-pw | --password )] + [(-pwf | --password-file )] + [(-u | --username )] assassinate [--] + + +OPTIONS + -h , --host + Node hostname or ip address + + -p , --port + Remote jmx agent port number + + -pp, --print-port + Operate in 4.0 mode with hosts disambiguated by port number + + -pw , --password + Remote jmx agent password + + -pwf , --password-file + Path to the JMX password file + + -u , --username + Remote jmx agent username + + -- + This option can be used to separate command-line options from + the list of argument, (useful when arguments might be mistaken + for command-line options + + + IP address of the endpoint to assassinate \ No newline at end of file diff --git a/test/resources/nodetool/help/compact b/test/resources/nodetool/help/compact new file mode 100644 index 000000000000..a9ca45ccfba7 --- /dev/null +++ b/test/resources/nodetool/help/compact @@ -0,0 +1,59 @@ +NAME + nodetool compact - Force a (major) compaction on one or more tables + or user-defined compaction on given SSTables + +SYNOPSIS + nodetool [(-h | --host )] [(-p | --port )] + [(-pp | --print-port)] [(-pw | --password )] + [(-pwf | --password-file )] + [(-u | --username )] compact + [(-et | --end-token )] + [--partition ] [(-s | --split-output)] + [(-st | --start-token )] [--user-defined] + [--] [ ...] or ... + +OPTIONS + -et , --end-token + Use -et to specify a token at which compaction range ends + (inclusive) + + -h , --host + Node hostname or ip address + + -p , --port + Remote jmx agent port number + + --partition + String representation of the partition key + + -pp, --print-port + Operate in 4.0 mode with hosts disambiguated by port number + + -pw , --password + Remote jmx agent password + + -pwf , --password-file + Path to the JMX password file + + -s, --split-output + Use -s to not create a single big file + + -st , --start-token + Use -st to specify a token at which the compaction range starts + (inclusive) + + -u , --username + Remote jmx agent username + + --user-defined + Use --user-defined to submit listed files for user-defined + compaction + + -- + This option can be used to separate command-line options from + the list of argument, (useful when arguments might be mistaken + for command-line options + + [ ...] or ... + The keyspace followed by one or many tables or list of SSTable + data files when using --user-defined \ No newline at end of file diff --git a/test/resources/nodetool/help/forcecompact b/test/resources/nodetool/help/forcecompact new file mode 100644 index 000000000000..b248f0ac88f6 --- /dev/null +++ b/test/resources/nodetool/help/forcecompact @@ -0,0 +1,37 @@ +NAME + nodetool forcecompact - Force a (major) compaction on a table + +SYNOPSIS + nodetool [(-h | --host )] [(-p | --port )] + [(-pp | --print-port)] [(-pw | --password )] + [(-pwf | --password-file )] + [(-u | --username )] forcecompact [--] + [
] + +OPTIONS + -h , --host + Node hostname or ip address + + -p , --port + Remote jmx agent port number + + -pp, --print-port + Operate in 4.0 mode with hosts disambiguated by port number + + -pw , --password + Remote jmx agent password + + -pwf , --password-file + Path to the JMX password file + + -u , --username + Remote jmx agent username + + -- + This option can be used to separate command-line options from + the list of argument, (useful when arguments might be mistaken + for command-line options + + [
] + The keyspace, table, and a list of partition keys ignoring the + gc_grace_seconds \ No newline at end of file diff --git a/test/unit/org/apache/cassandra/tools/ToolRunner.java b/test/unit/org/apache/cassandra/tools/ToolRunner.java index ad1f80e8127d..fd5042e0d243 100644 --- a/test/unit/org/apache/cassandra/tools/ToolRunner.java +++ b/test/unit/org/apache/cassandra/tools/ToolRunner.java @@ -32,6 +32,8 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.BiFunction; +import java.util.function.Consumer; import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -48,7 +50,9 @@ import org.apache.cassandra.utils.Pair; import org.assertj.core.util.Lists; +import static com.github.jknack.handlebars.internal.lang3.ArrayUtils.isEmpty; import static org.assertj.core.api.Assertions.assertThat; + import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -191,6 +195,16 @@ public static ToolResult invokeNodetool(List args) return invoke(CQLTester.buildNodetoolArgs(args)); } + public static ToolResult invokeNodetool(Map env, String... args) + { + return invokeNodetool(env, Arrays.asList(args)); + } + + public static ToolResult invokeNodetool(Map env, List args) + { + return invoke(env, CQLTester.buildNodetoolArgs(args)); + } + public static ToolResult invoke(List args) { return invoke(args.toArray(new String[args.size()])); @@ -305,6 +319,27 @@ public NodeToolResult get() res.right.getException()); } + public static ToolRunner.ToolResult invokeNodetoolInJvm(BiFunction factory, String... commands) + { + LinesOutputStream out = new LinesOutputStream(logger::info); + LinesOutputStream err = new LinesOutputStream(logger::error); + List args = CQLTester.buildNodetoolArgs(isEmpty(commands) ? new ArrayList<>() : List.of(commands)); + args.remove("bin/nodetool"); + try + { + Object runner = factory.apply(new NodeProbeFactory(), new Output(new PrintStream(out), new PrintStream(err))); + Object result = runner.getClass().getMethod("execute", String[].class) + .invoke(runner, new Object[] { args.toArray(new String[0]) }); + assertTrue(result instanceof Integer); + return new ToolResult(args, (Integer) result, out.getOutput(), err.getOutput(), null); + } + catch (Exception e) + { + return new ToolResult(args, -1, out.getOutput(), + err.getOutput() + '\n' + Throwables.getStackTraceAsString(e), e); + } + } + public static Pair invokeSupplier(Supplier runMe) { return invokeSupplier(runMe, null); @@ -528,6 +563,43 @@ public AssertHelp errorContainsAny(String... messages) return this; } + public AssertHelp outputContains(String messages) + { + return outputContainsAny(messages); + } + + public AssertHelp outputContainsAny(String... messages) + { + assertThat(messages).hasSizeGreaterThan(0); + assertThat(stdout).isNotNull(); + if (!Stream.of(messages).anyMatch(stdout::contains)) + fail("stdout does not contain " + Arrays.toString(messages)); + return this; + } + + public AssertHelp outputMatches(String messages) + { + return outputMatchesAny(messages); + } + + public AssertHelp outputMatchesAny(String... messages) + { + assertThat(messages).hasSizeGreaterThan(0); + assertThat(stdout).isNotNull(); + if (!Stream.of(messages).anyMatch(stdout::matches)) + fail("stdout does not contain " + Arrays.toString(messages)); + return this; + } + + public AssertHelp outputLinesMatchesAll(String... messages) + { + assertThat(messages).hasSizeGreaterThan(0); + assertThat(stdout).isNotNull(); + if (!Stream.of(messages).allMatch((String msg) -> stdout.lines().anyMatch((String line) -> line.matches(msg)))) + fail("stdout does not match " + Arrays.toString(messages)); + return this; + } + private void fail(String msg) { StringBuilder sb = new StringBuilder(); @@ -739,4 +811,46 @@ public ToolResult invoke() return ToolRunner.invoke(env, stdin, args); } } + + private static class LinesOutputStream extends OutputStream + { + private final List outputLines = new ArrayList<>(); + private final StringBuilder buffer = new StringBuilder(); + private final Consumer logger; + + public LinesOutputStream(Consumer logger) + { + this.logger = logger; + } + + @Override + public void write(int b) + { + char c = (char) b; + if (c == '\n') + { + // Add the buffer to the list if it's a new line + outputLines.add(buffer.toString()); + logger.accept(buffer.toString()); + buffer.setLength(0); // Clear the buffer + } + else + buffer.append(c); + } + + public void flush() + { + if (buffer.length() > 0) + { + outputLines.add(buffer.toString()); + logger.accept(buffer.toString()); + buffer.setLength(0); + } + } + + public String getOutput() + { + return String.join("\n", outputLines); + } + } } diff --git a/test/unit/org/apache/cassandra/tools/nodetool/CQLToolRunnerTester.java b/test/unit/org/apache/cassandra/tools/nodetool/CQLToolRunnerTester.java new file mode 100644 index 000000000000..1ee337042d45 --- /dev/null +++ b/test/unit/org/apache/cassandra/tools/nodetool/CQLToolRunnerTester.java @@ -0,0 +1,139 @@ +/* + * 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.text.DecimalFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import org.junit.BeforeClass; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import com.github.difflib.DiffUtils; +import com.github.difflib.patch.AbstractDelta; +import com.github.difflib.patch.Patch; +import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.tools.NodeTool; +import org.apache.cassandra.tools.NodeToolV2; +import org.apache.cassandra.tools.ToolRunner; + +@RunWith(Parameterized.class) +public abstract class CQLToolRunnerTester extends CQLTester +{ + public static final Map runnersMap = Map.of( + "invokeNodetoolV1InJvm", CQLToolRunnerTester::invokeNodetoolV1InJvm, + "invokeNodetoolV2InJvm", CQLToolRunnerTester::invokeNodetoolV2InJvm); + + @Parameterized.Parameter + public String runner; + + @Parameterized.Parameters(name = "runner={0}") + public static Collection data() { + List res = new ArrayList<>(); + runnersMap.forEach((k, v) -> res.add(new Object[]{ k })); + return res; + } + + @BeforeClass + public static void setUpClass() + { + CQLTester.setUpClass(); + requireNetwork(); + try + { + startJMXServer(); + } + catch (Exception e) + { + throw new RuntimeException(e); + } + } + + protected ToolRunner.ToolResult invokeNodetool(String... args) + { + return runnersMap.get(runner).execute(args); + } + + public static ToolRunner.ToolResult invokeNodetoolV2InJvm(String... commands) + { + return ToolRunner.invokeNodetoolInJvm(NodeToolV2::new, commands); + } + + public static ToolRunner.ToolResult invokeNodetoolV1InJvm(String... commands) + { + return ToolRunner.invokeNodetoolInJvm(NodeTool::new, commands); + } + + public static List sliceStdout(ToolRunner.ToolResult result) + { + return Arrays.asList(result.getStdout().split("\\R")); + } + + public interface ToolHandler + { + ToolRunner.ToolResult execute(String... args); + default ToolRunner.ToolResult execute(List args) { return execute(args.toArray(new String[0])); } + } + + + protected static String printFormattedDiffsMessage(List stdoutOrig, + List stdoutNew, + String commandName, + String diff) + { + return '\n' + ">> source <<" + '\n' + + printFormattedNodeToolOutput(stdoutOrig) + + '\n' + ">> result <<" + + '\n' + printFormattedNodeToolOutput(stdoutNew) + + '\n' + " difference for \"" + commandName + "\":" + diff; + } + + protected static String printFormattedNodeToolOutput(List output) + { + StringBuilder sb = new StringBuilder(); + DecimalFormat df = new DecimalFormat("000"); + for(int i = 0; i < output.size(); i++) + { + sb.append(df.format(i)).append(':').append(output.get(i)); + if(i < output.size() - 1) + sb.append('\n'); + } + return sb.toString(); + } + + protected static String computeDiff(List original, List revised) { + Patch patch = DiffUtils.diff(original, revised); + List diffLines = new ArrayList<>(); + + for (AbstractDelta delta : patch.getDeltas()) { + for (String line : delta.getSource().getLines()) { + diffLines.add(delta.getType().toString().toLowerCase() + " source: " + line); + } + for (String line : delta.getTarget().getLines()) { + diffLines.add(delta.getType().toString().toLowerCase() + " result: " + line); + } + } + + return '\n' + String.join("\n", diffLines); + } +} diff --git a/test/unit/org/apache/cassandra/tools/nodetool/CompactTest.java b/test/unit/org/apache/cassandra/tools/nodetool/CompactTest.java index 928f8851e8a9..0f844ca247ba 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/CompactTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/CompactTest.java @@ -19,26 +19,15 @@ import java.util.Arrays; -import org.junit.BeforeClass; import org.junit.Test; -import org.apache.cassandra.cql3.CQLTester; import org.apache.cassandra.db.ColumnFamilyStore; import org.apache.cassandra.db.Keyspace; import org.apache.cassandra.dht.Murmur3Partitioner; import org.assertj.core.api.Assertions; -import static org.apache.cassandra.tools.ToolRunner.invokeNodetool; - -public class CompactTest extends CQLTester +public class CompactTest extends CQLToolRunnerTester { - @BeforeClass - public static void setup() throws Throwable - { - requireNetwork(); - startJMXServer(); - } - @Test public void keyPresent() 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 878433d702cb..528cd939dbcc 100644 --- a/test/unit/org/apache/cassandra/tools/nodetool/ForceCompactionTest.java +++ b/test/unit/org/apache/cassandra/tools/nodetool/ForceCompactionTest.java @@ -24,14 +24,10 @@ import java.util.Random; import java.util.concurrent.TimeUnit; -import org.apache.cassandra.Util; - import org.junit.Before; import org.junit.Test; -import static org.junit.Assert.*; - -import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.Util; import org.apache.cassandra.db.ColumnFamilyStore; import org.apache.cassandra.db.Keyspace; import org.apache.cassandra.db.rows.Cell; @@ -41,7 +37,11 @@ import org.apache.cassandra.io.sstable.ISSTableScanner; import org.apache.cassandra.io.sstable.format.SSTableReader; -public class ForceCompactionTest extends CQLTester +import static org.apache.commons.lang3.ArrayUtils.addAll; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ForceCompactionTest extends CQLToolRunnerTester { private final static int NUM_PARTITIONS = 10; private final static int NUM_ROWS = 100; @@ -241,7 +241,8 @@ private void forceCompact(String[] partitionKeysIgnoreGcGrace) if (cfs != null) { cfs.forceMajorCompaction(); - cfs.forceCompactionKeysIgnoringGcGrace(partitionKeysIgnoreGcGrace); + invokeNodetool(addAll(new String[]{ "forcecompact", cfs.keyspace.getName(), cfs.getTableName() }, + partitionKeysIgnoreGcGrace)).assertOnCleanExit(); } } diff --git a/test/unit/org/apache/cassandra/tools/nodetool/NodeToolCommandsMBeanTest.java b/test/unit/org/apache/cassandra/tools/nodetool/NodeToolCommandsMBeanTest.java new file mode 100644 index 000000000000..7379b1c67f8b --- /dev/null +++ b/test/unit/org/apache/cassandra/tools/nodetool/NodeToolCommandsMBeanTest.java @@ -0,0 +1,169 @@ +/* + * 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.HashMap; +import java.util.List; +import java.util.Map; +import javax.management.StandardMBean; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import org.apache.cassandra.db.compaction.CompactionManagerMBean; +import org.apache.cassandra.dht.Murmur3Partitioner; +import org.apache.cassandra.service.StorageServiceMBean; +import org.apache.cassandra.utils.MBeanWrapper; +import org.mockito.Mockito; + +import static org.mockito.Mockito.when; + +public class NodeToolCommandsMBeanTest extends CQLToolRunnerTester +{ + public static final String STORAGE_SERVICE_MBEAN = "org.apache.cassandra.db:type=StorageService"; + public static final String COMPACTION_MANAGER_MBEAN = "org.apache.cassandra.db:type=CompactionManager"; + private static final MBeanWrapper mbeanServer = MBeanWrapper.instance; + private MBeanMockHodler mbeanMockHodler; + + @Before + public void prepareMocks() + { + mbeanMockHodler = new MBeanMockHodler(); + mbeanMockHodler.unregisterAll(mbeanServer); + mbeanMockHodler.registerAll(mbeanServer); + } + + @After + public void unregisterMocks() + { + mbeanMockHodler.unregisterAll(mbeanServer); + } + + @Test + public void testAssassinate() + { + String ip = "10.20.113.11"; + StorageServiceMBean mock = mbeanMockHodler.getMock(STORAGE_SERVICE_MBEAN); + when(mock.getNonSystemKeyspaces()).thenReturn(List.of(keyspace())); + invokeNodetool("assassinate", ip).assertOnCleanExit(); + Mockito.verify(mock).assassinateEndpoint(ip); + } + + @Test + public void testAbortBootstrap() + { + String nodeId = "1"; + String ip = "10.20.113.11"; + StorageServiceMBean mock = mbeanMockHodler.getMock(STORAGE_SERVICE_MBEAN); + invokeNodetool("abortbootstrap", "--node", nodeId).assertOnCleanExit(); + Mockito.verify(mock).abortBootstrap(nodeId, ""); + invokeNodetool("abortbootstrap", "--ip", ip).assertOnCleanExit(); + Mockito.verify(mock).abortBootstrap("", ip); + } + + @Test + public void testCompactForceKeyspaceCompactionForPartitionKey() throws Throwable + { + long token = 42; + long key = Murmur3Partitioner.LongToken.keyForToken(token).getLong(); + String table = "table"; + StorageServiceMBean mock = mbeanMockHodler.getMock(STORAGE_SERVICE_MBEAN); + when(mock.getKeyspaces()).thenReturn(List.of(keyspace())); + when(mock.getNonSystemKeyspaces()).thenReturn(List.of(keyspace())); + invokeNodetool("compact", "--partition", Long.toString(key), keyspace(), table).assertOnCleanExit(); + Mockito.verify(mock).forceKeyspaceCompactionForPartitionKey(keyspace(), Long.toString(key), table); + } + + @Test + public void testCompactForceKeyspaceCompactionForTokenRange() throws Throwable + { + long token = 11; + long key = Murmur3Partitioner.LongToken.keyForToken(token).getLong(); + String startToken = Long.toString(key - 1); + String endToken = Long.toString(key + 1); + String table = "table"; + StorageServiceMBean mock = mbeanMockHodler.getMock(STORAGE_SERVICE_MBEAN); + when(mock.getKeyspaces()).thenReturn(List.of(keyspace())); + when(mock.getNonSystemKeyspaces()).thenReturn(List.of(keyspace())); + invokeNodetool("compact", "--start-token", startToken, "--end-token", endToken, keyspace(), table).assertOnCleanExit(); + Mockito.verify(mock).forceKeyspaceCompactionForTokenRange(keyspace(), startToken, endToken, table); + } + + @Test + public void testCompactForceKeyspaceCompaction() throws Throwable + { + String table = "table"; + StorageServiceMBean mock = mbeanMockHodler.getMock(STORAGE_SERVICE_MBEAN); + when(mock.getKeyspaces()).thenReturn(List.of(keyspace())); + when(mock.getNonSystemKeyspaces()).thenReturn(List.of(keyspace())); + invokeNodetool("compact", "--split-output", keyspace(), table).assertOnCleanExit(); + Mockito.verify(mock).forceKeyspaceCompaction(true, keyspace(), table); + } + + @Test + public void testCompactForceUserDefinedCompaction() throws Throwable + { + String[] ssTables = new String[] { "ssTable1", "ssTable2" }; + CompactionManagerMBean mock = mbeanMockHodler.getMock(COMPACTION_MANAGER_MBEAN); + invokeNodetool("compact", "--user-defined", ssTables[0], ssTables[1]).assertOnCleanExit(); + Mockito.verify(mock).forceUserDefinedCompaction(String.join(",", ssTables)); + } + + private static class MBeanMockHodler + { + private static final Map> mbeans = Map.of( + STORAGE_SERVICE_MBEAN, StorageServiceMBean.class, + COMPACTION_MANAGER_MBEAN, CompactionManagerMBean.class); + private final Map mocks = new HashMap<>(); + + MBeanMockHodler() + { + mbeans.forEach((name, clz) -> mocks.put(name, newMock(clz))); + } + + public static StandardMBean newMock(Class clz) + { + try + { + return new StandardMBean(Mockito.mock(clz), clz); + } + catch (Exception e) + { + throw new RuntimeException(e); + } + } + + @SuppressWarnings("unchecked") + public T getMock(String mBeanName) + { + return (T) mocks.get(mBeanName).getImplementation(); + } + + public void registerAll(MBeanWrapper mbeanMockInstance) + { + mocks.forEach((name, mock) -> mbeanMockInstance.registerMBean(mock, name)); + } + + public void unregisterAll(MBeanWrapper mbeanMockInstance) + { + mocks.keySet().forEach(name -> mbeanMockInstance.unregisterMBean(name, MBeanWrapper.OnException.IGNORE)); + } + } +} diff --git a/test/unit/org/apache/cassandra/tools/nodetool/NodeToolHelpCommandTest.java b/test/unit/org/apache/cassandra/tools/nodetool/NodeToolHelpCommandTest.java new file mode 100644 index 000000000000..4e37b088a2d6 --- /dev/null +++ b/test/unit/org/apache/cassandra/tools/nodetool/NodeToolHelpCommandTest.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.tools.nodetool; + +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.stream.Stream; + +import org.apache.commons.lang3.StringUtils; +import org.junit.Test; +import org.junit.runners.Parameterized; + +import static org.junit.Assert.assertTrue; + +public class NodeToolHelpCommandTest extends CQLToolRunnerTester +{ + private static final String NODETOOL_COMMAND_HELP_DIR = "nodetool/help"; + private static final List COMMANDS = List.of("abortbootstrap", "assassinate", "forcecompact", "compact"); + + @Parameterized.Parameter(1) + public String command; + + @Parameterized.Parameters(name = "runner={0}, command={1}") + public static Collection data() + { + List res = new ArrayList<>(); + for (String tool : runnersMap.keySet()) + for (String command : COMMANDS) + res.add(new Object[]{ tool, command }); + return res; + } + + @Test + public void testCompareCommandHelpOutputBetweenTools() throws Exception + { + compareCommandHelpOutput(command); + } + + private void compareCommandHelpOutput(String commandName) throws Exception + { + List origLines = readCommandHelpLines(commandName); + List targetLines = sliceStdout(invokeNodetool("help", commandName)); + String diff = computeDiff(targetLines, origLines); + assertTrue(printFormattedDiffsMessage(origLines, targetLines, commandName, diff), + StringUtils.isBlank(diff)); + } + + private static List readCommandHelpLines(String command) throws Exception + { + List lines = new ArrayList<>(); + try (Stream stream = Files.lines( + Paths.get(NodeToolHelpCommandTest.class.getClassLoader() + .getResource(NODETOOL_COMMAND_HELP_DIR + '/' + command) + .toURI()))) + { + stream.forEach(lines::add); + } + return lines; + } +} diff --git a/test/unit/org/apache/cassandra/tools/nodetool/NodeToolMessageTest.java b/test/unit/org/apache/cassandra/tools/nodetool/NodeToolMessageTest.java new file mode 100644 index 000000000000..6002e0f33544 --- /dev/null +++ b/test/unit/org/apache/cassandra/tools/nodetool/NodeToolMessageTest.java @@ -0,0 +1,47 @@ +/* + * 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.List; + +import org.apache.commons.lang3.StringUtils; +import org.junit.Test; + +import static org.junit.Assert.assertTrue; + +public class NodeToolMessageTest extends CQLToolRunnerTester +{ + @Test + public void testCompareHelpCommand() + { + List outNodeTool = sliceStdout(invokeNodetoolV1InJvm("help")); + List outNodeToolV2 = sliceStdout(invokeNodetoolV2InJvm("help")); + + String diff = computeDiff(outNodeTool, outNodeToolV2); + assertTrue(printFormattedDiffsMessage(outNodeTool, outNodeToolV2, "help", diff), + StringUtils.isBlank(diff)); + } + + @Test + public void testBaseCommandOutput() + { + List outNodeToolV2 = sliceStdout(invokeNodetoolV1InJvm("help", "abortbootstrap")); + System.out.println(printFormattedNodeToolOutput(outNodeToolV2)); + } +} diff --git a/test/unit/org/apache/cassandra/tools/nodetool/VersionTest.java b/test/unit/org/apache/cassandra/tools/nodetool/VersionTest.java new file mode 100644 index 000000000000..2e064b5291b0 --- /dev/null +++ b/test/unit/org/apache/cassandra/tools/nodetool/VersionTest.java @@ -0,0 +1,100 @@ +/* + * 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.Test; + +public class VersionTest extends CQLToolRunnerTester +{ + private static final String SUBCOMMAND = "version"; + + @Test + public void testHelp() + { + String help = """ + NAME + nodetool version - Print cassandra version + + SYNOPSIS + nodetool [(-h | --host )] [(-p | --port )] + [(-pp | --print-port)] [(-pw | --password )] + [(-pwf | --password-file )] + [(-u | --username )] version [(-v | --verbose)] + + OPTIONS + -h , --host + Node hostname or ip address + + -p , --port + Remote jmx agent port number + + -pp, --print-port + Operate in 4.0 mode with hosts disambiguated by port number + + -pw , --password + Remote jmx agent password + + -pwf , --password-file + Path to the JMX password file + + -u , --username + Remote jmx agent username + + -v, --verbose + Include addtitional information + """; + + invokeNodetool("help", SUBCOMMAND) + .asserts() + .success() + .outputContains(help); + } + + @Test + public void testOutput() + { + invokeNodetool(SUBCOMMAND) + .asserts() + .success() + .outputMatches("ReleaseVersion: \\d+.*"); + } + + @Test + public void testVerboseOutput1() + { + invokeNodetool(SUBCOMMAND, "-v") + .asserts() + .success() + .outputLinesMatchesAll( + "ReleaseVersion: \\d+.*", + "GitSHA: [0-9a-f]{40}.*" + ); + } + + @Test + public void testVerboseOutput2() + { + invokeNodetool(SUBCOMMAND, "--verbose") + .asserts() + .success() + .outputLinesMatchesAll( + "ReleaseVersion: \\d+.*", + "GitSHA: [0-9a-f]{40}.*" + ); + } +}