From 9c93d52ac9aa422dcc3c3d40cb090467ee02e953 Mon Sep 17 00:00:00 2001 From: Stefan Miklosovic Date: Tue, 19 Aug 2025 17:12:27 +0200 Subject: [PATCH] Implement nodetool history patch by Stefan Miklosovic; reviewed by Maxim Muzafarov for CASSANDRA-20851 --- CHANGES.txt | 1 + .../org/apache/cassandra/tools/NodeTool.java | 6 +- .../cassandra/tools/nodetool/History.java | 95 ++++++++++++++ .../tools/nodetool/NodetoolCommand.java | 1 + test/resources/nodetool/help/history | 31 +++++ test/resources/nodetool/help/nodetool | 1 + .../cassandra/tools/nodetool/HistoryTest.java | 120 ++++++++++++++++++ 7 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 src/java/org/apache/cassandra/tools/nodetool/History.java create mode 100644 test/resources/nodetool/help/history create mode 100644 test/unit/org/apache/cassandra/tools/nodetool/HistoryTest.java diff --git a/CHANGES.txt b/CHANGES.txt index 32c874119b..f04688c99c 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,5 @@ 5.1 + * Implement nodetool history (CASSANDRA-20851) * Expose StorageService.dropPreparedStatements via JMX (CASSANDRA-20870) * Expose Metric for Prepared Statement Cache Size (in bytes) (CASSANDRA-20864) * Add support for BEGIN TRANSACTION to allow mutations that touch multiple partitions (CASSANDRA-20857) diff --git a/src/java/org/apache/cassandra/tools/NodeTool.java b/src/java/org/apache/cassandra/tools/NodeTool.java index 0244b49596..eefdbc2614 100644 --- a/src/java/org/apache/cassandra/tools/NodeTool.java +++ b/src/java/org/apache/cassandra/tools/NodeTool.java @@ -133,7 +133,7 @@ public class NodeTool String cmdLine = Joiner.on(" ").skipNulls().join(args); cmdLine = cmdLine.replaceFirst("(?<=(-pw|--password))\\s+\\S+", " "); - try (FileWriter writer = new File(FBUtilities.getToolsOutputDirectory(), HISTORYFILE).newWriter(APPEND)) + try (FileWriter writer = getHistoryFile().newWriter(APPEND)) { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss,SSS"); writer.append(sdf.format(new Date())).append(": ").append(cmdLine).append(System.lineSeparator()); @@ -144,6 +144,10 @@ public class NodeTool } } + public static File getHistoryFile() + { + return new File(FBUtilities.getToolsOutputDirectory(), HISTORYFILE); + } public static List getCommandsWithoutRoot(String separator) { diff --git a/src/java/org/apache/cassandra/tools/nodetool/History.java b/src/java/org/apache/cassandra/tools/nodetool/History.java new file mode 100644 index 0000000000..3f7d502161 --- /dev/null +++ b/src/java/org/apache/cassandra/tools/nodetool/History.java @@ -0,0 +1,95 @@ +/* + * 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.cassandra.io.util.File; +import org.apache.cassandra.io.util.FileUtils; +import org.apache.cassandra.tools.NodeProbe; +import org.apache.cassandra.tools.NodeTool; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command(name = "history", description = "Print previously executed nodetool commands") +public class History extends AbstractCommand +{ + @Option(paramLabel = "commands", + names = { "-n", "--num", "--number-of-commands" }, + description = "Number of commands to print, defaults to 1000.") + public int commands = 1000; + + @Override + protected void execute(NodeProbe probe) + { + if (commands < 1) + throw new IllegalArgumentException("Number of commands to display has to be at least 1."); + + File historyFile = getHistoryFile(); + validateHistoryFile(historyFile); + + for (String line : commandsToPrint(historyFile)) + output.out.println(line); + } + + @Override + protected boolean shouldConnect() throws CommandLine.ExecutionException + { + return false; + } + + File getHistoryFile() + { + return NodeTool.getHistoryFile(); + } + + List commandsToPrint(File file) + { + List historyCommands = FileUtils.readLines(file); + int size = historyCommands.size(); + List commandLines; + + if (commands > size) + commandLines = historyCommands; + else + commandLines = historyCommands.subList(size - commands, size); + + return commandLines; + } + + /** + * Nodetool is appending command to history file before it is executed so us checking on its + * existence and validating it is not technically necessary however nodetool is also swallowing + * all errors when it was not succesful in appending to the history file so better to check here in that case. + * + * @param historyFile file to check that it is actually a file which exists and it is readable + */ + void validateHistoryFile(File historyFile) + { + if (!historyFile.exists()) + throw new IllegalStateException(String.format("History file %s does not exist.%n", historyFile.absolutePath())); + + if (!historyFile.isFile()) + throw new IllegalStateException(String.format("History file %s is not a file.%n", historyFile.absolutePath())); + + if (!historyFile.isReadable()) + throw new IllegalStateException(String.format("History file %s is not readable.%n", historyFile.absolutePath())); + } +} diff --git a/src/java/org/apache/cassandra/tools/nodetool/NodetoolCommand.java b/src/java/org/apache/cassandra/tools/nodetool/NodetoolCommand.java index 88b86a20b1..c20c6936fc 100644 --- a/src/java/org/apache/cassandra/tools/nodetool/NodetoolCommand.java +++ b/src/java/org/apache/cassandra/tools/nodetool/NodetoolCommand.java @@ -128,6 +128,7 @@ import static org.apache.cassandra.tools.nodetool.Help.printTopCommandUsage; GossipInfo.class, GuardrailsConfigCommand.GetGuardrailsConfig.class, GuardrailsConfigCommand.SetGuardrailsConfig.class, + History.class, Import.class, Info.class, InvalidateCIDRPermissionsCache.class, diff --git a/test/resources/nodetool/help/history b/test/resources/nodetool/help/history new file mode 100644 index 0000000000..80bfe85a70 --- /dev/null +++ b/test/resources/nodetool/help/history @@ -0,0 +1,31 @@ +NAME + nodetool history - Print previously executed nodetool commands + +SYNOPSIS + nodetool [(-h | --host )] [(-p | --port )] + [(-pp | --print-port)] [(-pw | --password )] + [(-pwf | --password-file )] + [(-u | --username )] history + [(-n | --number-of-commands )] + +OPTIONS + -h , --host + Node hostname or ip address + + -n , --num , --number-of-commands + Number of commands to print, defaults to 1000. + + -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/nodetool b/test/resources/nodetool/help/nodetool index 3162f4e3cc..0430083056 100644 --- a/test/resources/nodetool/help/nodetool +++ b/test/resources/nodetool/help/nodetool @@ -77,6 +77,7 @@ The most commonly used nodetool commands are: gettraceprobability Print the current trace probability value gossipinfo Shows the gossip information for the cluster help Display help information + history Print previously executed nodetool commands import Import new SSTables to the system info Print node information (uptime, load, ...) invalidatecidrpermissionscache Invalidate the cidr permissions cache diff --git a/test/unit/org/apache/cassandra/tools/nodetool/HistoryTest.java b/test/unit/org/apache/cassandra/tools/nodetool/HistoryTest.java new file mode 100644 index 0000000000..cb5243a631 --- /dev/null +++ b/test/unit/org/apache/cassandra/tools/nodetool/HistoryTest.java @@ -0,0 +1,120 @@ +/* + * 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.junit.BeforeClass; +import org.junit.Test; + +import org.apache.cassandra.io.util.File; +import org.apache.cassandra.io.util.FileUtils; +import org.apache.cassandra.tools.Output; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class HistoryTest +{ + public static File tempHistoryFile = FileUtils.createTempFile("test_nodetool", "history"); + public static File emptyHistoryFile = FileUtils.createTempFile("test_nodetool_empty", "history"); + public static List listOfCommands = List.of("oldest command", + "older command", + "newer command", + "newest command"); + + @BeforeClass + public static void populateHistoryFile() + { + FileUtils.write(tempHistoryFile, listOfCommands); + } + + @Test + public void testHistory() + { + History history = new History() + { + @Override + File getHistoryFile() + { + return HistoryTest.tempHistoryFile; + } + }; + + history.logger(Output.CONSOLE); + File historyFile = history.getHistoryFile(); + + assertThat(history.commandsToPrint(historyFile)).containsExactly(listOfCommands.toArray(new String[0])); + + history.commands = 2; + assertThat(history.commandsToPrint(historyFile)).containsExactly(listOfCommands.subList(2, listOfCommands.size()).toArray(new String[0])); + + history.commands = 4; + assertThat(history.commandsToPrint(historyFile)).containsExactly(listOfCommands.toArray(new String[0])); + } + + @Test + public void testEmptyHistory() + { + History history = new History() + { + @Override + File getHistoryFile() + { + return HistoryTest.emptyHistoryFile; + } + }; + + history.logger(Output.CONSOLE); + File historyFile = history.getHistoryFile(); + + assertThat(history.commandsToPrint(historyFile)).isEmpty(); + } + + + @Test + public void testHistoryOutputOnNonExistingHistoryFile() + { + History history = new History() + { + @Override + File getHistoryFile() + { + return new File("/does/not/exist"); + } + }; + + history.logger(Output.CONSOLE); + + assertThatThrownBy(() -> history.execute(null)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("History file /does/not/exist does not exist.\n"); + } + + @Test + public void testInvalidNumberOfCommandsToPrint() + { + History history = new History(); + history.commands = 0; + + assertThatThrownBy(() -> history.execute(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Number of commands to display has to be at least 1."); + } +}