You are viewing a plain text version of this content. The canonical link for it is here.
Posted to pr@cassandra.apache.org by "nvharikrishna (via GitHub)" <gi...@apache.org> on 2025/05/08 14:45:23 UTC
[PR] CASSANDRA-19151 Adding offline tool for cluster metadata [cassandra]
nvharikrishna opened a new pull request, #4149:
URL: https://github.com/apache/cassandra/pull/4149
Added an offline tool to print or update cluste metadata. Following commands have been added to it.
* addtocms Makes a node as CMS member
* assigntokens Assigns a token for given instance
* forcejoin Forces a node to move to JOINED stated
* forgetnode Removes a nodes from given cluster metadata
* help Display help information
* printdataplacements Prints data placements in cluster medata file
* printdirectory Prints directory information in cluster metadata file
The [CASSANDRA-19151](https://issues.apache.org/jira/projects/CASSANDRA/issues/CASSANDRA-19151)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org
Re: [PR] CASSANDRA-19151 Adding offline tool for cluster metadata [cassandra]
Posted by "nvharikrishna (via GitHub)" <gi...@apache.org>.
nvharikrishna commented on code in PR #4149:
URL: https://github.com/apache/cassandra/pull/4149#discussion_r2869278935
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ipAddress;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ metadata = makeCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata makeCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ DataPlacement.Builder builder = metadata.placements.get(metaParams).unbuild();
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ builder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+ return metadata.transformer().with(metadata.placements.unbuild().with(metaParams,
+ builder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ @Command(name = "assigntokens", description = "Assigns a token for given instance")
+ public static class AssignTokens extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ip;
+
+ @Option(name = { "-t", "--token" }, description = "Token to assign. Pass it multiple times to assign multiple tokens to node.", required = true)
+ private List<String> tokenList = new ArrayList<>();
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ip);
+ NodeId nodeId = metadata.directory.peerId(nodeAddress);
+ if (nodeId == null)
+ {
+ throw new IllegalArgumentException("Cassandra node with address " + ip + " does not exist.");
+ }
+
+ Token.TokenFactory tokenFactory = metadata.partitioner.getTokenFactory();
+ List<Token> tokens = tokenList.stream().map(tokenFactory::fromString).collect(Collectors.toList());
+ ClusterMetadata updateMetadata = metadata.transformer().proposeToken(nodeId, tokens).build().metadata;
Review Comment:
done
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ipAddress;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ metadata = makeCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata makeCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ DataPlacement.Builder builder = metadata.placements.get(metaParams).unbuild();
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ builder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+ return metadata.transformer().with(metadata.placements.unbuild().with(metaParams,
+ builder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ @Command(name = "assigntokens", description = "Assigns a token for given instance")
+ public static class AssignTokens extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ip;
+
+ @Option(name = { "-t", "--token" }, description = "Token to assign. Pass it multiple times to assign multiple tokens to node.", required = true)
+ private List<String> tokenList = new ArrayList<>();
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ip);
+ NodeId nodeId = metadata.directory.peerId(nodeAddress);
+ if (nodeId == null)
+ {
+ throw new IllegalArgumentException("Cassandra node with address " + ip + " does not exist.");
+ }
+
+ Token.TokenFactory tokenFactory = metadata.partitioner.getTokenFactory();
+ List<Token> tokens = tokenList.stream().map(tokenFactory::fromString).collect(Collectors.toList());
+ ClusterMetadata updateMetadata = metadata.transformer().proposeToken(nodeId, tokens).build().metadata;
+ writeMetadata(output, updateMetadata, outputFilePath);
+ }
+ }
+
+ @Command(name = "describe", description = "Describes the cluster metadata")
+ public static class Describe extends ClusterMetadataToolCmd
+ {
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
Review Comment:
Added a separate 'print' command to print the metadata.
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ipAddress;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ metadata = makeCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata makeCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ DataPlacement.Builder builder = metadata.placements.get(metaParams).unbuild();
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ builder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+ return metadata.transformer().with(metadata.placements.unbuild().with(metaParams,
+ builder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ @Command(name = "assigntokens", description = "Assigns a token for given instance")
+ public static class AssignTokens extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ip;
+
+ @Option(name = { "-t", "--token" }, description = "Token to assign. Pass it multiple times to assign multiple tokens to node.", required = true)
+ private List<String> tokenList = new ArrayList<>();
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ip);
+ NodeId nodeId = metadata.directory.peerId(nodeAddress);
+ if (nodeId == null)
+ {
+ throw new IllegalArgumentException("Cassandra node with address " + ip + " does not exist.");
+ }
+
+ Token.TokenFactory tokenFactory = metadata.partitioner.getTokenFactory();
+ List<Token> tokens = tokenList.stream().map(tokenFactory::fromString).collect(Collectors.toList());
+ ClusterMetadata updateMetadata = metadata.transformer().proposeToken(nodeId, tokens).build().metadata;
+ writeMetadata(output, updateMetadata, outputFilePath);
+ }
+ }
+
+ @Command(name = "describe", description = "Describes the cluster metadata")
+ public static class Describe extends ClusterMetadataToolCmd
+ {
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ String members = metadata.fullCMSMembers().stream().sorted().map(Object::toString).collect(Collectors.joining(","));
+ output.out.printf("Cluster Metadata Service:%n");
+ output.out.printf("Members: %s%n", members);
+ output.out.printf("Needs reconfiguration: %s%n", needsReconfiguration(metadata));
+ output.out.printf("Service State: %s%n", ClusterMetadataService.state(metadata));
+ output.out.printf("Epoch: %s%n", metadata.epoch.getEpoch());
+ output.out.printf("Replication factor: %s%n", ReplicationParams.meta(metadata).toString());
+ }
+ }
+
+ @Command(name = "forcejoin", description = "Forces a node to move to JOINED stated")
+ public static class ForceJoin extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-id", "--node-id" }, description = "Node ID. It can be integer ID assigned to node or the node uuid", required = true)
+ private String id;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = NodeId.fromString(id);
+
+ if (!metadata.directory.peerIds().contains(nodeId))
+ {
+ throw new IllegalArgumentException("Node with id " + id + " does not exist.");
+ }
+
+ ClusterMetadata updatedMetadata = metadata.transformer().join(nodeId).build().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ @Command(name = "forgetnode", description = "Removes a nodes from given cluster metadata")
+ public static class ForgetNode extends ClusterMetadataToolCmd
Review Comment:
Removed this command and introduced assassinate. For joining/replacing nodes, recommending abortbootstrap (new command).
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
Review Comment:
> but I think we should use this version to deserialize the partitioner.
done
> but I'm not sure whether the best default is NodeVersion.CURRENT.serializationVersion() or the one read from the input file. wdyt?
Using version in metadata. If someone need to change the version then it can be passed explicitly to the tool
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ipAddress;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ metadata = makeCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata makeCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ DataPlacement.Builder builder = metadata.placements.get(metaParams).unbuild();
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ builder.withReadReplica(metadata.epoch, newCMS)
Review Comment:
Changed it to ResetCMS, which initializes the CMS member to the given node.
##########
test/unit/org/apache/cassandra/tools/CMSOfflineToolTest.java:
##########
@@ -0,0 +1,688 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Collections;
+import java.util.Map;
+import java.util.UUID;
+
+import com.google.common.collect.ImmutableMap;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Murmur3Partitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.DistributedSchema;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.KeyspaceParams;
+import org.apache.cassandra.schema.Keyspaces;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.schema.SchemaConstants;
+import org.apache.cassandra.service.accord.AccordFastPath;
+import org.apache.cassandra.service.accord.AccordStaleReplicas;
+import org.apache.cassandra.service.consensus.migration.ConsensusMigrationState;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.Epoch;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.DataPlacements;
+import org.apache.cassandra.tcm.ownership.TokenMap;
+import org.apache.cassandra.tcm.sequences.InProgressSequences;
+import org.apache.cassandra.tcm.sequences.LockedRanges;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.utils.CassandraVersion;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+public class CMSOfflineToolTest extends OfflineToolUtils
+{
+
+ @Rule
+ public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Test
+ public void testDefaultCmd()
+ {
+
+ ToolRunner.ToolResult tool = ToolRunner.invokeClass(CMSOfflineTool.class);
+
+ tool.assertOnCleanExit();
+ assertThat(tool.getExitCode()).isZero();
+ assertThat(tool.getStdout()).contains("usage");
+ assertThat(tool.getStderr()).isEmpty();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testRunCommandThatDoesNotExist()
+ {
+ ToolRunner.ToolResult tool = ToolRunner.invokeClass(CMSOfflineTool.class, "cmddoesnotexist");
+ assertThat(tool.getExitCode()).isOne();
+ }
+
+ @Test
+ public void testRunCommandWithMetadataFileThatDoesnotExist()
+ {
+ String metadataFile = temporaryFolder.getRoot().getAbsolutePath() + "/file-does-not-exists.dump";
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdirectory",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).isEqualTo(2);
+ }
+
+ @Test
+ public void testaddToCMS() throws IOException
+ {
+ assertAddToCMS("127.0.0.1:7000");
+ }
+
+ @Test
+ public void testaddToCMSIpAddressAlone() throws IOException
+ {
+ assertAddToCMS("127.0.0.1");
+ }
+
+ @Test
+ public void testaddToCMSNodeThatDoesNotExistEarlier() throws IOException
+ {
+ // Node that doesn't exist in Cluster Metadata can be added as CMS member
+ assertAddToCMS("127.0.0.55:7000");
+ }
+
+ private void assertAddToCMS(String ipAddress) throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "addtocms",
+ "-f",
+ metadataFile,
+ "-ip",
+ ipAddress,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+
+ // Check given ip address is added to CMS members
+ InetAddressAndPort candidate = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ assertThat(outMetadata.isCMSMember(candidate)).isTrue();
+
+ // Check existing nodes retained as CMS members
+ ClusterMetadata inMetadata = deserializeMetadata(metadataFile);
+ for (InetAddressAndPort existingMember : inMetadata.fullCMSMembers())
+ {
+ assertThat(inMetadata.isCMSMember(existingMember)).isTrue();
+ }
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testaddToCMSInvalidIpAddress() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidIpAddress = "/127.0.0.";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "addtocms",
+ "-f",
+ metadataFile,
+ "-ip",
+ invalidIpAddress,
+ "-o",
+ outputFile);
+
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getExitCode()).isEqualTo(2);
+ assertThat(result.getStderr()).contains("java.net.UnknownHostException");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testaddToCMSInvalidSerializationVersion() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidIpAddress = "127.0.0.3";
+ String serializationVersion = "-1";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "addtocms",
+ "-f",
+ metadataFile,
+ "-sv",
+ serializationVersion,
+ "-ip",
+ invalidIpAddress,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(1);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("cmsofflinetool: serializationVersion: can not convert \"" +
+ serializationVersion + "\" to a Version");
+ }
+
+ @Test
+ public void testAssignTokens() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.4:7000";
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token token1ToAssign = metadata.partitioner.getRandomToken();
+ Token token2ToAssign = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assigntokens",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ String.valueOf(token1ToAssign.getTokenValue()),
+ "--token",
+ String.valueOf(token2ToAssign.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+ assertThat(outMetadata.directory.peerId(newNodeInetAddress)).isNotNull();
+ assertThat(outMetadata.tokenMap.tokens(newNodeId)).contains(token1ToAssign)
+ .contains(token2ToAssign);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssignTokensForNonExistingNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String unknownNodeIpWithPort = "127.0.0.5:7000";
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token tokenToAssign = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assigntokens",
+ "-f",
+ metadataFile,
+ "-ip",
+ unknownNodeIpWithPort,
+ "-t",
+ String.valueOf(tokenToAssign.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).contains(" Cassandra node with address " + unknownNodeIpWithPort
+ + " does not exist.");
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssignTokensInvalidIpAddress() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String invalidIp = "127.0.0.";
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token tokenToAssign = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assigntokens",
+ "-f",
+ metadataFile,
+ "-ip",
+ invalidIp,
+ "-t",
+ String.valueOf(tokenToAssign.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).contains("java.net.UnknownHostException");
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssignTokensInvalidToken() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0.1:7000";
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidToken = "somegibberishinvalidtoken";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assigntokens",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ invalidToken,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoin() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.4:7000";
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ assertThat(metadata.directory.states.get(newNodeId)).isNotEqualTo(NodeState.JOINED);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+ assertThat(outMetadata.directory.peerId(newNodeInetAddress)).isNotNull();
+ assertThat(outMetadata.directory.states.get(newNodeId)).isEqualTo(NodeState.JOINED);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinNodeThatDoesNotExist() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = "-1";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).isNotEmpty();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForgetNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+
+ String joinedNodeIpWithPort = "127.0.0.4:7000";
+ InetAddressAndPort joiningNodeInetAddress = InetAddressAndPort.getByNameUnchecked(joinedNodeIpWithPort);
+ NodeId joinedNodeId = NodeId.fromUUID(new UUID(0, 4L));
+
+ Directory newDirectory = metadata.directory
+ .with(joinedNodeId,
+ new NodeAddresses(joiningNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT)
+ .withNodeState(joinedNodeId, NodeState.JOINED);
+
+ TokenMap tokenMap = new TokenMap(metadata.partitioner)
+ .assignTokens(joinedNodeId, Collections.singleton(metadata.partitioner.getRandomToken()));
+
+ metadata = updateMetadata(metadata, newDirectory);
+ metadata = updateMetadata(metadata, tokenMap);
+
+ assertThat(metadata.tokenMap.tokens(joinedNodeId)).isNotEmpty();
+
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forgetnode",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(joinedNodeId.id()),
+ "-o",
+ outputFile);
+
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+ assertThat(outMetadata.directory.peerIds()).doesNotContain(joinedNodeId);
+ assertThat(outMetadata.tokenMap.tokens(joinedNodeId)).isEmpty();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForgetNodeInvalidNodeId() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(-1);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forgetnode",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Node with id " + nodeId + " does not exist.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testPrintDataPlacements() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String keyspaceName = "ks1";
+ KeyspaceParams ksParams = KeyspaceParams.create(true,
+ Map.of("class", "NetworkTopologyStrategy",
+ "datacenter1", "3"));
+ Keyspaces keyspaces = Keyspaces.none().with(KeyspaceMetadata.create(keyspaceName, ksParams));
+ DistributedSchema newSchema = new DistributedSchema(keyspaces);
+ metadata = updateMetadata(metadata, newSchema);
+
+ InetAddressAndPort inetAddressAndPort = InetAddressAndPort.getByNameUnchecked("127.0.0.1:7000");
+ InetAddressAndPort i2 = InetAddressAndPort.getByNameUnchecked("127.0.0.2:7000");
+ InetAddressAndPort i3 = InetAddressAndPort.getByNameUnchecked("127.0.0.3:7000");
+
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(metadata.partitioner);
+
+ DataPlacement dataPlacement = DataPlacement.builder()
+ .withWriteReplica(Epoch.FIRST, Replica.fullReplica(inetAddressAndPort, new Range<>(metadata.partitioner.getMinimumToken(), metadata.partitioner.getTokenFactory().fromString("0"))))
+ .withWriteReplica(Epoch.FIRST, Replica.fullReplica(i2, new Range<>(metadata.partitioner.getMinimumToken(), metadata.partitioner.getTokenFactory().fromString("0"))))
Review Comment:
done
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ipAddress;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ metadata = makeCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata makeCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ DataPlacement.Builder builder = metadata.placements.get(metaParams).unbuild();
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ builder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+ return metadata.transformer().with(metadata.placements.unbuild().with(metaParams,
+ builder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ @Command(name = "assigntokens", description = "Assigns a token for given instance")
+ public static class AssignTokens extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ip;
+
+ @Option(name = { "-t", "--token" }, description = "Token to assign. Pass it multiple times to assign multiple tokens to node.", required = true)
+ private List<String> tokenList = new ArrayList<>();
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ip);
+ NodeId nodeId = metadata.directory.peerId(nodeAddress);
+ if (nodeId == null)
+ {
+ throw new IllegalArgumentException("Cassandra node with address " + ip + " does not exist.");
+ }
+
+ Token.TokenFactory tokenFactory = metadata.partitioner.getTokenFactory();
+ List<Token> tokens = tokenList.stream().map(tokenFactory::fromString).collect(Collectors.toList());
+ ClusterMetadata updateMetadata = metadata.transformer().proposeToken(nodeId, tokens).build().metadata;
Review Comment:
Changed it to 'move' token and applied Move transformation. Added token validation too.
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ipAddress;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ metadata = makeCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata makeCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ DataPlacement.Builder builder = metadata.placements.get(metaParams).unbuild();
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ builder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+ return metadata.transformer().with(metadata.placements.unbuild().with(metaParams,
+ builder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ @Command(name = "assigntokens", description = "Assigns a token for given instance")
+ public static class AssignTokens extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ip;
+
+ @Option(name = { "-t", "--token" }, description = "Token to assign. Pass it multiple times to assign multiple tokens to node.", required = true)
+ private List<String> tokenList = new ArrayList<>();
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ip);
+ NodeId nodeId = metadata.directory.peerId(nodeAddress);
+ if (nodeId == null)
+ {
+ throw new IllegalArgumentException("Cassandra node with address " + ip + " does not exist.");
+ }
+
+ Token.TokenFactory tokenFactory = metadata.partitioner.getTokenFactory();
+ List<Token> tokens = tokenList.stream().map(tokenFactory::fromString).collect(Collectors.toList());
+ ClusterMetadata updateMetadata = metadata.transformer().proposeToken(nodeId, tokens).build().metadata;
+ writeMetadata(output, updateMetadata, outputFilePath);
+ }
+ }
+
+ @Command(name = "describe", description = "Describes the cluster metadata")
+ public static class Describe extends ClusterMetadataToolCmd
+ {
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ String members = metadata.fullCMSMembers().stream().sorted().map(Object::toString).collect(Collectors.joining(","));
+ output.out.printf("Cluster Metadata Service:%n");
+ output.out.printf("Members: %s%n", members);
+ output.out.printf("Needs reconfiguration: %s%n", needsReconfiguration(metadata));
+ output.out.printf("Service State: %s%n", ClusterMetadataService.state(metadata));
+ output.out.printf("Epoch: %s%n", metadata.epoch.getEpoch());
+ output.out.printf("Replication factor: %s%n", ReplicationParams.meta(metadata).toString());
+ }
+ }
+
+ @Command(name = "forcejoin", description = "Forces a node to move to JOINED stated")
Review Comment:
Made changes to use UnsafeJoin transformation. Also checking the in-progress sequences.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org
Re: [PR] CASSANDRA-19151 Adding offline tool for cluster metadata [cassandra]
Posted by "nvharikrishna (via GitHub)" <gi...@apache.org>.
nvharikrishna commented on code in PR #4149:
URL: https://github.com/apache/cassandra/pull/4149#discussion_r2913723350
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
+ if (serializationVersion != null)
+ {
+ // Not a good idea to write metadata using lower serialization version. Ref: CASSANDRA-21174
+ if (currentVersion.isBefore(serializationVersion))
+ {
+ throw new IllegalArgumentException("Given serialization version " + serializationVersion +
+ " is newer compared to current version " + currentVersion + '.');
+ }
+
+ return serializationVersion;
+ }
+
+ Version metadataVersion = metadata.directory.commonSerializationVersion;
+ if (!currentVersion.isAtLeast(metadataVersion))
+ {
+ throw new IllegalArgumentException("Current version " + currentVersion +
+ " is older than version in cluster metadata (" +
+ metadataVersion + "). Cannot proceed further. " +
+ "Try modifying cluster metadata using binaries that support " +
+ "minimum serialization version: " + metadataVersion + '.');
+ }
+
+ return metadataVersion;
+ }
+ }
+
+ /**
+ * Cancels a JOIN or REPLACE bootstrap sequence for the given node and unregisters it
+ * from the cluster. Use this when a node is stuck in bootstrapping or replacement.
+ * Fails if no in-progress sequence exists, or if the sequence is not of kind JOIN or REPLACE.
+ */
+ @Command(name = "abortbootstrap",
+ description = "Aborts bootstrap for given node if in progress.")
+ public static class AbortBootstrap extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated cluster metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ if (!metadata.inProgressSequences.contains(nodeId))
+ {
+ throw new IllegalArgumentException("Did not find any sequences in progress for node " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (sequenceKind != MultiStepOperation.Kind.JOIN
+ && sequenceKind != MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("abortbootstrap is not a valid operation when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+
+ // Cancelling the sequence is not enough, but we need to unregister as well
+ Unregister unregister = new Unregister(nodeId, EnumSet.of(NodeState.REGISTERED),
+ ClusterMetadataService.instance().placementProvider());
+
+ updatedMetadata = unregister.execute(updatedMetadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress MOVE sequence for the given node, returning it to its
+ * pre-move token assignment. Fails if no in-progress sequence exists, or if the
+ * sequence is not of kind MOVE.
+ */
+ @Command(name = "abortmove", description = "Aborts in progress move sequence for given node.")
+ static class AbortMove extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.MOVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort move.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress LEAVE (decommission) sequence for the given node, keeping
+ * it as an active member of the ring. Fails if no in-progress sequence exists, or if
+ * the sequence is not of kind LEAVE.
+ */
+ @Command(name = "abortdecommission", description = "Aborts in progress decommission sequence for given node.")
+ static class AbortDecommission extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.LEAVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort decommission.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Removes a node from cluster metadata by applying the {@link Assassinate} transformation.
+ * If a MOVE or LEAVE sequence is in progress for the node, it is cancelled first.
+ * If the node is a CMS member, it is removed from CMS before assassination.
+ * Fails if the node is in a JOIN or REPLACE sequence; use {@code abortbootstrap} instead.
+ */
+ @Command(name = "assassinate", description = "Assassinates given node from Cluster metadata.")
+ static class AssassinateNode extends ClusterMetadataToolCmd
+ {
+ private final EnumSet<MultiStepOperation.Kind> supportedCancelSequences =
+ EnumSet.of(MultiStepOperation.Kind.MOVE, MultiStepOperation.Kind.LEAVE);
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ // Check if there are any in-progress sequences for given node
+ // If any, then cancel the sequence and then assassinate it
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (!supportedCancelSequences.contains(sequenceKind))
+ {
+ if (sequenceKind == MultiStepOperation.Kind.JOIN || sequenceKind == MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
Review Comment:
Assassinate is internally calling PrepareLeave, which checks if the node is in the JOINED state ([ref](https://github.com/apache/cassandra/blob/trunk/src/java/org/apache/cassandra/tcm/transformations/PrepareLeave.java#L92)). After cancelling the in-progress BootstrapAndJoin/BootstrapAndReplace sequence, node is coming to the REGISTERED state. Assassinate is failing as it is not in JOINED state. We just need to unregister it. So, suggesting to run `abortbootstrap` instead of `assassinate`.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org
Re: [PR] CASSANDRA-19151 Adding offline tool for cluster metadata [cassandra]
Posted by "ifesdjeen (via GitHub)" <gi...@apache.org>.
ifesdjeen commented on code in PR #4149:
URL: https://github.com/apache/cassandra/pull/4149#discussion_r2229212171
##########
src/java/org/apache/cassandra/tools/nodetool/CMSAdmin.java:
##########
@@ -269,4 +271,76 @@ public void execute(NodeProbe probe)
probe.getCMSOperationsProxy().resumeDropAccordTable(tableId);
}
}
+
+ @Command(name = "dumpclustermetadata", description = "Dumps Cluster Metadata into a file")
+ public static class DumpClusterMetadata extends NodeTool.NodeToolCmd
+ {
+
+ @Option(title = "Epoch", name = { "-e", "--epoch" }, required = false,
+ description = "Epoch at which cluster metadata should be dumped")
+ private Long epoch;
+
+ @Option(title = "Transform Epoch", name = { "-te", "--transform-epoch" }, required = false,
+ description = "The epoch to which the cluster meta data should be transformed before dumping")
+ private Long transformEpoch;
+
+ @Option(title = "Searialization Version", name = { "-sv", "--serialization-version" }, required = false,
Review Comment:
typo in word Searialization
##########
src/java/org/apache/cassandra/tools/nodetool/CMSAdmin.java:
##########
@@ -269,4 +271,76 @@ public void execute(NodeProbe probe)
probe.getCMSOperationsProxy().resumeDropAccordTable(tableId);
}
}
+
+ @Command(name = "dumpclustermetadata", description = "Dumps Cluster Metadata into a file")
+ public static class DumpClusterMetadata extends NodeTool.NodeToolCmd
+ {
+
+ @Option(title = "Epoch", name = { "-e", "--epoch" }, required = false,
+ description = "Epoch at which cluster metadata should be dumped")
+ private Long epoch;
+
+ @Option(title = "Transform Epoch", name = { "-te", "--transform-epoch" }, required = false,
+ description = "The epoch to which the cluster meta data should be transformed before dumping")
Review Comment:
maybe something like "force metadata to X epoch while dumping"
##########
src/java/org/apache/cassandra/tools/nodetool/CMSAdmin.java:
##########
@@ -269,4 +271,76 @@ public void execute(NodeProbe probe)
probe.getCMSOperationsProxy().resumeDropAccordTable(tableId);
}
}
+
+ @Command(name = "dumpclustermetadata", description = "Dumps Cluster Metadata into a file")
Review Comment:
nit: since this is already gated by CMS admin, I would just use `dump_metadata` here maybe?
##########
src/java/org/apache/cassandra/tools/nodetool/CMSAdmin.java:
##########
@@ -269,4 +271,76 @@ public void execute(NodeProbe probe)
probe.getCMSOperationsProxy().resumeDropAccordTable(tableId);
}
}
+
+ @Command(name = "dumpclustermetadata", description = "Dumps Cluster Metadata into a file")
+ public static class DumpClusterMetadata extends NodeTool.NodeToolCmd
+ {
+
+ @Option(title = "Epoch", name = { "-e", "--epoch" }, required = false,
+ description = "Epoch at which cluster metadata should be dumped")
+ private Long epoch;
+
+ @Option(title = "Transform Epoch", name = { "-te", "--transform-epoch" }, required = false,
+ description = "The epoch to which the cluster meta data should be transformed before dumping")
+ private Long transformEpoch;
+
+ @Option(title = "Searialization Version", name = { "-sv", "--serialization-version" }, required = false,
+ description = "Searialization Version")
Review Comment:
typo in Searialization
##########
src/java/org/apache/cassandra/tools/nodetool/CMSAdmin.java:
##########
@@ -269,4 +271,76 @@ public void execute(NodeProbe probe)
probe.getCMSOperationsProxy().resumeDropAccordTable(tableId);
}
}
+
+ @Command(name = "dumpclustermetadata", description = "Dumps Cluster Metadata into a file")
+ public static class DumpClusterMetadata extends NodeTool.NodeToolCmd
+ {
+
+ @Option(title = "Epoch", name = { "-e", "--epoch" }, required = false,
+ description = "Epoch at which cluster metadata should be dumped")
+ private Long epoch;
+
+ @Option(title = "Transform Epoch", name = { "-te", "--transform-epoch" }, required = false,
+ description = "The epoch to which the cluster meta data should be transformed before dumping")
+ private Long transformEpoch;
+
+ @Option(title = "Searialization Version", name = { "-sv", "--serialization-version" }, required = false,
+ description = "Searialization Version")
+ private Version version;
+
+ protected void execute(NodeProbe probe)
+ {
+ if (epoch == null && transformEpoch == null && version == null)
+ {
+ try
+ {
+ String fileLocation = probe.getCMSOperationsProxy().dumpClusterMetadata();
+ printCMSDumpLocation(probe, fileLocation);
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+ else if (epoch != null && transformEpoch != null && version != null)
+ {
+ try
+ {
+ String fileLocation = probe.getCMSOperationsProxy().dumpClusterMetadata(epoch,
+ transformEpoch,
+ version.name());
+ printCMSDumpLocation(probe, fileLocation);
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+ else
+ {
+ List<String> invalidArgs = new ArrayList<>(2);
+ if (null == epoch)
+ {
Review Comment:
nit: unnecessary brackets
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,434 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+
Review Comment:
nit: extra line
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,434 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of node to make CMS", required = true)
+ private String ipAddress;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ metadata = makeCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata makeCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ DataPlacement.Builder builder = metadata.placements.get(metaParams).unbuild();
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ builder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+ return metadata.transformer().with(metadata.placements.unbuild().with(metaParams,
+ builder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ @Command(name = "assigntokens", description = "Assigns a token for given instance")
+ public static class AssignTokens extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of endpoint. Port can be specified as well.", required = true)
Review Comment:
nit: describe how to specify port, i.e. with semicolon, for completenesss
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org
Re: [PR] CASSANDRA-19151 Adding offline tool for cluster metadata [cassandra]
Posted by "smiklosovic (via GitHub)" <gi...@apache.org>.
smiklosovic commented on PR #4149:
URL: https://github.com/apache/cassandra/pull/4149#issuecomment-3139513343
the patch needs to be rewritten to use picocli instead of airline
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org
Re: [PR] CASSANDRA-19151 Adding offline tool for cluster metadata [cassandra]
Posted by "nvharikrishna (via GitHub)" <gi...@apache.org>.
nvharikrishna commented on code in PR #4149:
URL: https://github.com/apache/cassandra/pull/4149#discussion_r2913723350
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
+ if (serializationVersion != null)
+ {
+ // Not a good idea to write metadata using lower serialization version. Ref: CASSANDRA-21174
+ if (currentVersion.isBefore(serializationVersion))
+ {
+ throw new IllegalArgumentException("Given serialization version " + serializationVersion +
+ " is newer compared to current version " + currentVersion + '.');
+ }
+
+ return serializationVersion;
+ }
+
+ Version metadataVersion = metadata.directory.commonSerializationVersion;
+ if (!currentVersion.isAtLeast(metadataVersion))
+ {
+ throw new IllegalArgumentException("Current version " + currentVersion +
+ " is older than version in cluster metadata (" +
+ metadataVersion + "). Cannot proceed further. " +
+ "Try modifying cluster metadata using binaries that support " +
+ "minimum serialization version: " + metadataVersion + '.');
+ }
+
+ return metadataVersion;
+ }
+ }
+
+ /**
+ * Cancels a JOIN or REPLACE bootstrap sequence for the given node and unregisters it
+ * from the cluster. Use this when a node is stuck in bootstrapping or replacement.
+ * Fails if no in-progress sequence exists, or if the sequence is not of kind JOIN or REPLACE.
+ */
+ @Command(name = "abortbootstrap",
+ description = "Aborts bootstrap for given node if in progress.")
+ public static class AbortBootstrap extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated cluster metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ if (!metadata.inProgressSequences.contains(nodeId))
+ {
+ throw new IllegalArgumentException("Did not find any sequences in progress for node " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (sequenceKind != MultiStepOperation.Kind.JOIN
+ && sequenceKind != MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("abortbootstrap is not a valid operation when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+
+ // Cancelling the sequence is not enough, but we need to unregister as well
+ Unregister unregister = new Unregister(nodeId, EnumSet.of(NodeState.REGISTERED),
+ ClusterMetadataService.instance().placementProvider());
+
+ updatedMetadata = unregister.execute(updatedMetadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress MOVE sequence for the given node, returning it to its
+ * pre-move token assignment. Fails if no in-progress sequence exists, or if the
+ * sequence is not of kind MOVE.
+ */
+ @Command(name = "abortmove", description = "Aborts in progress move sequence for given node.")
+ static class AbortMove extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.MOVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort move.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress LEAVE (decommission) sequence for the given node, keeping
+ * it as an active member of the ring. Fails if no in-progress sequence exists, or if
+ * the sequence is not of kind LEAVE.
+ */
+ @Command(name = "abortdecommission", description = "Aborts in progress decommission sequence for given node.")
+ static class AbortDecommission extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.LEAVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort decommission.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Removes a node from cluster metadata by applying the {@link Assassinate} transformation.
+ * If a MOVE or LEAVE sequence is in progress for the node, it is cancelled first.
+ * If the node is a CMS member, it is removed from CMS before assassination.
+ * Fails if the node is in a JOIN or REPLACE sequence; use {@code abortbootstrap} instead.
+ */
+ @Command(name = "assassinate", description = "Assassinates given node from Cluster metadata.")
+ static class AssassinateNode extends ClusterMetadataToolCmd
+ {
+ private final EnumSet<MultiStepOperation.Kind> supportedCancelSequences =
+ EnumSet.of(MultiStepOperation.Kind.MOVE, MultiStepOperation.Kind.LEAVE);
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ // Check if there are any in-progress sequences for given node
+ // If any, then cancel the sequence and then assassinate it
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (!supportedCancelSequences.contains(sequenceKind))
+ {
+ if (sequenceKind == MultiStepOperation.Kind.JOIN || sequenceKind == MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
Review Comment:
Assassinate is internally calling PrepareLeave, which checks if the node is in the JOINED state ([ref](https://github.com/apache/cassandra/blob/trunk/src/java/org/apache/cassandra/tcm/transformations/PrepareLeave.java#L92)). After cancelling the in-progress BootstrapAndJoin/BootstrapAndReplace sequence, node comes to the REGISTERED state. We just need to unregister it. So, suggesting to run `abortbootstrap` instead of `assassinate`.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org
Re: [PR] CASSANDRA-19151 Adding offline tool for cluster metadata [cassandra]
Posted by "nvharikrishna (via GitHub)" <gi...@apache.org>.
nvharikrishna commented on code in PR #4149:
URL: https://github.com/apache/cassandra/pull/4149#discussion_r3104999054
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
+ if (serializationVersion != null)
+ {
+ // Not a good idea to write metadata using lower serialization version. Ref: CASSANDRA-21174
+ if (currentVersion.isBefore(serializationVersion))
+ {
+ throw new IllegalArgumentException("Given serialization version " + serializationVersion +
+ " is newer compared to current version " + currentVersion + '.');
+ }
+
+ return serializationVersion;
+ }
+
+ Version metadataVersion = metadata.directory.commonSerializationVersion;
+ if (!currentVersion.isAtLeast(metadataVersion))
+ {
+ throw new IllegalArgumentException("Current version " + currentVersion +
+ " is older than version in cluster metadata (" +
+ metadataVersion + "). Cannot proceed further. " +
+ "Try modifying cluster metadata using binaries that support " +
+ "minimum serialization version: " + metadataVersion + '.');
+ }
+
+ return metadataVersion;
+ }
+ }
+
+ /**
+ * Cancels a JOIN or REPLACE bootstrap sequence for the given node and unregisters it
+ * from the cluster. Use this when a node is stuck in bootstrapping or replacement.
+ * Fails if no in-progress sequence exists, or if the sequence is not of kind JOIN or REPLACE.
+ */
+ @Command(name = "abortbootstrap",
+ description = "Aborts bootstrap for given node if in progress.")
+ public static class AbortBootstrap extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated cluster metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ if (!metadata.inProgressSequences.contains(nodeId))
+ {
+ throw new IllegalArgumentException("Did not find any sequences in progress for node " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (sequenceKind != MultiStepOperation.Kind.JOIN
+ && sequenceKind != MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("abortbootstrap is not a valid operation when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+
+ // Cancelling the sequence is not enough, but we need to unregister as well
+ Unregister unregister = new Unregister(nodeId, EnumSet.of(NodeState.REGISTERED),
+ ClusterMetadataService.instance().placementProvider());
+
+ updatedMetadata = unregister.execute(updatedMetadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress MOVE sequence for the given node, returning it to its
+ * pre-move token assignment. Fails if no in-progress sequence exists, or if the
+ * sequence is not of kind MOVE.
+ */
+ @Command(name = "abortmove", description = "Aborts in progress move sequence for given node.")
+ static class AbortMove extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.MOVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort move.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress LEAVE (decommission) sequence for the given node, keeping
+ * it as an active member of the ring. Fails if no in-progress sequence exists, or if
+ * the sequence is not of kind LEAVE.
+ */
+ @Command(name = "abortdecommission", description = "Aborts in progress decommission sequence for given node.")
+ static class AbortDecommission extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.LEAVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort decommission.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Removes a node from cluster metadata by applying the {@link Assassinate} transformation.
+ * If a MOVE or LEAVE sequence is in progress for the node, it is cancelled first.
+ * If the node is a CMS member, it is removed from CMS before assassination.
+ * Fails if the node is in a JOIN or REPLACE sequence; use {@code abortbootstrap} instead.
+ */
+ @Command(name = "assassinate", description = "Assassinates given node from Cluster metadata.")
+ static class AssassinateNode extends ClusterMetadataToolCmd
+ {
+ private final EnumSet<MultiStepOperation.Kind> supportedCancelSequences =
+ EnumSet.of(MultiStepOperation.Kind.MOVE, MultiStepOperation.Kind.LEAVE);
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ // Check if there are any in-progress sequences for given node
+ // If any, then cancel the sequence and then assassinate it
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (!supportedCancelSequences.contains(sequenceKind))
+ {
+ if (sequenceKind == MultiStepOperation.Kind.JOIN || sequenceKind == MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
+ sequenceKind + " is in progress. " +
+ "Run abortbootstrap instead.");
+ }
+ else
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+ }
+
+ output.out.printf("Cancelling in-progress sequence of kind %s before assassinating node.\n",
+ metadata.inProgressSequences.get(nodeId).kind());
+ metadata = new CancelInProgressSequence(nodeId).execute(metadata).success().metadata;
+ }
+
+ metadata = maybeReconfigureCMS(metadata, nodeId);
+ Assassinate transformation = new Assassinate(nodeId, ClusterMetadataService.instance().placementProvider());
+ ClusterMetadata updatedMetadata = transformation.execute(metadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+
+ ClusterMetadata maybeReconfigureCMS(ClusterMetadata metadata, NodeId nodeId)
+ {
+ InetAddressAndPort addressAndPort = metadata.directory.endpoint(nodeId);
+ if (!metadata.isCMSMember(addressAndPort))
+ {
+ return metadata;
+ }
+
+ // Ref: org.apache.cassandra.tcm.sequences.ReconfigureCMS.maybeReconfigureCMS
+ PrepareCMSReconfiguration.Simple transformation = new PrepareCMSReconfiguration.Simple(nodeId, Set.of());
+ ClusterMetadata updatedMetadata = transformation.execute(metadata).success().metadata;
+ updatedMetadata = updatedMetadata.inProgressSequences.get(ReconfigureCMS.SequenceKey.instance)
+ .applyTo(updatedMetadata).success().metadata;
+ if (updatedMetadata.isCMSMember(addressAndPort))
+ {
+ throw new IllegalStateException("Could not remove node " + nodeIdentifierOption.getNodeIpOrId() +
+ " from CMS.");
+ }
+ return updatedMetadata;
+ }
+ }
+
+ /**
+ * Replaces the entire CMS membership with a single node. All existing CMS replicas are
+ * removed and the specified node becomes the sole CMS member. Useful for recovering from
+ * a state where the CMS is unreachable.
+ */
+ @Command(name = "resetcms",
+ description = "Replaces all CMS members with the specified node. WARNING: all existing CMS replicas are removed.")
+ static class ResetCMS extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ public void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ InetAddressAndPort nodeAddress = metadata.directory.getNodeAddresses(nodeId).broadcastAddress;
+ metadata = resetCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata resetCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ Iterable<Replica> currentReplicas = metadata.placements.get(metaParams).writes.byEndpoint().flattenValues();
+ DataPlacement.Builder placementBuilder = metadata.placements.get(metaParams).unbuild();
+ for (Replica replica : currentReplicas)
+ {
+ placementBuilder.withoutReadReplica(metadata.epoch, replica)
+ .withoutWriteReplica(metadata.epoch, replica);
+ }
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ placementBuilder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+
+ return metadata.transformer()
+ .with(metadata.placements.unbuild()
+ .with(metaParams, placementBuilder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ /**
+ * Moves a node to a new token. Only supports single-token nodes.
+ * If a MOVE sequence is already in progress for the node, it is completed rather than started fresh;
+ * in that case the provided token must match the token of the in-progress sequence.
+ */
+ @Command(name = "move",
+ description = "Moves node to given token. Works only for cluster having nodes with single token.")
+ static class MoveToken extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-t", "--token" },
+ description = "Token to assign.")
+ private String token;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ // Took the reference from org.apache.cassandra.tcm.sequences.SingleNodeSequences.move
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ ClusterMetadata updatedMetadata = finishInProgressSequence(nodeId, token, metadata);
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ return;
+ }
+
+ if (null == token)
+ {
+ throw new IllegalArgumentException("Token required when no MOVE sequence is in progress.");
+ }
+ metadata.partitioner.getTokenFactory().validate(token);
+ Token toToken = metadata.partitioner.getTokenFactory().fromString(token);
+ if (metadata.tokenMap.tokens().contains(toToken))
+ {
+ throw new IllegalArgumentException("Target token " + toToken + " is already owned by another node.");
Review Comment:
Made changes to include id of node which owns the token.
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
+ if (serializationVersion != null)
+ {
+ // Not a good idea to write metadata using lower serialization version. Ref: CASSANDRA-21174
+ if (currentVersion.isBefore(serializationVersion))
+ {
+ throw new IllegalArgumentException("Given serialization version " + serializationVersion +
+ " is newer compared to current version " + currentVersion + '.');
+ }
+
+ return serializationVersion;
+ }
+
+ Version metadataVersion = metadata.directory.commonSerializationVersion;
+ if (!currentVersion.isAtLeast(metadataVersion))
+ {
+ throw new IllegalArgumentException("Current version " + currentVersion +
+ " is older than version in cluster metadata (" +
+ metadataVersion + "). Cannot proceed further. " +
+ "Try modifying cluster metadata using binaries that support " +
+ "minimum serialization version: " + metadataVersion + '.');
+ }
+
+ return metadataVersion;
+ }
+ }
+
+ /**
+ * Cancels a JOIN or REPLACE bootstrap sequence for the given node and unregisters it
+ * from the cluster. Use this when a node is stuck in bootstrapping or replacement.
+ * Fails if no in-progress sequence exists, or if the sequence is not of kind JOIN or REPLACE.
+ */
+ @Command(name = "abortbootstrap",
+ description = "Aborts bootstrap for given node if in progress.")
+ public static class AbortBootstrap extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated cluster metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ if (!metadata.inProgressSequences.contains(nodeId))
+ {
+ throw new IllegalArgumentException("Did not find any sequences in progress for node " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (sequenceKind != MultiStepOperation.Kind.JOIN
+ && sequenceKind != MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("abortbootstrap is not a valid operation when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+
+ // Cancelling the sequence is not enough, but we need to unregister as well
+ Unregister unregister = new Unregister(nodeId, EnumSet.of(NodeState.REGISTERED),
+ ClusterMetadataService.instance().placementProvider());
+
+ updatedMetadata = unregister.execute(updatedMetadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress MOVE sequence for the given node, returning it to its
+ * pre-move token assignment. Fails if no in-progress sequence exists, or if the
+ * sequence is not of kind MOVE.
+ */
+ @Command(name = "abortmove", description = "Aborts in progress move sequence for given node.")
+ static class AbortMove extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.MOVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort move.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress LEAVE (decommission) sequence for the given node, keeping
+ * it as an active member of the ring. Fails if no in-progress sequence exists, or if
+ * the sequence is not of kind LEAVE.
+ */
+ @Command(name = "abortdecommission", description = "Aborts in progress decommission sequence for given node.")
+ static class AbortDecommission extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.LEAVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort decommission.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Removes a node from cluster metadata by applying the {@link Assassinate} transformation.
+ * If a MOVE or LEAVE sequence is in progress for the node, it is cancelled first.
+ * If the node is a CMS member, it is removed from CMS before assassination.
+ * Fails if the node is in a JOIN or REPLACE sequence; use {@code abortbootstrap} instead.
+ */
+ @Command(name = "assassinate", description = "Assassinates given node from Cluster metadata.")
+ static class AssassinateNode extends ClusterMetadataToolCmd
+ {
+ private final EnumSet<MultiStepOperation.Kind> supportedCancelSequences =
+ EnumSet.of(MultiStepOperation.Kind.MOVE, MultiStepOperation.Kind.LEAVE);
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ // Check if there are any in-progress sequences for given node
+ // If any, then cancel the sequence and then assassinate it
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (!supportedCancelSequences.contains(sequenceKind))
+ {
+ if (sequenceKind == MultiStepOperation.Kind.JOIN || sequenceKind == MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
+ sequenceKind + " is in progress. " +
+ "Run abortbootstrap instead.");
+ }
+ else
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+ }
+
+ output.out.printf("Cancelling in-progress sequence of kind %s before assassinating node.\n",
+ metadata.inProgressSequences.get(nodeId).kind());
+ metadata = new CancelInProgressSequence(nodeId).execute(metadata).success().metadata;
+ }
+
+ metadata = maybeReconfigureCMS(metadata, nodeId);
+ Assassinate transformation = new Assassinate(nodeId, ClusterMetadataService.instance().placementProvider());
+ ClusterMetadata updatedMetadata = transformation.execute(metadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+
+ ClusterMetadata maybeReconfigureCMS(ClusterMetadata metadata, NodeId nodeId)
+ {
+ InetAddressAndPort addressAndPort = metadata.directory.endpoint(nodeId);
+ if (!metadata.isCMSMember(addressAndPort))
+ {
+ return metadata;
+ }
+
+ // Ref: org.apache.cassandra.tcm.sequences.ReconfigureCMS.maybeReconfigureCMS
+ PrepareCMSReconfiguration.Simple transformation = new PrepareCMSReconfiguration.Simple(nodeId, Set.of());
+ ClusterMetadata updatedMetadata = transformation.execute(metadata).success().metadata;
+ updatedMetadata = updatedMetadata.inProgressSequences.get(ReconfigureCMS.SequenceKey.instance)
+ .applyTo(updatedMetadata).success().metadata;
+ if (updatedMetadata.isCMSMember(addressAndPort))
+ {
+ throw new IllegalStateException("Could not remove node " + nodeIdentifierOption.getNodeIpOrId() +
+ " from CMS.");
+ }
+ return updatedMetadata;
+ }
+ }
+
+ /**
+ * Replaces the entire CMS membership with a single node. All existing CMS replicas are
+ * removed and the specified node becomes the sole CMS member. Useful for recovering from
+ * a state where the CMS is unreachable.
+ */
+ @Command(name = "resetcms",
+ description = "Replaces all CMS members with the specified node. WARNING: all existing CMS replicas are removed.")
+ static class ResetCMS extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ public void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ InetAddressAndPort nodeAddress = metadata.directory.getNodeAddresses(nodeId).broadcastAddress;
+ metadata = resetCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata resetCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
Review Comment:
done
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
Review Comment:
Addressed in below comment.
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
+ if (serializationVersion != null)
+ {
+ // Not a good idea to write metadata using lower serialization version. Ref: CASSANDRA-21174
+ if (currentVersion.isBefore(serializationVersion))
+ {
+ throw new IllegalArgumentException("Given serialization version " + serializationVersion +
+ " is newer compared to current version " + currentVersion + '.');
+ }
+
+ return serializationVersion;
+ }
+
+ Version metadataVersion = metadata.directory.commonSerializationVersion;
+ if (!currentVersion.isAtLeast(metadataVersion))
+ {
+ throw new IllegalArgumentException("Current version " + currentVersion +
+ " is older than version in cluster metadata (" +
+ metadataVersion + "). Cannot proceed further. " +
+ "Try modifying cluster metadata using binaries that support " +
+ "minimum serialization version: " + metadataVersion + '.');
+ }
+
+ return metadataVersion;
+ }
+ }
+
+ /**
+ * Cancels a JOIN or REPLACE bootstrap sequence for the given node and unregisters it
+ * from the cluster. Use this when a node is stuck in bootstrapping or replacement.
+ * Fails if no in-progress sequence exists, or if the sequence is not of kind JOIN or REPLACE.
Review Comment:
`abortbootstrap` performs unregister transformation too, which is not required for abortmove or abortdecommission. Introduced a new abstract base class that will be used by abort move/decommission/bootstrap commands to reduce the duplicated code, yet remains as individual commands.
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
+ if (serializationVersion != null)
+ {
+ // Not a good idea to write metadata using lower serialization version. Ref: CASSANDRA-21174
Review Comment:
I wanted to do this:
1. By default, use the serialization version used in the given cluster metadata.
2. If the user has specified the serialization version, then it will take precedence.
a. If the user’s specified version is less than the metadata version, then just dropping a warning message.
3. The current version should be compatible with whatever the version finalized after step 2. It should not be before the finalized version.
Sorry for the confusion. Made changes it to make it more readable.
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
+ if (serializationVersion != null)
+ {
+ // Not a good idea to write metadata using lower serialization version. Ref: CASSANDRA-21174
+ if (currentVersion.isBefore(serializationVersion))
+ {
+ throw new IllegalArgumentException("Given serialization version " + serializationVersion +
+ " is newer compared to current version " + currentVersion + '.');
+ }
+
+ return serializationVersion;
+ }
+
+ Version metadataVersion = metadata.directory.commonSerializationVersion;
+ if (!currentVersion.isAtLeast(metadataVersion))
+ {
+ throw new IllegalArgumentException("Current version " + currentVersion +
+ " is older than version in cluster metadata (" +
+ metadataVersion + "). Cannot proceed further. " +
+ "Try modifying cluster metadata using binaries that support " +
+ "minimum serialization version: " + metadataVersion + '.');
+ }
+
+ return metadataVersion;
+ }
+ }
+
+ /**
+ * Cancels a JOIN or REPLACE bootstrap sequence for the given node and unregisters it
+ * from the cluster. Use this when a node is stuck in bootstrapping or replacement.
+ * Fails if no in-progress sequence exists, or if the sequence is not of kind JOIN or REPLACE.
+ */
+ @Command(name = "abortbootstrap",
+ description = "Aborts bootstrap for given node if in progress.")
+ public static class AbortBootstrap extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated cluster metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ if (!metadata.inProgressSequences.contains(nodeId))
+ {
+ throw new IllegalArgumentException("Did not find any sequences in progress for node " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (sequenceKind != MultiStepOperation.Kind.JOIN
+ && sequenceKind != MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("abortbootstrap is not a valid operation when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+
+ // Cancelling the sequence is not enough, but we need to unregister as well
+ Unregister unregister = new Unregister(nodeId, EnumSet.of(NodeState.REGISTERED),
+ ClusterMetadataService.instance().placementProvider());
+
+ updatedMetadata = unregister.execute(updatedMetadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress MOVE sequence for the given node, returning it to its
+ * pre-move token assignment. Fails if no in-progress sequence exists, or if the
+ * sequence is not of kind MOVE.
+ */
+ @Command(name = "abortmove", description = "Aborts in progress move sequence for given node.")
+ static class AbortMove extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.MOVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort move.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress LEAVE (decommission) sequence for the given node, keeping
+ * it as an active member of the ring. Fails if no in-progress sequence exists, or if
+ * the sequence is not of kind LEAVE.
+ */
+ @Command(name = "abortdecommission", description = "Aborts in progress decommission sequence for given node.")
+ static class AbortDecommission extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.LEAVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort decommission.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Removes a node from cluster metadata by applying the {@link Assassinate} transformation.
+ * If a MOVE or LEAVE sequence is in progress for the node, it is cancelled first.
+ * If the node is a CMS member, it is removed from CMS before assassination.
+ * Fails if the node is in a JOIN or REPLACE sequence; use {@code abortbootstrap} instead.
+ */
+ @Command(name = "assassinate", description = "Assassinates given node from Cluster metadata.")
+ static class AssassinateNode extends ClusterMetadataToolCmd
+ {
+ private final EnumSet<MultiStepOperation.Kind> supportedCancelSequences =
+ EnumSet.of(MultiStepOperation.Kind.MOVE, MultiStepOperation.Kind.LEAVE);
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ // Check if there are any in-progress sequences for given node
+ // If any, then cancel the sequence and then assassinate it
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (!supportedCancelSequences.contains(sequenceKind))
+ {
+ if (sequenceKind == MultiStepOperation.Kind.JOIN || sequenceKind == MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
+ sequenceKind + " is in progress. " +
+ "Run abortbootstrap instead.");
+ }
+ else
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+ }
+
+ output.out.printf("Cancelling in-progress sequence of kind %s before assassinating node.\n",
+ metadata.inProgressSequences.get(nodeId).kind());
+ metadata = new CancelInProgressSequence(nodeId).execute(metadata).success().metadata;
+ }
+
+ metadata = maybeReconfigureCMS(metadata, nodeId);
+ Assassinate transformation = new Assassinate(nodeId, ClusterMetadataService.instance().placementProvider());
+ ClusterMetadata updatedMetadata = transformation.execute(metadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+
+ ClusterMetadata maybeReconfigureCMS(ClusterMetadata metadata, NodeId nodeId)
+ {
+ InetAddressAndPort addressAndPort = metadata.directory.endpoint(nodeId);
+ if (!metadata.isCMSMember(addressAndPort))
+ {
+ return metadata;
+ }
+
+ // Ref: org.apache.cassandra.tcm.sequences.ReconfigureCMS.maybeReconfigureCMS
+ PrepareCMSReconfiguration.Simple transformation = new PrepareCMSReconfiguration.Simple(nodeId, Set.of());
+ ClusterMetadata updatedMetadata = transformation.execute(metadata).success().metadata;
+ updatedMetadata = updatedMetadata.inProgressSequences.get(ReconfigureCMS.SequenceKey.instance)
+ .applyTo(updatedMetadata).success().metadata;
+ if (updatedMetadata.isCMSMember(addressAndPort))
+ {
+ throw new IllegalStateException("Could not remove node " + nodeIdentifierOption.getNodeIpOrId() +
+ " from CMS.");
+ }
+ return updatedMetadata;
+ }
+ }
+
+ /**
+ * Replaces the entire CMS membership with a single node. All existing CMS replicas are
+ * removed and the specified node becomes the sole CMS member. Useful for recovering from
+ * a state where the CMS is unreachable.
+ */
+ @Command(name = "resetcms",
+ description = "Replaces all CMS members with the specified node. WARNING: all existing CMS replicas are removed.")
+ static class ResetCMS extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ public void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ InetAddressAndPort nodeAddress = metadata.directory.getNodeAddresses(nodeId).broadcastAddress;
+ metadata = resetCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata resetCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ Iterable<Replica> currentReplicas = metadata.placements.get(metaParams).writes.byEndpoint().flattenValues();
+ DataPlacement.Builder placementBuilder = metadata.placements.get(metaParams).unbuild();
+ for (Replica replica : currentReplicas)
+ {
+ placementBuilder.withoutReadReplica(metadata.epoch, replica)
+ .withoutWriteReplica(metadata.epoch, replica);
+ }
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ placementBuilder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+
+ return metadata.transformer()
+ .with(metadata.placements.unbuild()
+ .with(metaParams, placementBuilder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ /**
+ * Moves a node to a new token. Only supports single-token nodes.
+ * If a MOVE sequence is already in progress for the node, it is completed rather than started fresh;
+ * in that case the provided token must match the token of the in-progress sequence.
+ */
+ @Command(name = "move",
+ description = "Moves node to given token. Works only for cluster having nodes with single token.")
+ static class MoveToken extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-t", "--token" },
+ description = "Token to assign.")
+ private String token;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ // Took the reference from org.apache.cassandra.tcm.sequences.SingleNodeSequences.move
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ ClusterMetadata updatedMetadata = finishInProgressSequence(nodeId, token, metadata);
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ return;
+ }
+
+ if (null == token)
+ {
+ throw new IllegalArgumentException("Token required when no MOVE sequence is in progress.");
+ }
+ metadata.partitioner.getTokenFactory().validate(token);
+ Token toToken = metadata.partitioner.getTokenFactory().fromString(token);
+ if (metadata.tokenMap.tokens().contains(toToken))
+ {
+ throw new IllegalArgumentException("Target token " + toToken + " is already owned by another node.");
+ }
+ if (metadata.tokenMap.tokens(nodeId).size() > 1)
+ {
+ throw new UnsupportedOperationException("This node has more than one token and cannot be moved thusly.");
+ }
+
+ PrepareMove prepareMove = new PrepareMove(nodeId, Set.of(toToken), new UniformRangePlacement(), false);
+ ClusterMetadata updatedMetadata = prepareMove.execute(metadata).success().metadata;
+ updatedMetadata = updatedMetadata.inProgressSequences.get(nodeId).applyTo(updatedMetadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+
+ ClusterMetadata finishInProgressSequence(NodeId nodeId, String token, ClusterMetadata metadata)
+ {
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.MOVE)
+ {
+ throw new IllegalArgumentException("Another sequence of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with move.");
+ }
+
+ Move moveInProgress = (Move) multiStepOperation;
+ Collection<Token> inProgressTokens = moveInProgress.tokens;
+ Collection<Token> givenTokenSet = token == null
+ ? Set.of()
+ : Set.of(metadata.partitioner.getTokenFactory().fromString(token));
Review Comment:
done
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
+ if (serializationVersion != null)
+ {
+ // Not a good idea to write metadata using lower serialization version. Ref: CASSANDRA-21174
+ if (currentVersion.isBefore(serializationVersion))
+ {
+ throw new IllegalArgumentException("Given serialization version " + serializationVersion +
+ " is newer compared to current version " + currentVersion + '.');
+ }
+
+ return serializationVersion;
+ }
+
+ Version metadataVersion = metadata.directory.commonSerializationVersion;
+ if (!currentVersion.isAtLeast(metadataVersion))
+ {
+ throw new IllegalArgumentException("Current version " + currentVersion +
+ " is older than version in cluster metadata (" +
+ metadataVersion + "). Cannot proceed further. " +
+ "Try modifying cluster metadata using binaries that support " +
+ "minimum serialization version: " + metadataVersion + '.');
+ }
+
+ return metadataVersion;
+ }
+ }
+
+ /**
+ * Cancels a JOIN or REPLACE bootstrap sequence for the given node and unregisters it
+ * from the cluster. Use this when a node is stuck in bootstrapping or replacement.
+ * Fails if no in-progress sequence exists, or if the sequence is not of kind JOIN or REPLACE.
+ */
+ @Command(name = "abortbootstrap",
+ description = "Aborts bootstrap for given node if in progress.")
+ public static class AbortBootstrap extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated cluster metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ if (!metadata.inProgressSequences.contains(nodeId))
+ {
+ throw new IllegalArgumentException("Did not find any sequences in progress for node " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (sequenceKind != MultiStepOperation.Kind.JOIN
+ && sequenceKind != MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("abortbootstrap is not a valid operation when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+
+ // Cancelling the sequence is not enough, but we need to unregister as well
+ Unregister unregister = new Unregister(nodeId, EnumSet.of(NodeState.REGISTERED),
+ ClusterMetadataService.instance().placementProvider());
+
+ updatedMetadata = unregister.execute(updatedMetadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress MOVE sequence for the given node, returning it to its
+ * pre-move token assignment. Fails if no in-progress sequence exists, or if the
+ * sequence is not of kind MOVE.
+ */
+ @Command(name = "abortmove", description = "Aborts in progress move sequence for given node.")
+ static class AbortMove extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.MOVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort move.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress LEAVE (decommission) sequence for the given node, keeping
+ * it as an active member of the ring. Fails if no in-progress sequence exists, or if
+ * the sequence is not of kind LEAVE.
+ */
+ @Command(name = "abortdecommission", description = "Aborts in progress decommission sequence for given node.")
+ static class AbortDecommission extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.LEAVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort decommission.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Removes a node from cluster metadata by applying the {@link Assassinate} transformation.
+ * If a MOVE or LEAVE sequence is in progress for the node, it is cancelled first.
+ * If the node is a CMS member, it is removed from CMS before assassination.
+ * Fails if the node is in a JOIN or REPLACE sequence; use {@code abortbootstrap} instead.
+ */
+ @Command(name = "assassinate", description = "Assassinates given node from Cluster metadata.")
+ static class AssassinateNode extends ClusterMetadataToolCmd
+ {
+ private final EnumSet<MultiStepOperation.Kind> supportedCancelSequences =
+ EnumSet.of(MultiStepOperation.Kind.MOVE, MultiStepOperation.Kind.LEAVE);
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ // Check if there are any in-progress sequences for given node
+ // If any, then cancel the sequence and then assassinate it
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (!supportedCancelSequences.contains(sequenceKind))
+ {
+ if (sequenceKind == MultiStepOperation.Kind.JOIN || sequenceKind == MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
+ sequenceKind + " is in progress. " +
+ "Run abortbootstrap instead.");
+ }
+ else
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+ }
+
+ output.out.printf("Cancelling in-progress sequence of kind %s before assassinating node.\n",
+ metadata.inProgressSequences.get(nodeId).kind());
+ metadata = new CancelInProgressSequence(nodeId).execute(metadata).success().metadata;
+ }
+
+ metadata = maybeReconfigureCMS(metadata, nodeId);
+ Assassinate transformation = new Assassinate(nodeId, ClusterMetadataService.instance().placementProvider());
+ ClusterMetadata updatedMetadata = transformation.execute(metadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+
+ ClusterMetadata maybeReconfigureCMS(ClusterMetadata metadata, NodeId nodeId)
+ {
+ InetAddressAndPort addressAndPort = metadata.directory.endpoint(nodeId);
+ if (!metadata.isCMSMember(addressAndPort))
+ {
+ return metadata;
+ }
+
+ // Ref: org.apache.cassandra.tcm.sequences.ReconfigureCMS.maybeReconfigureCMS
+ PrepareCMSReconfiguration.Simple transformation = new PrepareCMSReconfiguration.Simple(nodeId, Set.of());
+ ClusterMetadata updatedMetadata = transformation.execute(metadata).success().metadata;
+ updatedMetadata = updatedMetadata.inProgressSequences.get(ReconfigureCMS.SequenceKey.instance)
+ .applyTo(updatedMetadata).success().metadata;
+ if (updatedMetadata.isCMSMember(addressAndPort))
+ {
+ throw new IllegalStateException("Could not remove node " + nodeIdentifierOption.getNodeIpOrId() +
+ " from CMS.");
+ }
+ return updatedMetadata;
+ }
+ }
+
+ /**
+ * Replaces the entire CMS membership with a single node. All existing CMS replicas are
+ * removed and the specified node becomes the sole CMS member. Useful for recovering from
+ * a state where the CMS is unreachable.
+ */
+ @Command(name = "resetcms",
+ description = "Replaces all CMS members with the specified node. WARNING: all existing CMS replicas are removed.")
+ static class ResetCMS extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ public void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ InetAddressAndPort nodeAddress = metadata.directory.getNodeAddresses(nodeId).broadcastAddress;
+ metadata = resetCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata resetCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ Iterable<Replica> currentReplicas = metadata.placements.get(metaParams).writes.byEndpoint().flattenValues();
+ DataPlacement.Builder placementBuilder = metadata.placements.get(metaParams).unbuild();
+ for (Replica replica : currentReplicas)
+ {
+ placementBuilder.withoutReadReplica(metadata.epoch, replica)
+ .withoutWriteReplica(metadata.epoch, replica);
+ }
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ placementBuilder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+
+ return metadata.transformer()
+ .with(metadata.placements.unbuild()
+ .with(metaParams, placementBuilder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ /**
+ * Moves a node to a new token. Only supports single-token nodes.
+ * If a MOVE sequence is already in progress for the node, it is completed rather than started fresh;
+ * in that case the provided token must match the token of the in-progress sequence.
+ */
+ @Command(name = "move",
+ description = "Moves node to given token. Works only for cluster having nodes with single token.")
+ static class MoveToken extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-t", "--token" },
+ description = "Token to assign.")
+ private String token;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ // Took the reference from org.apache.cassandra.tcm.sequences.SingleNodeSequences.move
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ ClusterMetadata updatedMetadata = finishInProgressSequence(nodeId, token, metadata);
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ return;
+ }
+
+ if (null == token)
+ {
+ throw new IllegalArgumentException("Token required when no MOVE sequence is in progress.");
+ }
+ metadata.partitioner.getTokenFactory().validate(token);
+ Token toToken = metadata.partitioner.getTokenFactory().fromString(token);
+ if (metadata.tokenMap.tokens().contains(toToken))
+ {
+ throw new IllegalArgumentException("Target token " + toToken + " is already owned by another node.");
+ }
+ if (metadata.tokenMap.tokens(nodeId).size() > 1)
+ {
+ throw new UnsupportedOperationException("This node has more than one token and cannot be moved thusly.");
+ }
+
+ PrepareMove prepareMove = new PrepareMove(nodeId, Set.of(toToken), new UniformRangePlacement(), false);
+ ClusterMetadata updatedMetadata = prepareMove.execute(metadata).success().metadata;
Review Comment:
Thrown exception message is propagated to the user. These exception messages are giving decent information about what went wrong. So allowing it to throw the exception.
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
+ if (serializationVersion != null)
+ {
+ // Not a good idea to write metadata using lower serialization version. Ref: CASSANDRA-21174
+ if (currentVersion.isBefore(serializationVersion))
+ {
+ throw new IllegalArgumentException("Given serialization version " + serializationVersion +
+ " is newer compared to current version " + currentVersion + '.');
+ }
+
+ return serializationVersion;
+ }
+
+ Version metadataVersion = metadata.directory.commonSerializationVersion;
+ if (!currentVersion.isAtLeast(metadataVersion))
+ {
+ throw new IllegalArgumentException("Current version " + currentVersion +
+ " is older than version in cluster metadata (" +
+ metadataVersion + "). Cannot proceed further. " +
+ "Try modifying cluster metadata using binaries that support " +
+ "minimum serialization version: " + metadataVersion + '.');
+ }
+
+ return metadataVersion;
+ }
+ }
+
+ /**
+ * Cancels a JOIN or REPLACE bootstrap sequence for the given node and unregisters it
+ * from the cluster. Use this when a node is stuck in bootstrapping or replacement.
+ * Fails if no in-progress sequence exists, or if the sequence is not of kind JOIN or REPLACE.
+ */
+ @Command(name = "abortbootstrap",
+ description = "Aborts bootstrap for given node if in progress.")
+ public static class AbortBootstrap extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated cluster metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ if (!metadata.inProgressSequences.contains(nodeId))
+ {
+ throw new IllegalArgumentException("Did not find any sequences in progress for node " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (sequenceKind != MultiStepOperation.Kind.JOIN
+ && sequenceKind != MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("abortbootstrap is not a valid operation when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+
+ // Cancelling the sequence is not enough, but we need to unregister as well
+ Unregister unregister = new Unregister(nodeId, EnumSet.of(NodeState.REGISTERED),
+ ClusterMetadataService.instance().placementProvider());
+
+ updatedMetadata = unregister.execute(updatedMetadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress MOVE sequence for the given node, returning it to its
+ * pre-move token assignment. Fails if no in-progress sequence exists, or if the
+ * sequence is not of kind MOVE.
+ */
+ @Command(name = "abortmove", description = "Aborts in progress move sequence for given node.")
+ static class AbortMove extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.MOVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort move.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress LEAVE (decommission) sequence for the given node, keeping
+ * it as an active member of the ring. Fails if no in-progress sequence exists, or if
+ * the sequence is not of kind LEAVE.
+ */
+ @Command(name = "abortdecommission", description = "Aborts in progress decommission sequence for given node.")
+ static class AbortDecommission extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.LEAVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort decommission.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Removes a node from cluster metadata by applying the {@link Assassinate} transformation.
+ * If a MOVE or LEAVE sequence is in progress for the node, it is cancelled first.
+ * If the node is a CMS member, it is removed from CMS before assassination.
+ * Fails if the node is in a JOIN or REPLACE sequence; use {@code abortbootstrap} instead.
+ */
+ @Command(name = "assassinate", description = "Assassinates given node from Cluster metadata.")
+ static class AssassinateNode extends ClusterMetadataToolCmd
+ {
+ private final EnumSet<MultiStepOperation.Kind> supportedCancelSequences =
+ EnumSet.of(MultiStepOperation.Kind.MOVE, MultiStepOperation.Kind.LEAVE);
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ // Check if there are any in-progress sequences for given node
+ // If any, then cancel the sequence and then assassinate it
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (!supportedCancelSequences.contains(sequenceKind))
+ {
+ if (sequenceKind == MultiStepOperation.Kind.JOIN || sequenceKind == MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
+ sequenceKind + " is in progress. " +
+ "Run abortbootstrap instead.");
+ }
+ else
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+ }
+
+ output.out.printf("Cancelling in-progress sequence of kind %s before assassinating node.\n",
+ metadata.inProgressSequences.get(nodeId).kind());
+ metadata = new CancelInProgressSequence(nodeId).execute(metadata).success().metadata;
+ }
+
+ metadata = maybeReconfigureCMS(metadata, nodeId);
+ Assassinate transformation = new Assassinate(nodeId, ClusterMetadataService.instance().placementProvider());
+ ClusterMetadata updatedMetadata = transformation.execute(metadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+
+ ClusterMetadata maybeReconfigureCMS(ClusterMetadata metadata, NodeId nodeId)
+ {
+ InetAddressAndPort addressAndPort = metadata.directory.endpoint(nodeId);
+ if (!metadata.isCMSMember(addressAndPort))
+ {
+ return metadata;
+ }
+
+ // Ref: org.apache.cassandra.tcm.sequences.ReconfigureCMS.maybeReconfigureCMS
+ PrepareCMSReconfiguration.Simple transformation = new PrepareCMSReconfiguration.Simple(nodeId, Set.of());
+ ClusterMetadata updatedMetadata = transformation.execute(metadata).success().metadata;
+ updatedMetadata = updatedMetadata.inProgressSequences.get(ReconfigureCMS.SequenceKey.instance)
+ .applyTo(updatedMetadata).success().metadata;
+ if (updatedMetadata.isCMSMember(addressAndPort))
+ {
+ throw new IllegalStateException("Could not remove node " + nodeIdentifierOption.getNodeIpOrId() +
+ " from CMS.");
+ }
+ return updatedMetadata;
+ }
+ }
+
+ /**
+ * Replaces the entire CMS membership with a single node. All existing CMS replicas are
+ * removed and the specified node becomes the sole CMS member. Useful for recovering from
+ * a state where the CMS is unreachable.
+ */
+ @Command(name = "resetcms",
+ description = "Replaces all CMS members with the specified node. WARNING: all existing CMS replicas are removed.")
+ static class ResetCMS extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ public void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ InetAddressAndPort nodeAddress = metadata.directory.getNodeAddresses(nodeId).broadcastAddress;
+ metadata = resetCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata resetCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ Iterable<Replica> currentReplicas = metadata.placements.get(metaParams).writes.byEndpoint().flattenValues();
+ DataPlacement.Builder placementBuilder = metadata.placements.get(metaParams).unbuild();
+ for (Replica replica : currentReplicas)
+ {
+ placementBuilder.withoutReadReplica(metadata.epoch, replica)
+ .withoutWriteReplica(metadata.epoch, replica);
+ }
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ placementBuilder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+
+ return metadata.transformer()
+ .with(metadata.placements.unbuild()
+ .with(metaParams, placementBuilder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ /**
+ * Moves a node to a new token. Only supports single-token nodes.
+ * If a MOVE sequence is already in progress for the node, it is completed rather than started fresh;
+ * in that case the provided token must match the token of the in-progress sequence.
+ */
+ @Command(name = "move",
+ description = "Moves node to given token. Works only for cluster having nodes with single token.")
+ static class MoveToken extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-t", "--token" },
+ description = "Token to assign.")
+ private String token;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ // Took the reference from org.apache.cassandra.tcm.sequences.SingleNodeSequences.move
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ ClusterMetadata updatedMetadata = finishInProgressSequence(nodeId, token, metadata);
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ return;
+ }
+
+ if (null == token)
+ {
+ throw new IllegalArgumentException("Token required when no MOVE sequence is in progress.");
+ }
+ metadata.partitioner.getTokenFactory().validate(token);
+ Token toToken = metadata.partitioner.getTokenFactory().fromString(token);
+ if (metadata.tokenMap.tokens().contains(toToken))
+ {
+ throw new IllegalArgumentException("Target token " + toToken + " is already owned by another node.");
+ }
+ if (metadata.tokenMap.tokens(nodeId).size() > 1)
+ {
+ throw new UnsupportedOperationException("This node has more than one token and cannot be moved thusly.");
+ }
+
+ PrepareMove prepareMove = new PrepareMove(nodeId, Set.of(toToken), new UniformRangePlacement(), false);
+ ClusterMetadata updatedMetadata = prepareMove.execute(metadata).success().metadata;
+ updatedMetadata = updatedMetadata.inProgressSequences.get(nodeId).applyTo(updatedMetadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+
+ ClusterMetadata finishInProgressSequence(NodeId nodeId, String token, ClusterMetadata metadata)
+ {
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.MOVE)
+ {
+ throw new IllegalArgumentException("Another sequence of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with move.");
+ }
+
+ Move moveInProgress = (Move) multiStepOperation;
+ Collection<Token> inProgressTokens = moveInProgress.tokens;
+ Collection<Token> givenTokenSet = token == null
+ ? Set.of()
+ : Set.of(metadata.partitioner.getTokenFactory().fromString(token));
+ if (givenTokenSet.isEmpty() || new HashSet<>(moveInProgress.tokens).equals(givenTokenSet))
+ {
+ return moveInProgress.applyTo(metadata).success().metadata;
+ }
+
+ throw new IllegalArgumentException("Move in progress for another token(s) " + inProgressTokens + '.');
+ }
+ }
+
+ /**
+ * Identifies a target node using either its IP address or its integer/UUID node ID.
+ * Exactly one of {@code -ip} or {@code -id} must be provided.
+ */
+ static class NodeIdentifierOption
+ {
+ @Option(names = { "-ip", "--ip-address" }, required = true,
+ description = "IP address of the target endpoint. Port can be optionally specified " +
+ "using a colon after the IP address (e.g., 127.0.0.1:9042).")
+ private String ip;
+
+ @Option(names = { "-id", "--node-id" }, required = true,
+ description = "Node ID. It can be integer ID assigned to node or the node uuid.")
+ private String id;
+
+ String getNodeIpOrId()
+ {
+ return ip != null ? ip : id;
+ }
+
+ NodeId getNodeId(ClusterMetadata metadata)
+ {
+ if (id != null)
+ {
+ NodeId nodeId = NodeId.fromString(id);
+ if (!metadata.directory.peerIds().contains(nodeId))
+ {
+ throw new IllegalArgumentException("No node present with id " + id +
+ " in the given cluster metadata.");
+ }
+ return nodeId;
+ }
+ else if (ip != null)
+ {
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ip);
+ NodeId nodeId = metadata.directory.peerId(nodeAddress);
+
+ if (null == nodeId)
+ {
+ throw new IllegalArgumentException("No node present with ip address " + ip +
+ " in given cluster metadata.");
+ }
+ return nodeId;
+ }
+
+ throw new IllegalArgumentException("Neither node id nor ip address specified to fetch NodeId from metadata.");
+ }
+ }
+
+
+ /**
+ * Prints a high-level summary of the CMS state: members, epoch, replication factor,
+ * service state, and whether reconfiguration is needed.
+ */
+ @Command(name = "describe", description = "Describes the cluster metadata.")
+ static class Describe extends ClusterMetadataToolCmd
+ {
+ @Override
+ public void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ String members = metadata.fullCMSMembers()
+ .stream()
+ .sorted()
+ .map(Object::toString)
+ .collect(Collectors.joining(","));
+
+ output.out.printf("Cluster Metadata Service:%n");
+ output.out.printf("Members: %s%n", members);
+ output.out.printf("Needs reconfiguration: %s%n", needsReconfiguration(metadata));
+ output.out.printf("Service State: %s%n", ClusterMetadataService.state(metadata));
+ output.out.printf("Epoch: %s%n", metadata.epoch.getEpoch());
+ output.out.printf("Replication factor: %s%n", ReplicationParams.meta(metadata).toString());
+ }
+ }
+
+ /**
+ * Forces a node directly to JOINED state, bypassing the normal bootstrap sequence.
+ * <ul>
+ * <li>If the node has an in-progress JOIN sequence, it is completed; provided tokens
+ * must match the sequence tokens (or be omitted to use the sequence tokens).</li>
+ * <li>If no sequence is in progress, tokens must be explicitly provided and
+ * an {@link UnsafeJoin} is applied.</li>
+ * </ul>
+ * Fails if the node is already JOINED, or if a non-JOIN sequence is in progress.
+ */
+ @Command(name = "forcejoin", description = "Forces a node to move to JOINED state.")
+ static class ForceJoin extends ClusterMetadataToolCmd
+ {
+ @SuppressWarnings("MismatchedQueryAndUpdateOfCollection")
+ @Option(names = { "-t", "--token" },
+ description = "Token to assign. Pass it multiple times to assign multiple tokens to node.")
+ private final List<String> tokens = new ArrayList<>();
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ public void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ Set<Token> tokenSet = new HashSet<>(tokens.size());
+ Token.TokenFactory tokenFactory = metadata.partitioner.getTokenFactory();
+ tokens.forEach(t -> tokenSet.add(tokenFactory.fromString(t)));
+
+ NodeState nodeState = metadata.directory.peerState(nodeId);
+ if (nodeState == NodeState.JOINED)
+ {
+ throw new IllegalArgumentException("Node " + nodeIdentifierOption.getNodeIpOrId() +
+ " is already in JOINED state.");
+ }
+
+ ClusterMetadata updatedMetadata;
+ if (metadata.inProgressSequences.get(nodeId) != null)
+ {
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.JOIN)
+ {
+ throw new IllegalArgumentException("Another sequence of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with force join.");
+ }
+ BootstrapAndJoin bootstrapAndJoin = (BootstrapAndJoin) multiStepOperation;
+ Set<Token> sequenceTokens = bootstrapAndJoin.finishJoin.tokens;
+ if (tokens.isEmpty()
+ || (tokenSet.size() == sequenceTokens.size() && sequenceTokens.containsAll(tokenSet)))
+ {
+ updatedMetadata = bootstrapAndJoin.applyTo(metadata).success().metadata;
+ }
+ else
+ {
+ // If tokens are provided, then it should match with the in-progress sequence tokens
+ throw new IllegalArgumentException("The tokens provided " + tokens + " do not match with " +
+ " in progress BootstrapAndJoin sequence tokens " +
+ sequenceTokens + ". Cannot proceed further.");
+ }
+ }
+ else
+ {
+ // There are no in-progress sequences, force join by using UnsafeJoin transformation
+ if (tokenSet.isEmpty())
+ {
+ throw new IllegalArgumentException("Tokens must be provided to force join a node.");
+ }
+ UnsafeJoin unsafeJoin = new UnsafeJoin(nodeId, tokenSet, new UniformRangePlacement());
+ updatedMetadata = unsafeJoin.execute(metadata).success().metadata;
+ }
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Prints the full {@code toString()} representation of the cluster metadata to stdout.
+ * Useful for a quick human-readable overview of the entire metadata state.
+ * <p>
+ * <b>Note:</b> The output format is not stable and may change between versions.
+ * Do not rely on it for programmatic parsing.
+ */
+ @Command(name = "print", description = "Prints string output of the cluster metadata. " +
+ "Output format is subject to change and should not be relied on for parsing.")
+ static class Print extends ClusterMetadataToolCmd
+ {
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ // It supports only toString output of the ClusterMetadata for now
+ ClusterMetadata metadata = parseClusterMetadata();
+ output.out.println(metadata);
+ }
+ }
+
+ /**
+ * Prints per-node directory information: addresses, ports, rack, DC, state,
+ * serialization version, and CMS membership status.
+ */
+ @Command(name = "printdirectory", description = "Prints directory information in cluster metadata file.")
+ static class PrintDirectoryCmd extends ClusterMetadataToolCmd
+ {
+ @Override
+ public void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ Directory directory = metadata.directory;
+ Set<NodeId> nodeIdList = directory.peerIds();
+ for (NodeId nodeId : nodeIdList)
+ {
+ NodeAddresses nodeAddresses = directory.getNodeAddresses(nodeId);
+ Location location = directory.location(nodeId);
+ output.out.println("NodeId: " + nodeId.id());
+ String format = " %-22s%s\n";
+ output.out.printf(format, "rack", location.rack);
+ output.out.printf(format, "local_port", nodeAddresses.localAddress.getPort());
+ output.out.printf(format, "broadcast_port", nodeAddresses.broadcastAddress.getPort());
+ output.out.printf(format, "host_id", nodeId.toUUID());
+ output.out.printf(format, "broadcast_address", nodeAddresses.broadcastAddress.getAddress().toString());
+ output.out.printf(format, "native_address", nodeAddresses.nativeAddress.getAddress().toString());
+ output.out.printf(format, "native_port", nodeAddresses.nativeAddress.getPort());
+ output.out.printf(format, "local_address", nodeAddresses.localAddress.getAddress().toString());
+ output.out.printf(format, "state", directory.peerState(nodeId));
+ output.out.printf(format, "serialization_version", directory.version(nodeId).serializationVersion());
+ output.out.printf(format, "cassandra_version", directory.version(nodeId).cassandraVersion);
+ output.out.printf(format, "dc", location.datacenter);
+ output.out.printf(format, "is_cms_member", metadata.isCMSMember(nodeAddresses.broadcastAddress));
+ }
+ }
+ }
+
+ /**
+ * Prints read and write replica placements for a specific keyspace, sorted by token range.
+ * Requires the {@code -ks} option to specify the target keyspace.
+ */
+ @Command(name = "printdataplacements", description = "Prints data placements in cluster metadata file.")
+ static class PrintDataPlacements extends ClusterMetadataToolCmd
+ {
+ @Option(names = { "-ks", "--keyspace" }, required = true,
+ description = "Keyspace to use for printing data placements.")
+ private String keyspace;
+
+ @Override
+ public void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+
+ KeyspaceMetadata keyspaceMetadata = metadata.schema.getKeyspaces().getNullable(keyspace);
+ if (keyspaceMetadata == null)
+ {
+ throw new IllegalArgumentException("Keyspace " + keyspace + " not found in cluster metadata.");
+ }
+
+ DataPlacement placement = metadata.placements.get(keyspaceMetadata.params.replication);
+ List<Object[]> rows = new ArrayList<>();
+ rows.addAll(replicaGroupsToRows(placement.reads, "read"));
+ rows.addAll(replicaGroupsToRows(placement.writes, "write"));
+
+ rows.sort((o1, o2) -> {
+ Range<Token> left = (Range<Token>) o1[0];
+ Range<Token> right = (Range<Token>) o2[0];
+ return left.compareTo(right);
+ });
+
+ int rangeMaxLength = 0;
+ for (Object[] objects : rows)
+ {
+ rangeMaxLength = Math.max(rangeMaxLength, objects[0].toString().length());
+ }
+
+ String rowFormat = String.format("%%-%ds %%-7s %%s\n", (rangeMaxLength + 2));
+ output.out.printf(rowFormat, "Token Range", "Type", "Endpoints");
+
+ rows.forEach(row -> output.out.printf(rowFormat, row));
+ }
+
+ List<Object[]> replicaGroupsToRows(ReplicaGroups replicaGroups, String replicaGroupType)
+ {
+ List<Object[]> rows = new ArrayList<>();
+ replicaGroups.forEach(((tokenRange, forRange) -> {
+ Range<Token> range = forRange.get().range();
Review Comment:
done
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org
Re: [PR] CASSANDRA-19151 Adding offline tool for cluster metadata [cassandra]
Posted by "beobal (via GitHub)" <gi...@apache.org>.
beobal commented on code in PR #4149:
URL: https://github.com/apache/cassandra/pull/4149#discussion_r2905494950
##########
test/unit/org/apache/cassandra/tools/CMSOfflineToolTest.java:
##########
@@ -0,0 +1,1834 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Murmur3Partitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.DistributedSchema;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.KeyspaceParams;
+import org.apache.cassandra.schema.Keyspaces;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.schema.SchemaConstants;
+import org.apache.cassandra.service.accord.AccordFastPath;
+import org.apache.cassandra.service.accord.AccordStaleReplicas;
+import org.apache.cassandra.service.consensus.migration.ConsensusMigrationState;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.Epoch;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.DataPlacements;
+import org.apache.cassandra.tcm.ownership.TokenMap;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.BootstrapAndReplace;
+import org.apache.cassandra.tcm.sequences.InProgressSequences;
+import org.apache.cassandra.tcm.sequences.LeaveStreams;
+import org.apache.cassandra.tcm.sequences.LockedRanges;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.UnbootstrapAndLeave;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.PrepareJoin;
+import org.apache.cassandra.tcm.transformations.PrepareLeave;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.PrepareReplace;
+import org.apache.cassandra.tcm.transformations.Register;
+
+import static org.apache.cassandra.config.DatabaseDescriptor.getStoragePort;
+import static org.apache.cassandra.distributed.test.log.ClusterMetadataTestHelper.prepareJoin;
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+public class CMSOfflineToolTest extends OfflineToolUtils
Review Comment:
When I run these tests, they all seem to be failing in the post-test condition because the JMX server threads are running. e.g:
```java.lang.AssertionError: Bad threads found:
RMI Scheduler(0)
RMI TCP Connection(1)-192.168.1.62
-Tools should not start threads that initialize singletons or other system resources
at org.junit.Assert.fail(Assert.java:88)
at org.apache.cassandra.tools.OfflineToolUtils.assertNoUnexpectedThreadsStarted(OfflineToolUtils.java:133)
at org.apache.cassandra.tools.OfflineToolUtils.assertCorrectEnvPostTest(OfflineToolUtils.java:252)
at org.apache.cassandra.tools.CMSOfflineToolTest.testAbortDecommissionLeavingNode(CMSOfflineToolTest.java:444)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:566)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26)
at org.junit.rules.ExternalResource$1.evaluate(ExternalResource.java:48)
at org.junit.rules.RunRules.evaluate(RunRules.java:20)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26)
at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:69)
at com.intellij.rt.junit.IdeaTestRunner$Repeater$1.execute(IdeaTestRunner.java:38)
at com.intellij.rt.execution.junit.TestsRepeater.repeat(TestsRepeater.java:11)
at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:35)
at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:231)
at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:55)
```
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
+ if (serializationVersion != null)
+ {
+ // Not a good idea to write metadata using lower serialization version. Ref: CASSANDRA-21174
+ if (currentVersion.isBefore(serializationVersion))
+ {
+ throw new IllegalArgumentException("Given serialization version " + serializationVersion +
+ " is newer compared to current version " + currentVersion + '.');
+ }
+
+ return serializationVersion;
+ }
+
+ Version metadataVersion = metadata.directory.commonSerializationVersion;
+ if (!currentVersion.isAtLeast(metadataVersion))
+ {
+ throw new IllegalArgumentException("Current version " + currentVersion +
+ " is older than version in cluster metadata (" +
+ metadataVersion + "). Cannot proceed further. " +
+ "Try modifying cluster metadata using binaries that support " +
+ "minimum serialization version: " + metadataVersion + '.');
+ }
+
+ return metadataVersion;
+ }
+ }
+
+ /**
+ * Cancels a JOIN or REPLACE bootstrap sequence for the given node and unregisters it
+ * from the cluster. Use this when a node is stuck in bootstrapping or replacement.
+ * Fails if no in-progress sequence exists, or if the sequence is not of kind JOIN or REPLACE.
+ */
+ @Command(name = "abortbootstrap",
+ description = "Aborts bootstrap for given node if in progress.")
+ public static class AbortBootstrap extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated cluster metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ if (!metadata.inProgressSequences.contains(nodeId))
+ {
+ throw new IllegalArgumentException("Did not find any sequences in progress for node " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (sequenceKind != MultiStepOperation.Kind.JOIN
+ && sequenceKind != MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("abortbootstrap is not a valid operation when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+
+ // Cancelling the sequence is not enough, but we need to unregister as well
+ Unregister unregister = new Unregister(nodeId, EnumSet.of(NodeState.REGISTERED),
+ ClusterMetadataService.instance().placementProvider());
+
+ updatedMetadata = unregister.execute(updatedMetadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress MOVE sequence for the given node, returning it to its
+ * pre-move token assignment. Fails if no in-progress sequence exists, or if the
+ * sequence is not of kind MOVE.
+ */
+ @Command(name = "abortmove", description = "Aborts in progress move sequence for given node.")
+ static class AbortMove extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.MOVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort move.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress LEAVE (decommission) sequence for the given node, keeping
+ * it as an active member of the ring. Fails if no in-progress sequence exists, or if
+ * the sequence is not of kind LEAVE.
+ */
+ @Command(name = "abortdecommission", description = "Aborts in progress decommission sequence for given node.")
+ static class AbortDecommission extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.LEAVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort decommission.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Removes a node from cluster metadata by applying the {@link Assassinate} transformation.
+ * If a MOVE or LEAVE sequence is in progress for the node, it is cancelled first.
+ * If the node is a CMS member, it is removed from CMS before assassination.
+ * Fails if the node is in a JOIN or REPLACE sequence; use {@code abortbootstrap} instead.
+ */
+ @Command(name = "assassinate", description = "Assassinates given node from Cluster metadata.")
+ static class AssassinateNode extends ClusterMetadataToolCmd
+ {
+ private final EnumSet<MultiStepOperation.Kind> supportedCancelSequences =
+ EnumSet.of(MultiStepOperation.Kind.MOVE, MultiStepOperation.Kind.LEAVE);
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ // Check if there are any in-progress sequences for given node
+ // If any, then cancel the sequence and then assassinate it
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (!supportedCancelSequences.contains(sequenceKind))
+ {
+ if (sequenceKind == MultiStepOperation.Kind.JOIN || sequenceKind == MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
Review Comment:
Why can't we allow this, is there something about `BootstrapAndJoin/BootstrapAndReplace` that means we can't cancel them here?
##########
test/unit/org/apache/cassandra/tools/CMSOfflineToolTest.java:
##########
@@ -0,0 +1,1834 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Murmur3Partitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.DistributedSchema;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.KeyspaceParams;
+import org.apache.cassandra.schema.Keyspaces;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.schema.SchemaConstants;
+import org.apache.cassandra.service.accord.AccordFastPath;
+import org.apache.cassandra.service.accord.AccordStaleReplicas;
+import org.apache.cassandra.service.consensus.migration.ConsensusMigrationState;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.Epoch;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.DataPlacements;
+import org.apache.cassandra.tcm.ownership.TokenMap;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.BootstrapAndReplace;
+import org.apache.cassandra.tcm.sequences.InProgressSequences;
+import org.apache.cassandra.tcm.sequences.LeaveStreams;
+import org.apache.cassandra.tcm.sequences.LockedRanges;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.UnbootstrapAndLeave;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.PrepareJoin;
+import org.apache.cassandra.tcm.transformations.PrepareLeave;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.PrepareReplace;
+import org.apache.cassandra.tcm.transformations.Register;
+
+import static org.apache.cassandra.config.DatabaseDescriptor.getStoragePort;
+import static org.apache.cassandra.distributed.test.log.ClusterMetadataTestHelper.prepareJoin;
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+public class CMSOfflineToolTest extends OfflineToolUtils
+{
+
+ public static final String DC = "datacenter1";
+ @Rule
+ public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ private static ClusterMetadata getClusterMetadata(Keyspaces keyspaces, IPartitioner partitioner, Directory directory)
+ {
+ DistributedSchema distributedSchema = new DistributedSchema(keyspaces);
+
+ return new ClusterMetadata(Epoch.EMPTY,
+ partitioner,
+ distributedSchema,
+ directory,
+ new TokenMap(partitioner),
+ DataPlacements.empty(),
+ AccordFastPath.EMPTY,
+ LockedRanges.EMPTY,
+ InProgressSequences.EMPTY,
+ ConsensusMigrationState.EMPTY,
+ ImmutableMap.of(),
+ AccordStaleReplicas.EMPTY);
+ }
+
+ @Before
+ public void setup()
+ {
+ DatabaseDescriptor.toolInitialization();
+ ClusterMetadataService.initializeForTools(true);
+ }
+
+ @Test
+ public void testDefaultCmd()
+ {
+ ToolRunner.ToolResult tool = ToolRunner.invokeClass(CMSOfflineTool.class);
+
+ tool.assertOnCleanExit();
+ assertThat(tool.getExitCode()).isZero();
+ assertThat(tool.getStderr()).isEmpty();
+ assertThat(tool.getStdout()).withFailMessage(tool.getStderr()).contains("Usage");
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testRunCommandThatDoesNotExist()
+ {
+ ToolRunner.ToolResult tool = ToolRunner.invokeClass(CMSOfflineTool.class, "cmddoesnotexist");
+ assertThat(tool.getExitCode()).isEqualTo(2);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testRunCommandWithMetadataFileThatDoesnotExist()
+ {
+ String metadataFile = temporaryFolder.getRoot().getAbsolutePath() + "/file-does-not-exists.dump";
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdirectory",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).isEqualTo(2);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapJoiningNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ int nodeToMove = 4;
+ metadata = startJoining(nodeToMove, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(nodeToMove);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerIds()).doesNotContain(new NodeId(nodeToMove));
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapReplacingNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ NodeId nodeToReplaceId = new NodeId(3);
+ NodeId newNodeId = new NodeId(4);
+ metadata = startReplacing(nodeToReplaceId, newNodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ String newNodeAddr = metadata.directory.getNodeAddresses(newNodeId).nativeAddress.getHostAddressAndPort();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeAddr,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerIds()).doesNotContain(newNodeId);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapNodeGettingReplaced() throws IOException
+ {
+ // Assuming that operator unintentionally invokes abortbootstrap on node getting replaced
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ NodeId nodeToReplaceId = new NodeId(3);
+ NodeId newNodeId = new NodeId(4);
+ metadata = startReplacing(nodeToReplaceId, newNodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ String nodeGettingReplaced = metadata.directory.getNodeAddresses(nodeToReplaceId).nativeAddress.getHostAddressAndPort();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeGettingReplaced,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Did not find any sequences in progress for node " + nodeGettingReplaced);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapWhenLeavingSequenceIsInProgress() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startLeaving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("abortbootstrap is not a valid operation when sequence of kind LEAVE");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapJoinedNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ int nodeId = 4;
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortMoveNoInProgressSequence() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ int nodeId = 4;
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortmove",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("No transformation sequence is in progress for");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortMoveNonMovingSequence() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startLeaving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortmove",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Multi step operation of kind")
+ .contains("LEAVE")
+ .contains("Cannot proceed with abort move");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortMoveMovingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startMoving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortmove",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.inProgressSequences.contains(nodeId)).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortDecommissionNoInProgressSequence() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ int nodeId = 4;
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortdecommission",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("No transformation sequence is in progress for");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortDecommissionNonLeavingSequence() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startMoving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortdecommission",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Multi step operation of kind")
+ .contains("MOVE")
+ .contains("Cannot proceed with abort decommission");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortDecommissionLeavingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startLeaving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortdecommission",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.inProgressSequences.contains(nodeId)).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateCMSMember() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ // Verify that we have CMS members
+ assertThat(metadata.fullCMSMembers()).isNotEmpty();
+
+ // Pick the first CMS member to assassinate
+ NodeId cmsNodeId = metadata.directory.peerIds().stream().findFirst().orElseThrow();
+ assertThat(metadata.isCMSMember(metadata.directory.endpoint(cmsNodeId))).isTrue();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(cmsNodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(cmsNodeId)).isEqualTo(NodeState.LEFT);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateNodeNonCMSMember() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ // Verify that we have CMS members
+ assertThat(metadata.fullCMSMembers()).isNotEmpty();
+
+ // Select non-CMS node
+ NodeId nonCMSNodeId = metadata.directory.peerIds().stream()
+ .filter(id -> !metadata.isCMSMember(metadata.directory.endpoint(id)))
+ .findFirst().orElseThrow();
+ assertThat(metadata.isCMSMember(metadata.directory.endpoint(nonCMSNodeId))).isFalse();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nonCMSNodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(nonCMSNodeId)).isEqualTo(NodeState.LEFT);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateNodeInvalidNodeId() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(-1);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("No node present with id " + nodeId +
+ " in the given cluster metadata");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateMovingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ int nodeToMove = 4;
+ metadata = startMoving(new NodeId(nodeToMove), metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(nodeToMove);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+ assertThat(result.getStdout()).contains("Cancelling in-progress sequence");
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(new NodeId(nodeToMove))).isEqualTo(NodeState.LEFT);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateJoiningNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ int nodeToMove = 4;
+ metadata = startJoining(nodeToMove, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(nodeToMove);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ // It should suggest to use abortbootstrap for node in joining state
+ assertThat(result.getStderr()).contains("abortbootstrap");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateNodeGettingReplaced() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeToReplaceId = new NodeId(4);
+ NodeId newNodeId = new NodeId(5);
+ metadata = startReplacing(nodeToReplaceId, newNodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ String nodeGettingReplaced = metadata.directory.getNodeAddresses(nodeToReplaceId)
+ .nativeAddress.getHostAddressAndPort();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeGettingReplaced,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("INVALID: Rejecting this plan as it interacts with a range locked");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateLeavingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startLeaving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String ip = metadata.directory.endpoint(nodeId).getHostAddressAndPort();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-ip",
+ ip,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+ assertThat(result.getStdout()).contains("Cancelling in-progress sequence");
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(nodeId)).isEqualTo(NodeState.LEFT);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testDescribe() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "describe",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+
+ int storagePort = getStoragePort();
+
+ String expectedOutput =
+ "Cluster Metadata Service:\n" +
+ "Members: /127.0.0.1:" + storagePort + ",/127.0.0.2:" + storagePort + ",/127.0.0.3:" + storagePort + '\n' +
+ "Needs reconfiguration: false\n" +
+ "Service State: LOCAL\n" +
+ "Epoch: 2\n" +
+ "Replication factor: ReplicationParams{class=org.apache.cassandra.locator.MetaStrategy, datacenter1=3}\n";
+ assertThat(result.getStdout()).isEqualTo(expectedOutput);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testDescribeRawString() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "print",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStdout()).isNotEmpty().startsWith("ClusterMetadata{");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMSUsingNodeId() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out" + new Random().nextLong() + ".dump";
+ NodeId nodeId = new NodeId(1);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStderr()).isEmpty();
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ InetAddressAndPort nodeAddress = metadata.directory.getNodeAddresses(nodeId).broadcastAddress;
+ assertThat(outMetadata.isCMSMember(nodeAddress)).isTrue();
+ assertThat(outMetadata.fullCMSMembers().size()).isEqualTo(1);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMS() throws IOException
+ {
+ assertResetCMS("127.0.0.1:" + getStoragePort());
+ }
+
+ @Test
+ public void testResetCMSUsingIp() throws IOException
+ {
+ assertResetCMS("127.0.0.1");
+ }
+
+ private void assertResetCMS(String ipAddress) throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out" + new Random().nextLong() + ".dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-ip",
+ ipAddress,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStderr()).isEmpty();
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+
+ // Check given ip address is added to CMS members
+ InetAddressAndPort candidate = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ assertThat(outMetadata.isCMSMember(candidate)).isTrue();
+ assertThat(outMetadata.fullCMSMembers().size()).isEqualTo(1);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMSUsingIpAddressThatDoesNotExist() throws IOException
+ {
+ // Node that doesn't exist in Cluster Metadata can be added as CMS member
+ String ipAddress = "127.0.0.55:" + getStoragePort();
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-ip",
+ ipAddress,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isNotEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("No node present").contains(ipAddress);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMSInvalidIpAddress() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidIpAddress = "/127.0.0.";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-ip",
+ invalidIpAddress,
+ "-o",
+ outputFile);
+
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).contains("java.net.UnknownHostException");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMSInvalidSerializationVersion() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidIpAddress = "127.0.0.3";
+ String serializationVersion = "-1";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-sv",
+ serializationVersion,
+ "-ip",
+ invalidIpAddress,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Invalid value for option '--serialization-version'");
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveToken() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata(16);
+ String nodeToMove = "127.0.0.4:" + getStoragePort();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeToMove,
+ "-t",
+ String.valueOf(metadata.partitioner.getRandomToken()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("This node has more than one token and cannot be moved thusly.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveTokenVnodes() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata(4);
+ metadata = addNewNode(metadata, 4, Set.of(metadata.partitioner.getRandomToken()));
+ String nodeToMove = "127.0.0.4:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(nodeToMove);
+ NodeId nodeId = metadata.directory.peerId(InetAddressAndPort.getByName(nodeToMove));
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token desiredToken = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeToMove,
+ "-t",
+ String.valueOf(desiredToken.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+ assertThat(outMetadata.directory.peerId(newNodeInetAddress)).isNotNull();
+ assertThat(outMetadata.tokenMap.tokens(nodeId)).contains(desiredToken);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveTokenWithoutToken() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String nodeToMove = "127.0.0.4:" + getStoragePort();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeToMove,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Token required").contains("MOVE");
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveTokenForNonExistingNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String unknownNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token tokenToAssign = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ unknownNodeIpWithPort,
+ "-t",
+ String.valueOf(tokenToAssign.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).contains("No node present with ip address")
+ .contains(unknownNodeIpWithPort);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveInvalidIpAddress() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String invalidIp = "127.0.0.";
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token tokenToAssign = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ invalidIp,
+ "-t",
+ String.valueOf(tokenToAssign.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).contains("java.net.UnknownHostException");
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveNodeToInvalidToken() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0.1:" + getStoragePort();
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidToken = "somegibberishinvalidtoken";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ invalidToken,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveNodeToSomeOtherNodeToken() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0.1:" + getStoragePort();
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ImmutableList<Token> secondNodeTokenList = metadata.tokenMap.tokens(new NodeId(2));
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ secondNodeTokenList.get(0).toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveNodeWhenMoveInProgressToAnotherToken() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ NodeId nodeId = new NodeId(2);
+ Token randomToken = metadata.partitioner.getRandomToken();
+ Set<Token> toTokenSet = Set.of(randomToken);
+ metadata = startMoving(nodeId, metadata, toTokenSet);
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0." + nodeId.id() + ':' + getStoragePort();
+
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ randomToken.toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.tokenMap.tokens(nodeId)).isEqualTo(ImmutableList.of(randomToken));
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveNodeFinishInProgress() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ NodeId nodeId = new NodeId(2);
+ metadata = startMoving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0." + nodeId.id() + ':' + getStoragePort();
+
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Move in progress for another token(s)");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoin() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ assertThat(metadata.directory.states.get(newNodeId)).isEqualTo(NodeState.REGISTERED);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+ assertThat(outMetadata.directory.peerId(newNodeInetAddress)).isNotNull();
+ assertThat(outMetadata.directory.states.get(newNodeId)).isEqualTo(NodeState.JOINED);
+ assertThat(outMetadata.tokenMap.tokens(newNodeId)).isNotEmpty();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinNewNodeWithoutTokens() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ assertThat(metadata.directory.states.get(newNodeId)).isEqualTo(NodeState.REGISTERED);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Tokens must be provided to force join a node.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinUnknownNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ // New node is not registered, it should fail
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isNotEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains(newNodeIpWithPort)
+ .contains("No node present with ip address");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinMovingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startMoving(nodeId, metadata);
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.MOVING);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Another sequence of kind MOVE is in progress for node " + nodeId.id() +
+ ". Cannot proceed with force join.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinAlreadyJoinedNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.JOINED);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Node " + nodeId.id() + " is already in JOINED state.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinLeftNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ int id = 4;
+ NodeId nodeId = new NodeId(id);
+ metadata = startLeaving(nodeId, metadata);
+ UnbootstrapAndLeave unbootstrapAndLeave = (UnbootstrapAndLeave) metadata.inProgressSequences.get(nodeId);
+ metadata = unbootstrapAndLeave.applyTo(metadata).success().metadata;
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.LEFT);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(nodeId)).isEqualTo(NodeState.JOINED);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinJoiningNodeTokenMismatch() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ int nodeNum = 4;
+ metadata = startJoining(nodeNum, metadata);
+ NodeId nodeId = new NodeId(nodeNum);
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.BOOTSTRAPPING);
+
+ BootstrapAndJoin bootstrapAndJoin = (BootstrapAndJoin) metadata.inProgressSequences.get(nodeId);
+ Set<Token> sequenceTokens = bootstrapAndJoin.finishJoin.tokens;
+
+ // Pick a token that is different from the in-progress sequence tokens
+ Token differentToken = metadata.partitioner.getRandomToken();
+ while (sequenceTokens.contains(differentToken))
+ differentToken = metadata.partitioner.getRandomToken();
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-t",
+ differentToken.toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("do not match with")
+ .contains("in progress BootstrapAndJoin sequence tokens");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinJoiningNodeTokenMatch() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ int nodeNum = 4;
+ metadata = startJoining(nodeNum, metadata);
+ NodeId nodeId = new NodeId(nodeNum);
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.BOOTSTRAPPING);
+
+ BootstrapAndJoin bootstrapAndJoin = (BootstrapAndJoin) metadata.inProgressSequences.get(nodeId);
+ Set<Token> sequenceTokens = bootstrapAndJoin.finishJoin.tokens;
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ // Build args: one -t per token in the sequence
+ List<String> args = new ArrayList<>(List.of("forcejoin", "-f", metadataFile,
+ "-id", String.valueOf(nodeId.id()),
+ "-o", outputFile));
+ for (Token t : sequenceTokens)
+ {
+ args.add("-t");
+ args.add(t.toString());
+ }
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ args.toArray(new String[0]));
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(nodeId)).isEqualTo(NodeState.JOINED);
+ assertThat(outMetadata.tokenMap.tokens(nodeId)).containsAll(sequenceTokens);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinNodeThatDoesNotExist() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = "-1";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).isNotEmpty();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinWithSerializationVersion() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ assertThat(metadata.directory.states.get(newNodeId)).isEqualTo(NodeState.REGISTERED);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ // Use V7 — a known valid version older than the current (V8)
+ Version targetVersion = Version.V7;
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-sv",
+ targetVersion.toString(),
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ // Verify that the output file was serialized using the requested version
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(outputFile))
+ {
+ int versionInt = fisp.readUnsignedVInt32();
+ assertThat(Version.fromInt(versionInt)).isEqualTo(targetVersion);
+ }
+
+ // Verify the node was force-joined in the output metadata
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(newNodeId)).isEqualTo(NodeState.JOINED);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinInvalidSerializationVersion() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidSerializationVersion = "-1";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-sv",
+ invalidSerializationVersion,
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Invalid value for option '--serialization-version'");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testPrintDataPlacements() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String keyspaceName = "ks1";
+ KeyspaceParams ksParams = KeyspaceParams.create(true,
+ Map.of("class", "NetworkTopologyStrategy",
+ "datacenter1", "3"));
+ Keyspaces keyspaces = Keyspaces.none().with(KeyspaceMetadata.create(keyspaceName, ksParams));
+ DistributedSchema newSchema = new DistributedSchema(keyspaces);
+ metadata = updateMetadata(metadata, newSchema);
+
+ InetAddressAndPort inetAddressAndPort = InetAddressAndPort.getByNameUnchecked("127.0.0.1:" + getStoragePort());
+ InetAddressAndPort i2 = InetAddressAndPort.getByNameUnchecked("127.0.0.2:" + getStoragePort());
+ InetAddressAndPort i3 = InetAddressAndPort.getByNameUnchecked("127.0.0.3:" + getStoragePort());
+
+ Range<Token> tokenRange = new Range<>(metadata.partitioner.getMinimumToken(), metadata.partitioner.getTokenFactory().fromString("0"));
+
+ DataPlacement dataPlacement = DataPlacement.builder()
+ .withWriteReplica(Epoch.FIRST, Replica.fullReplica(inetAddressAndPort, tokenRange))
+ .withWriteReplica(Epoch.FIRST, Replica.fullReplica(i2, tokenRange))
+ .withWriteReplica(Epoch.FIRST, Replica.fullReplica(i3, tokenRange))
+ .withReadReplica(Epoch.FIRST, Replica.fullReplica(inetAddressAndPort, tokenRange))
+ .withReadReplica(Epoch.FIRST, Replica.fullReplica(i2, tokenRange))
+ .withReadReplica(Epoch.FIRST, Replica.fullReplica(i3, tokenRange))
+ .build();
+
+ DataPlacements dataPlacements = DataPlacements.builder(1)
+ .with(ReplicationParams.fromMap(Map.of("class", "NetworkTopologyStrategy",
+ "datacenter1", "3")),
+ dataPlacement)
+ .build();
+ metadata = updateMetadata(metadata, dataPlacements);
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdataplacements",
+ "-f",
+ metadataFile,
+ "-ks",
+ keyspaceName);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStdout()).isNotEmpty();
+ assertThat(result.getStderr()).isNullOrEmpty();
+
+ String stdout = result.getStdout();
+ assertThat(stdout).isEqualTo(
+ "Token Range Type Endpoints\n" +
+ "(-9223372036854775808,0] read /127.0.0.1:" + getStoragePort() + ", /127.0.0.2:" + getStoragePort() + ", /127.0.0.3:" + getStoragePort() + '\n' +
+ "(-9223372036854775808,0] write /127.0.0.1:" + getStoragePort() + ", /127.0.0.2:" + getStoragePort() + ", /127.0.0.3:" + getStoragePort() + '\n');
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testPrintDataPlacementsKeyspaceDoesNotExist() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String keyspaceName = "ks1";
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdataplacements",
+ "-f",
+ metadataFile,
+ "-ks",
+ keyspaceName);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).isNotEmpty();
+ assertThat(result.getStderr()).contains("Keyspace " + keyspaceName + " not found in cluster metadata");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testPrintDirectory() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdirectory",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStdout()).isNotEmpty();
+ assertThat(result.getStderr()).isNullOrEmpty();
+
+ String stdout = result.getStdout();
+
+ String expectedSerializationVersion = NodeVersion.CURRENT.serializationVersion().toString();
+ assertThat(stdout).isEqualTo(
+ "NodeId: 1\n" +
+ " rack rack1\n" +
+ " local_port " + getStoragePort() + '\n' +
+ " broadcast_port " + getStoragePort() + '\n' +
+ " host_id 6d194555-f6eb-41d0-c000-000000000001\n" +
+ " broadcast_address /127.0.0.1\n" +
+ " native_address /127.0.0.1\n" +
+ " native_port " + getStoragePort() + '\n' +
+ " local_address /127.0.0.1\n" +
+ " state JOINED\n" +
+ " serialization_version " + expectedSerializationVersion + '\n' +
+ " cassandra_version " + metadata.directory.version(new NodeId(1)).cassandraVersion + '\n' +
+ " dc datacenter1\n" +
+ " is_cms_member true\n" +
+ "NodeId: 2\n" +
+ " rack rack2\n" +
+ " local_port " + getStoragePort() + '\n' +
+ " broadcast_port " + getStoragePort() + '\n' +
+ " host_id 6d194555-f6eb-41d0-c000-000000000002\n" +
+ " broadcast_address /127.0.0.2\n" +
+ " native_address /127.0.0.2\n" +
+ " native_port " + getStoragePort() + '\n' +
+ " local_address /127.0.0.2\n" +
+ " state JOINED\n" +
+ " serialization_version " + expectedSerializationVersion + '\n' +
+ " cassandra_version " + metadata.directory.version(new NodeId(2)).cassandraVersion + '\n' +
+ " dc datacenter1\n" +
+ " is_cms_member true\n" +
+ "NodeId: 3\n" +
+ " rack rack3\n" +
+ " local_port " + getStoragePort() + '\n' +
+ " broadcast_port " + getStoragePort() + '\n' +
+ " host_id 6d194555-f6eb-41d0-c000-000000000003\n" +
+ " broadcast_address /127.0.0.3\n" +
+ " native_address /127.0.0.3\n" +
+ " native_port " + getStoragePort() + '\n' +
+ " local_address /127.0.0.3\n" +
+ " state JOINED\n" +
+ " serialization_version " + expectedSerializationVersion + '\n' +
+ " cassandra_version " + metadata.directory.version(new NodeId(3)).cassandraVersion + '\n' +
+ " dc datacenter1\n" +
+ " is_cms_member true\n"
+ );
+
+ assertCorrectEnvPostTest();
+ }
+
+ private String dumpMetadata(ClusterMetadata metadata) throws IOException
+ {
+ String tempFile = temporaryFolder.newFile().getAbsolutePath();
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(tempFile))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ NodeVersion.CURRENT.serializationVersion());
+ }
+ return tempFile;
+ }
+
+ /**
+ * Creates a three-node cluster metadata for testing.
+ */
+
+ private ClusterMetadata getThreeNodeClusterMetadata()
+ {
+ return getThreeNodeClusterMetadata(1);
+ }
+
+ private ClusterMetadata getThreeNodeClusterMetadata(int tokenSize)
+ {
+ IPartitioner partitioner = Murmur3Partitioner.instance;
+ NodeId nodeId1 = new NodeId(1);
+ NodeId nodeId2 = new NodeId(2);
+ NodeId nodeId3 = new NodeId(3);
+
+ InetAddressAndPort addr1 = InetAddressAndPort.getByNameUnchecked("127.0.0.1:" + getStoragePort());
+ InetAddressAndPort addr2 = InetAddressAndPort.getByNameUnchecked("127.0.0.2:" + getStoragePort());
+ InetAddressAndPort addr3 = InetAddressAndPort.getByNameUnchecked("127.0.0.3:" + getStoragePort());
+
+ NodeVersion nodeVersion = NodeVersion.CURRENT;
+
+ Directory directory =
+ new Directory()
+ .unsafeWithNodeForTesting(nodeId1, new NodeAddresses(addr1), new Location(DC, "rack1"), nodeVersion)
+ .unsafeWithNodeForTesting(nodeId2, new NodeAddresses(addr2), new Location(DC, "rack2"), nodeVersion)
+ .unsafeWithNodeForTesting(nodeId3, new NodeAddresses(addr3), new Location(DC, "rack3"), nodeVersion)
+ .withRackAndDC(nodeId1)
+ .withRackAndDC(nodeId2)
+ .withRackAndDC(nodeId3);
+
+ KeyspaceParams metaKsParams = KeyspaceParams.create(true,
+ Map.of("class", "MetaStrategy", "datacenter1", "3"));
+ KeyspaceMetadata metaKeyspace = KeyspaceMetadata.create(SchemaConstants.METADATA_KEYSPACE_NAME, metaKsParams);
+ KeyspaceMetadata normalKeyspace = KeyspaceMetadata.create("ks", KeyspaceParams.simple(3));
+ Keyspaces keyspaces = Keyspaces.none().with(metaKeyspace).with(normalKeyspace);
+
+ ClusterMetadata clusterMetadata = getClusterMetadata(keyspaces, partitioner, directory);
+
+
+ ClusterMetadata metadata = clusterMetadata
+ .transformer()
+ .with(directory)
+ .join(nodeId1)
+ .proposeToken(nodeId1, getRandomTokens(partitioner, tokenSize))
+ .join(nodeId2)
+ .proposeToken(nodeId2, getRandomTokens(partitioner, tokenSize))
+ .join(nodeId3)
+ .proposeToken(nodeId3, getRandomTokens(partitioner, tokenSize))
+ .build().metadata;
+
+ // Create replicas for the metadata keyspace on all three nodes
+ ReplicationParams metaParams = ReplicationParams.ntsMeta(Collections.singletonMap(DC, 3));
+ DataPlacements placements = DataPlacements.empty().unbuild()
+ .with(metaParams, getCMSMemberPlacement(metadata, List.of(addr1, addr2, addr3)))
+ .with(ReplicationParams.simple(3), getKeyspacePlacement(metadata, normalKeyspace))
+ .build();
+
+ return updateMetadata(metadata, placements);
+ }
+
+ List<Token> getRandomTokens(IPartitioner partitioner, int size)
+ {
+ List<Token> tokens = new ArrayList<>(size);
+ for (int i = 0; i < size; i++)
+ {
+ tokens.add(partitioner.getRandomToken());
+ }
+
+ return tokens;
+ }
+
+ DataPlacement getCMSMemberPlacement(ClusterMetadata clusterMetadata, List<InetAddressAndPort> inetAddressAndPorts)
+ {
+ IPartitioner partitioner = clusterMetadata.partitioner;
+ // Create replicas for the metadata keyspace on all three nodes
+ Range<Token> fullRange = new Range<>(partitioner.getMinimumToken(), partitioner.getMinimumToken());
+ DataPlacement.Builder placementBuilder = DataPlacement.builder();
+ for (InetAddressAndPort addr : inetAddressAndPorts)
+ {
+ Replica replica = Replica.fullReplica(addr, fullRange);
+ placementBuilder.withReadReplica(Epoch.EMPTY, replica)
+ .withWriteReplica(Epoch.EMPTY, replica);
+ }
+
+ return placementBuilder.build();
+ }
+
+ DataPlacement getKeyspacePlacement(ClusterMetadata metadata, KeyspaceMetadata keyspaceMetadata)
+ {
+ UniformRangePlacement uniformRangePlacement = new UniformRangePlacement();
+ List<Range<Token>> tokenRanges = uniformRangePlacement.calculateRanges(metadata.tokenMap);
+
+ return keyspaceMetadata.replicationStrategy.calculateDataPlacement(metadata.epoch, tokenRanges, metadata);
+ }
+
+ ClusterMetadata getFourNodeMetadata()
+ {
+ return getFourNodeMetadata(1);
+ }
+
+ ClusterMetadata getFourNodeMetadata(int tokenSize)
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ return addNewNode(metadata, 4, new HashSet<>(getRandomTokens(metadata.partitioner, tokenSize)));
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ ClusterMetadata addNewNode(ClusterMetadata prev, int newNodeId, Set<Token> tokens)
+ {
+ NodeId nodeId = new NodeId(newNodeId);
+ Directory directory = prev.directory
+ .unsafeWithNodeForTesting(nodeId, getNodeAddresses(newNodeId),
+ new Location(DC, "rack" + newNodeId),
+ NodeVersion.CURRENT);
Review Comment:
BTW, I found this issue by stepping through `testAssassinateCMSMember` and inspecting the `ClusterMetadata` at the end. The `reconfigureCMS` was being run, but only ending up with 2 members instead of 3. This was because `node4` was not present in the `endpointsByDC` so the recalculation of the new CMS was ignoring it.
##########
test/unit/org/apache/cassandra/tools/CMSOfflineToolTest.java:
##########
@@ -0,0 +1,1834 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Murmur3Partitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.DistributedSchema;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.KeyspaceParams;
+import org.apache.cassandra.schema.Keyspaces;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.schema.SchemaConstants;
+import org.apache.cassandra.service.accord.AccordFastPath;
+import org.apache.cassandra.service.accord.AccordStaleReplicas;
+import org.apache.cassandra.service.consensus.migration.ConsensusMigrationState;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.Epoch;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.DataPlacements;
+import org.apache.cassandra.tcm.ownership.TokenMap;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.BootstrapAndReplace;
+import org.apache.cassandra.tcm.sequences.InProgressSequences;
+import org.apache.cassandra.tcm.sequences.LeaveStreams;
+import org.apache.cassandra.tcm.sequences.LockedRanges;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.UnbootstrapAndLeave;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.PrepareJoin;
+import org.apache.cassandra.tcm.transformations.PrepareLeave;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.PrepareReplace;
+import org.apache.cassandra.tcm.transformations.Register;
+
+import static org.apache.cassandra.config.DatabaseDescriptor.getStoragePort;
+import static org.apache.cassandra.distributed.test.log.ClusterMetadataTestHelper.prepareJoin;
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+public class CMSOfflineToolTest extends OfflineToolUtils
+{
+
+ public static final String DC = "datacenter1";
+ @Rule
+ public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ private static ClusterMetadata getClusterMetadata(Keyspaces keyspaces, IPartitioner partitioner, Directory directory)
+ {
+ DistributedSchema distributedSchema = new DistributedSchema(keyspaces);
+
+ return new ClusterMetadata(Epoch.EMPTY,
+ partitioner,
+ distributedSchema,
+ directory,
+ new TokenMap(partitioner),
+ DataPlacements.empty(),
+ AccordFastPath.EMPTY,
+ LockedRanges.EMPTY,
+ InProgressSequences.EMPTY,
+ ConsensusMigrationState.EMPTY,
+ ImmutableMap.of(),
+ AccordStaleReplicas.EMPTY);
+ }
+
+ @Before
+ public void setup()
+ {
+ DatabaseDescriptor.toolInitialization();
+ ClusterMetadataService.initializeForTools(true);
+ }
+
+ @Test
+ public void testDefaultCmd()
+ {
+ ToolRunner.ToolResult tool = ToolRunner.invokeClass(CMSOfflineTool.class);
+
+ tool.assertOnCleanExit();
+ assertThat(tool.getExitCode()).isZero();
+ assertThat(tool.getStderr()).isEmpty();
+ assertThat(tool.getStdout()).withFailMessage(tool.getStderr()).contains("Usage");
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testRunCommandThatDoesNotExist()
+ {
+ ToolRunner.ToolResult tool = ToolRunner.invokeClass(CMSOfflineTool.class, "cmddoesnotexist");
+ assertThat(tool.getExitCode()).isEqualTo(2);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testRunCommandWithMetadataFileThatDoesnotExist()
+ {
+ String metadataFile = temporaryFolder.getRoot().getAbsolutePath() + "/file-does-not-exists.dump";
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdirectory",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).isEqualTo(2);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapJoiningNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ int nodeToMove = 4;
+ metadata = startJoining(nodeToMove, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(nodeToMove);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerIds()).doesNotContain(new NodeId(nodeToMove));
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapReplacingNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ NodeId nodeToReplaceId = new NodeId(3);
+ NodeId newNodeId = new NodeId(4);
+ metadata = startReplacing(nodeToReplaceId, newNodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ String newNodeAddr = metadata.directory.getNodeAddresses(newNodeId).nativeAddress.getHostAddressAndPort();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeAddr,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerIds()).doesNotContain(newNodeId);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapNodeGettingReplaced() throws IOException
+ {
+ // Assuming that operator unintentionally invokes abortbootstrap on node getting replaced
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ NodeId nodeToReplaceId = new NodeId(3);
+ NodeId newNodeId = new NodeId(4);
+ metadata = startReplacing(nodeToReplaceId, newNodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ String nodeGettingReplaced = metadata.directory.getNodeAddresses(nodeToReplaceId).nativeAddress.getHostAddressAndPort();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeGettingReplaced,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Did not find any sequences in progress for node " + nodeGettingReplaced);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapWhenLeavingSequenceIsInProgress() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startLeaving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("abortbootstrap is not a valid operation when sequence of kind LEAVE");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapJoinedNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ int nodeId = 4;
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortMoveNoInProgressSequence() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ int nodeId = 4;
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortmove",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("No transformation sequence is in progress for");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortMoveNonMovingSequence() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startLeaving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortmove",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Multi step operation of kind")
+ .contains("LEAVE")
+ .contains("Cannot proceed with abort move");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortMoveMovingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startMoving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortmove",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.inProgressSequences.contains(nodeId)).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortDecommissionNoInProgressSequence() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ int nodeId = 4;
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortdecommission",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("No transformation sequence is in progress for");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortDecommissionNonLeavingSequence() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startMoving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortdecommission",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Multi step operation of kind")
+ .contains("MOVE")
+ .contains("Cannot proceed with abort decommission");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortDecommissionLeavingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startLeaving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortdecommission",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.inProgressSequences.contains(nodeId)).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateCMSMember() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ // Verify that we have CMS members
+ assertThat(metadata.fullCMSMembers()).isNotEmpty();
+
+ // Pick the first CMS member to assassinate
+ NodeId cmsNodeId = metadata.directory.peerIds().stream().findFirst().orElseThrow();
+ assertThat(metadata.isCMSMember(metadata.directory.endpoint(cmsNodeId))).isTrue();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(cmsNodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(cmsNodeId)).isEqualTo(NodeState.LEFT);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateNodeNonCMSMember() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ // Verify that we have CMS members
+ assertThat(metadata.fullCMSMembers()).isNotEmpty();
+
+ // Select non-CMS node
+ NodeId nonCMSNodeId = metadata.directory.peerIds().stream()
+ .filter(id -> !metadata.isCMSMember(metadata.directory.endpoint(id)))
+ .findFirst().orElseThrow();
+ assertThat(metadata.isCMSMember(metadata.directory.endpoint(nonCMSNodeId))).isFalse();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nonCMSNodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(nonCMSNodeId)).isEqualTo(NodeState.LEFT);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateNodeInvalidNodeId() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(-1);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("No node present with id " + nodeId +
+ " in the given cluster metadata");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateMovingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ int nodeToMove = 4;
+ metadata = startMoving(new NodeId(nodeToMove), metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(nodeToMove);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+ assertThat(result.getStdout()).contains("Cancelling in-progress sequence");
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(new NodeId(nodeToMove))).isEqualTo(NodeState.LEFT);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateJoiningNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ int nodeToMove = 4;
+ metadata = startJoining(nodeToMove, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(nodeToMove);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ // It should suggest to use abortbootstrap for node in joining state
+ assertThat(result.getStderr()).contains("abortbootstrap");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateNodeGettingReplaced() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeToReplaceId = new NodeId(4);
+ NodeId newNodeId = new NodeId(5);
+ metadata = startReplacing(nodeToReplaceId, newNodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ String nodeGettingReplaced = metadata.directory.getNodeAddresses(nodeToReplaceId)
+ .nativeAddress.getHostAddressAndPort();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeGettingReplaced,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("INVALID: Rejecting this plan as it interacts with a range locked");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateLeavingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startLeaving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String ip = metadata.directory.endpoint(nodeId).getHostAddressAndPort();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-ip",
+ ip,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+ assertThat(result.getStdout()).contains("Cancelling in-progress sequence");
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(nodeId)).isEqualTo(NodeState.LEFT);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testDescribe() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "describe",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+
+ int storagePort = getStoragePort();
+
+ String expectedOutput =
+ "Cluster Metadata Service:\n" +
+ "Members: /127.0.0.1:" + storagePort + ",/127.0.0.2:" + storagePort + ",/127.0.0.3:" + storagePort + '\n' +
+ "Needs reconfiguration: false\n" +
+ "Service State: LOCAL\n" +
+ "Epoch: 2\n" +
+ "Replication factor: ReplicationParams{class=org.apache.cassandra.locator.MetaStrategy, datacenter1=3}\n";
+ assertThat(result.getStdout()).isEqualTo(expectedOutput);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testDescribeRawString() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "print",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStdout()).isNotEmpty().startsWith("ClusterMetadata{");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMSUsingNodeId() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out" + new Random().nextLong() + ".dump";
+ NodeId nodeId = new NodeId(1);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStderr()).isEmpty();
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ InetAddressAndPort nodeAddress = metadata.directory.getNodeAddresses(nodeId).broadcastAddress;
+ assertThat(outMetadata.isCMSMember(nodeAddress)).isTrue();
+ assertThat(outMetadata.fullCMSMembers().size()).isEqualTo(1);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMS() throws IOException
+ {
+ assertResetCMS("127.0.0.1:" + getStoragePort());
+ }
+
+ @Test
+ public void testResetCMSUsingIp() throws IOException
+ {
+ assertResetCMS("127.0.0.1");
+ }
+
+ private void assertResetCMS(String ipAddress) throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out" + new Random().nextLong() + ".dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-ip",
+ ipAddress,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStderr()).isEmpty();
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+
+ // Check given ip address is added to CMS members
+ InetAddressAndPort candidate = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ assertThat(outMetadata.isCMSMember(candidate)).isTrue();
+ assertThat(outMetadata.fullCMSMembers().size()).isEqualTo(1);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMSUsingIpAddressThatDoesNotExist() throws IOException
+ {
+ // Node that doesn't exist in Cluster Metadata can be added as CMS member
+ String ipAddress = "127.0.0.55:" + getStoragePort();
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-ip",
+ ipAddress,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isNotEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("No node present").contains(ipAddress);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMSInvalidIpAddress() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidIpAddress = "/127.0.0.";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-ip",
+ invalidIpAddress,
+ "-o",
+ outputFile);
+
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).contains("java.net.UnknownHostException");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMSInvalidSerializationVersion() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidIpAddress = "127.0.0.3";
+ String serializationVersion = "-1";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-sv",
+ serializationVersion,
+ "-ip",
+ invalidIpAddress,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Invalid value for option '--serialization-version'");
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveToken() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata(16);
+ String nodeToMove = "127.0.0.4:" + getStoragePort();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeToMove,
+ "-t",
+ String.valueOf(metadata.partitioner.getRandomToken()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("This node has more than one token and cannot be moved thusly.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveTokenVnodes() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata(4);
+ metadata = addNewNode(metadata, 4, Set.of(metadata.partitioner.getRandomToken()));
+ String nodeToMove = "127.0.0.4:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(nodeToMove);
+ NodeId nodeId = metadata.directory.peerId(InetAddressAndPort.getByName(nodeToMove));
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token desiredToken = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeToMove,
+ "-t",
+ String.valueOf(desiredToken.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+ assertThat(outMetadata.directory.peerId(newNodeInetAddress)).isNotNull();
+ assertThat(outMetadata.tokenMap.tokens(nodeId)).contains(desiredToken);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveTokenWithoutToken() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String nodeToMove = "127.0.0.4:" + getStoragePort();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeToMove,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Token required").contains("MOVE");
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveTokenForNonExistingNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String unknownNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token tokenToAssign = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ unknownNodeIpWithPort,
+ "-t",
+ String.valueOf(tokenToAssign.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).contains("No node present with ip address")
+ .contains(unknownNodeIpWithPort);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveInvalidIpAddress() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String invalidIp = "127.0.0.";
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token tokenToAssign = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ invalidIp,
+ "-t",
+ String.valueOf(tokenToAssign.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).contains("java.net.UnknownHostException");
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveNodeToInvalidToken() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0.1:" + getStoragePort();
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidToken = "somegibberishinvalidtoken";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ invalidToken,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveNodeToSomeOtherNodeToken() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0.1:" + getStoragePort();
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ImmutableList<Token> secondNodeTokenList = metadata.tokenMap.tokens(new NodeId(2));
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ secondNodeTokenList.get(0).toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveNodeWhenMoveInProgressToAnotherToken() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ NodeId nodeId = new NodeId(2);
+ Token randomToken = metadata.partitioner.getRandomToken();
+ Set<Token> toTokenSet = Set.of(randomToken);
+ metadata = startMoving(nodeId, metadata, toTokenSet);
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0." + nodeId.id() + ':' + getStoragePort();
+
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ randomToken.toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.tokenMap.tokens(nodeId)).isEqualTo(ImmutableList.of(randomToken));
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveNodeFinishInProgress() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ NodeId nodeId = new NodeId(2);
+ metadata = startMoving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0." + nodeId.id() + ':' + getStoragePort();
+
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Move in progress for another token(s)");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoin() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ assertThat(metadata.directory.states.get(newNodeId)).isEqualTo(NodeState.REGISTERED);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+ assertThat(outMetadata.directory.peerId(newNodeInetAddress)).isNotNull();
+ assertThat(outMetadata.directory.states.get(newNodeId)).isEqualTo(NodeState.JOINED);
+ assertThat(outMetadata.tokenMap.tokens(newNodeId)).isNotEmpty();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinNewNodeWithoutTokens() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ assertThat(metadata.directory.states.get(newNodeId)).isEqualTo(NodeState.REGISTERED);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Tokens must be provided to force join a node.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinUnknownNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ // New node is not registered, it should fail
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isNotEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains(newNodeIpWithPort)
+ .contains("No node present with ip address");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinMovingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startMoving(nodeId, metadata);
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.MOVING);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Another sequence of kind MOVE is in progress for node " + nodeId.id() +
+ ". Cannot proceed with force join.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinAlreadyJoinedNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.JOINED);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Node " + nodeId.id() + " is already in JOINED state.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinLeftNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ int id = 4;
+ NodeId nodeId = new NodeId(id);
+ metadata = startLeaving(nodeId, metadata);
+ UnbootstrapAndLeave unbootstrapAndLeave = (UnbootstrapAndLeave) metadata.inProgressSequences.get(nodeId);
+ metadata = unbootstrapAndLeave.applyTo(metadata).success().metadata;
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.LEFT);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(nodeId)).isEqualTo(NodeState.JOINED);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinJoiningNodeTokenMismatch() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ int nodeNum = 4;
+ metadata = startJoining(nodeNum, metadata);
+ NodeId nodeId = new NodeId(nodeNum);
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.BOOTSTRAPPING);
+
+ BootstrapAndJoin bootstrapAndJoin = (BootstrapAndJoin) metadata.inProgressSequences.get(nodeId);
+ Set<Token> sequenceTokens = bootstrapAndJoin.finishJoin.tokens;
+
+ // Pick a token that is different from the in-progress sequence tokens
+ Token differentToken = metadata.partitioner.getRandomToken();
+ while (sequenceTokens.contains(differentToken))
+ differentToken = metadata.partitioner.getRandomToken();
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-t",
+ differentToken.toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("do not match with")
+ .contains("in progress BootstrapAndJoin sequence tokens");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinJoiningNodeTokenMatch() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ int nodeNum = 4;
+ metadata = startJoining(nodeNum, metadata);
+ NodeId nodeId = new NodeId(nodeNum);
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.BOOTSTRAPPING);
+
+ BootstrapAndJoin bootstrapAndJoin = (BootstrapAndJoin) metadata.inProgressSequences.get(nodeId);
+ Set<Token> sequenceTokens = bootstrapAndJoin.finishJoin.tokens;
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ // Build args: one -t per token in the sequence
+ List<String> args = new ArrayList<>(List.of("forcejoin", "-f", metadataFile,
+ "-id", String.valueOf(nodeId.id()),
+ "-o", outputFile));
+ for (Token t : sequenceTokens)
+ {
+ args.add("-t");
+ args.add(t.toString());
+ }
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ args.toArray(new String[0]));
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(nodeId)).isEqualTo(NodeState.JOINED);
+ assertThat(outMetadata.tokenMap.tokens(nodeId)).containsAll(sequenceTokens);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinNodeThatDoesNotExist() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = "-1";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).isNotEmpty();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinWithSerializationVersion() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ assertThat(metadata.directory.states.get(newNodeId)).isEqualTo(NodeState.REGISTERED);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ // Use V7 — a known valid version older than the current (V8)
+ Version targetVersion = Version.V7;
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-sv",
+ targetVersion.toString(),
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ // Verify that the output file was serialized using the requested version
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(outputFile))
+ {
+ int versionInt = fisp.readUnsignedVInt32();
+ assertThat(Version.fromInt(versionInt)).isEqualTo(targetVersion);
+ }
+
+ // Verify the node was force-joined in the output metadata
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(newNodeId)).isEqualTo(NodeState.JOINED);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinInvalidSerializationVersion() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidSerializationVersion = "-1";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-sv",
+ invalidSerializationVersion,
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Invalid value for option '--serialization-version'");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testPrintDataPlacements() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String keyspaceName = "ks1";
+ KeyspaceParams ksParams = KeyspaceParams.create(true,
+ Map.of("class", "NetworkTopologyStrategy",
+ "datacenter1", "3"));
+ Keyspaces keyspaces = Keyspaces.none().with(KeyspaceMetadata.create(keyspaceName, ksParams));
+ DistributedSchema newSchema = new DistributedSchema(keyspaces);
+ metadata = updateMetadata(metadata, newSchema);
+
+ InetAddressAndPort inetAddressAndPort = InetAddressAndPort.getByNameUnchecked("127.0.0.1:" + getStoragePort());
+ InetAddressAndPort i2 = InetAddressAndPort.getByNameUnchecked("127.0.0.2:" + getStoragePort());
+ InetAddressAndPort i3 = InetAddressAndPort.getByNameUnchecked("127.0.0.3:" + getStoragePort());
+
+ Range<Token> tokenRange = new Range<>(metadata.partitioner.getMinimumToken(), metadata.partitioner.getTokenFactory().fromString("0"));
+
+ DataPlacement dataPlacement = DataPlacement.builder()
+ .withWriteReplica(Epoch.FIRST, Replica.fullReplica(inetAddressAndPort, tokenRange))
+ .withWriteReplica(Epoch.FIRST, Replica.fullReplica(i2, tokenRange))
+ .withWriteReplica(Epoch.FIRST, Replica.fullReplica(i3, tokenRange))
+ .withReadReplica(Epoch.FIRST, Replica.fullReplica(inetAddressAndPort, tokenRange))
+ .withReadReplica(Epoch.FIRST, Replica.fullReplica(i2, tokenRange))
+ .withReadReplica(Epoch.FIRST, Replica.fullReplica(i3, tokenRange))
+ .build();
+
+ DataPlacements dataPlacements = DataPlacements.builder(1)
+ .with(ReplicationParams.fromMap(Map.of("class", "NetworkTopologyStrategy",
+ "datacenter1", "3")),
+ dataPlacement)
+ .build();
+ metadata = updateMetadata(metadata, dataPlacements);
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdataplacements",
+ "-f",
+ metadataFile,
+ "-ks",
+ keyspaceName);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStdout()).isNotEmpty();
+ assertThat(result.getStderr()).isNullOrEmpty();
+
+ String stdout = result.getStdout();
+ assertThat(stdout).isEqualTo(
+ "Token Range Type Endpoints\n" +
+ "(-9223372036854775808,0] read /127.0.0.1:" + getStoragePort() + ", /127.0.0.2:" + getStoragePort() + ", /127.0.0.3:" + getStoragePort() + '\n' +
+ "(-9223372036854775808,0] write /127.0.0.1:" + getStoragePort() + ", /127.0.0.2:" + getStoragePort() + ", /127.0.0.3:" + getStoragePort() + '\n');
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testPrintDataPlacementsKeyspaceDoesNotExist() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String keyspaceName = "ks1";
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdataplacements",
+ "-f",
+ metadataFile,
+ "-ks",
+ keyspaceName);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).isNotEmpty();
+ assertThat(result.getStderr()).contains("Keyspace " + keyspaceName + " not found in cluster metadata");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testPrintDirectory() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdirectory",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStdout()).isNotEmpty();
+ assertThat(result.getStderr()).isNullOrEmpty();
+
+ String stdout = result.getStdout();
+
+ String expectedSerializationVersion = NodeVersion.CURRENT.serializationVersion().toString();
+ assertThat(stdout).isEqualTo(
+ "NodeId: 1\n" +
+ " rack rack1\n" +
+ " local_port " + getStoragePort() + '\n' +
+ " broadcast_port " + getStoragePort() + '\n' +
+ " host_id 6d194555-f6eb-41d0-c000-000000000001\n" +
+ " broadcast_address /127.0.0.1\n" +
+ " native_address /127.0.0.1\n" +
+ " native_port " + getStoragePort() + '\n' +
+ " local_address /127.0.0.1\n" +
+ " state JOINED\n" +
+ " serialization_version " + expectedSerializationVersion + '\n' +
+ " cassandra_version " + metadata.directory.version(new NodeId(1)).cassandraVersion + '\n' +
+ " dc datacenter1\n" +
+ " is_cms_member true\n" +
+ "NodeId: 2\n" +
+ " rack rack2\n" +
+ " local_port " + getStoragePort() + '\n' +
+ " broadcast_port " + getStoragePort() + '\n' +
+ " host_id 6d194555-f6eb-41d0-c000-000000000002\n" +
+ " broadcast_address /127.0.0.2\n" +
+ " native_address /127.0.0.2\n" +
+ " native_port " + getStoragePort() + '\n' +
+ " local_address /127.0.0.2\n" +
+ " state JOINED\n" +
+ " serialization_version " + expectedSerializationVersion + '\n' +
+ " cassandra_version " + metadata.directory.version(new NodeId(2)).cassandraVersion + '\n' +
+ " dc datacenter1\n" +
+ " is_cms_member true\n" +
+ "NodeId: 3\n" +
+ " rack rack3\n" +
+ " local_port " + getStoragePort() + '\n' +
+ " broadcast_port " + getStoragePort() + '\n' +
+ " host_id 6d194555-f6eb-41d0-c000-000000000003\n" +
+ " broadcast_address /127.0.0.3\n" +
+ " native_address /127.0.0.3\n" +
+ " native_port " + getStoragePort() + '\n' +
+ " local_address /127.0.0.3\n" +
+ " state JOINED\n" +
+ " serialization_version " + expectedSerializationVersion + '\n' +
+ " cassandra_version " + metadata.directory.version(new NodeId(3)).cassandraVersion + '\n' +
+ " dc datacenter1\n" +
+ " is_cms_member true\n"
+ );
+
+ assertCorrectEnvPostTest();
+ }
+
+ private String dumpMetadata(ClusterMetadata metadata) throws IOException
+ {
+ String tempFile = temporaryFolder.newFile().getAbsolutePath();
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(tempFile))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ NodeVersion.CURRENT.serializationVersion());
+ }
+ return tempFile;
+ }
+
+ /**
+ * Creates a three-node cluster metadata for testing.
+ */
+
+ private ClusterMetadata getThreeNodeClusterMetadata()
+ {
+ return getThreeNodeClusterMetadata(1);
+ }
+
+ private ClusterMetadata getThreeNodeClusterMetadata(int tokenSize)
+ {
+ IPartitioner partitioner = Murmur3Partitioner.instance;
+ NodeId nodeId1 = new NodeId(1);
+ NodeId nodeId2 = new NodeId(2);
+ NodeId nodeId3 = new NodeId(3);
+
+ InetAddressAndPort addr1 = InetAddressAndPort.getByNameUnchecked("127.0.0.1:" + getStoragePort());
+ InetAddressAndPort addr2 = InetAddressAndPort.getByNameUnchecked("127.0.0.2:" + getStoragePort());
+ InetAddressAndPort addr3 = InetAddressAndPort.getByNameUnchecked("127.0.0.3:" + getStoragePort());
+
+ NodeVersion nodeVersion = NodeVersion.CURRENT;
+
+ Directory directory =
+ new Directory()
+ .unsafeWithNodeForTesting(nodeId1, new NodeAddresses(addr1), new Location(DC, "rack1"), nodeVersion)
+ .unsafeWithNodeForTesting(nodeId2, new NodeAddresses(addr2), new Location(DC, "rack2"), nodeVersion)
+ .unsafeWithNodeForTesting(nodeId3, new NodeAddresses(addr3), new Location(DC, "rack3"), nodeVersion)
+ .withRackAndDC(nodeId1)
+ .withRackAndDC(nodeId2)
+ .withRackAndDC(nodeId3);
+
+ KeyspaceParams metaKsParams = KeyspaceParams.create(true,
+ Map.of("class", "MetaStrategy", "datacenter1", "3"));
+ KeyspaceMetadata metaKeyspace = KeyspaceMetadata.create(SchemaConstants.METADATA_KEYSPACE_NAME, metaKsParams);
+ KeyspaceMetadata normalKeyspace = KeyspaceMetadata.create("ks", KeyspaceParams.simple(3));
+ Keyspaces keyspaces = Keyspaces.none().with(metaKeyspace).with(normalKeyspace);
+
+ ClusterMetadata clusterMetadata = getClusterMetadata(keyspaces, partitioner, directory);
+
+
+ ClusterMetadata metadata = clusterMetadata
+ .transformer()
+ .with(directory)
+ .join(nodeId1)
+ .proposeToken(nodeId1, getRandomTokens(partitioner, tokenSize))
+ .join(nodeId2)
+ .proposeToken(nodeId2, getRandomTokens(partitioner, tokenSize))
+ .join(nodeId3)
+ .proposeToken(nodeId3, getRandomTokens(partitioner, tokenSize))
+ .build().metadata;
+
+ // Create replicas for the metadata keyspace on all three nodes
+ ReplicationParams metaParams = ReplicationParams.ntsMeta(Collections.singletonMap(DC, 3));
+ DataPlacements placements = DataPlacements.empty().unbuild()
+ .with(metaParams, getCMSMemberPlacement(metadata, List.of(addr1, addr2, addr3)))
+ .with(ReplicationParams.simple(3), getKeyspacePlacement(metadata, normalKeyspace))
+ .build();
+
+ return updateMetadata(metadata, placements);
+ }
+
+ List<Token> getRandomTokens(IPartitioner partitioner, int size)
+ {
+ List<Token> tokens = new ArrayList<>(size);
+ for (int i = 0; i < size; i++)
+ {
+ tokens.add(partitioner.getRandomToken());
+ }
+
+ return tokens;
+ }
+
+ DataPlacement getCMSMemberPlacement(ClusterMetadata clusterMetadata, List<InetAddressAndPort> inetAddressAndPorts)
+ {
+ IPartitioner partitioner = clusterMetadata.partitioner;
+ // Create replicas for the metadata keyspace on all three nodes
+ Range<Token> fullRange = new Range<>(partitioner.getMinimumToken(), partitioner.getMinimumToken());
+ DataPlacement.Builder placementBuilder = DataPlacement.builder();
+ for (InetAddressAndPort addr : inetAddressAndPorts)
+ {
+ Replica replica = Replica.fullReplica(addr, fullRange);
+ placementBuilder.withReadReplica(Epoch.EMPTY, replica)
+ .withWriteReplica(Epoch.EMPTY, replica);
+ }
+
+ return placementBuilder.build();
+ }
+
+ DataPlacement getKeyspacePlacement(ClusterMetadata metadata, KeyspaceMetadata keyspaceMetadata)
+ {
+ UniformRangePlacement uniformRangePlacement = new UniformRangePlacement();
+ List<Range<Token>> tokenRanges = uniformRangePlacement.calculateRanges(metadata.tokenMap);
+
+ return keyspaceMetadata.replicationStrategy.calculateDataPlacement(metadata.epoch, tokenRanges, metadata);
+ }
+
+ ClusterMetadata getFourNodeMetadata()
+ {
+ return getFourNodeMetadata(1);
+ }
+
+ ClusterMetadata getFourNodeMetadata(int tokenSize)
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ return addNewNode(metadata, 4, new HashSet<>(getRandomTokens(metadata.partitioner, tokenSize)));
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ ClusterMetadata addNewNode(ClusterMetadata prev, int newNodeId, Set<Token> tokens)
+ {
+ NodeId nodeId = new NodeId(newNodeId);
+ Directory directory = prev.directory
+ .unsafeWithNodeForTesting(nodeId, getNodeAddresses(newNodeId),
+ new Location(DC, "rack" + newNodeId),
+ NodeVersion.CURRENT);
Review Comment:
This needs to call `.withRackAndDC` like `getThreeNodeClusterMetadata` does. It's a bit unfortunate that this is the case and that it's so easy to overlook, but this is a super-low level api which is very rarely used outside of tests. There are higher level abstractions in `ClusterMetadataTestHelper` but they modify `ClusterMetadata.current()` rather than working directly on a specific `ClusterMetadata` instance like we're doing here, so I don't think rewriting this test to use them would be productive.
##########
test/unit/org/apache/cassandra/tools/CMSOfflineToolTest.java:
##########
@@ -0,0 +1,1834 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Murmur3Partitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.DistributedSchema;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.KeyspaceParams;
+import org.apache.cassandra.schema.Keyspaces;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.schema.SchemaConstants;
+import org.apache.cassandra.service.accord.AccordFastPath;
+import org.apache.cassandra.service.accord.AccordStaleReplicas;
+import org.apache.cassandra.service.consensus.migration.ConsensusMigrationState;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.Epoch;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.DataPlacements;
+import org.apache.cassandra.tcm.ownership.TokenMap;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.BootstrapAndReplace;
+import org.apache.cassandra.tcm.sequences.InProgressSequences;
+import org.apache.cassandra.tcm.sequences.LeaveStreams;
+import org.apache.cassandra.tcm.sequences.LockedRanges;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.UnbootstrapAndLeave;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.PrepareJoin;
+import org.apache.cassandra.tcm.transformations.PrepareLeave;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.PrepareReplace;
+import org.apache.cassandra.tcm.transformations.Register;
+
+import static org.apache.cassandra.config.DatabaseDescriptor.getStoragePort;
+import static org.apache.cassandra.distributed.test.log.ClusterMetadataTestHelper.prepareJoin;
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+public class CMSOfflineToolTest extends OfflineToolUtils
+{
+
+ public static final String DC = "datacenter1";
+ @Rule
+ public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ private static ClusterMetadata getClusterMetadata(Keyspaces keyspaces, IPartitioner partitioner, Directory directory)
+ {
+ DistributedSchema distributedSchema = new DistributedSchema(keyspaces);
+
+ return new ClusterMetadata(Epoch.EMPTY,
+ partitioner,
+ distributedSchema,
+ directory,
+ new TokenMap(partitioner),
+ DataPlacements.empty(),
+ AccordFastPath.EMPTY,
+ LockedRanges.EMPTY,
+ InProgressSequences.EMPTY,
+ ConsensusMigrationState.EMPTY,
+ ImmutableMap.of(),
+ AccordStaleReplicas.EMPTY);
+ }
+
+ @Before
+ public void setup()
+ {
+ DatabaseDescriptor.toolInitialization();
+ ClusterMetadataService.initializeForTools(true);
+ }
+
+ @Test
+ public void testDefaultCmd()
+ {
+ ToolRunner.ToolResult tool = ToolRunner.invokeClass(CMSOfflineTool.class);
+
+ tool.assertOnCleanExit();
+ assertThat(tool.getExitCode()).isZero();
+ assertThat(tool.getStderr()).isEmpty();
+ assertThat(tool.getStdout()).withFailMessage(tool.getStderr()).contains("Usage");
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testRunCommandThatDoesNotExist()
+ {
+ ToolRunner.ToolResult tool = ToolRunner.invokeClass(CMSOfflineTool.class, "cmddoesnotexist");
+ assertThat(tool.getExitCode()).isEqualTo(2);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testRunCommandWithMetadataFileThatDoesnotExist()
+ {
+ String metadataFile = temporaryFolder.getRoot().getAbsolutePath() + "/file-does-not-exists.dump";
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdirectory",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).isEqualTo(2);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapJoiningNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ int nodeToMove = 4;
+ metadata = startJoining(nodeToMove, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(nodeToMove);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerIds()).doesNotContain(new NodeId(nodeToMove));
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapReplacingNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ NodeId nodeToReplaceId = new NodeId(3);
+ NodeId newNodeId = new NodeId(4);
+ metadata = startReplacing(nodeToReplaceId, newNodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ String newNodeAddr = metadata.directory.getNodeAddresses(newNodeId).nativeAddress.getHostAddressAndPort();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeAddr,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerIds()).doesNotContain(newNodeId);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapNodeGettingReplaced() throws IOException
+ {
+ // Assuming that operator unintentionally invokes abortbootstrap on node getting replaced
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ NodeId nodeToReplaceId = new NodeId(3);
+ NodeId newNodeId = new NodeId(4);
+ metadata = startReplacing(nodeToReplaceId, newNodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ String nodeGettingReplaced = metadata.directory.getNodeAddresses(nodeToReplaceId).nativeAddress.getHostAddressAndPort();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeGettingReplaced,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Did not find any sequences in progress for node " + nodeGettingReplaced);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapWhenLeavingSequenceIsInProgress() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startLeaving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("abortbootstrap is not a valid operation when sequence of kind LEAVE");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapJoinedNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ int nodeId = 4;
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortMoveNoInProgressSequence() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ int nodeId = 4;
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortmove",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("No transformation sequence is in progress for");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortMoveNonMovingSequence() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startLeaving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortmove",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Multi step operation of kind")
+ .contains("LEAVE")
+ .contains("Cannot proceed with abort move");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortMoveMovingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startMoving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortmove",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.inProgressSequences.contains(nodeId)).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortDecommissionNoInProgressSequence() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ int nodeId = 4;
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortdecommission",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("No transformation sequence is in progress for");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortDecommissionNonLeavingSequence() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startMoving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortdecommission",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Multi step operation of kind")
+ .contains("MOVE")
+ .contains("Cannot proceed with abort decommission");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortDecommissionLeavingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startLeaving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortdecommission",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.inProgressSequences.contains(nodeId)).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateCMSMember() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ // Verify that we have CMS members
+ assertThat(metadata.fullCMSMembers()).isNotEmpty();
+
+ // Pick the first CMS member to assassinate
+ NodeId cmsNodeId = metadata.directory.peerIds().stream().findFirst().orElseThrow();
+ assertThat(metadata.isCMSMember(metadata.directory.endpoint(cmsNodeId))).isTrue();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(cmsNodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(cmsNodeId)).isEqualTo(NodeState.LEFT);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateNodeNonCMSMember() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ // Verify that we have CMS members
+ assertThat(metadata.fullCMSMembers()).isNotEmpty();
+
+ // Select non-CMS node
+ NodeId nonCMSNodeId = metadata.directory.peerIds().stream()
+ .filter(id -> !metadata.isCMSMember(metadata.directory.endpoint(id)))
+ .findFirst().orElseThrow();
+ assertThat(metadata.isCMSMember(metadata.directory.endpoint(nonCMSNodeId))).isFalse();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nonCMSNodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(nonCMSNodeId)).isEqualTo(NodeState.LEFT);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateNodeInvalidNodeId() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(-1);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("No node present with id " + nodeId +
+ " in the given cluster metadata");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateMovingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ int nodeToMove = 4;
+ metadata = startMoving(new NodeId(nodeToMove), metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(nodeToMove);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+ assertThat(result.getStdout()).contains("Cancelling in-progress sequence");
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(new NodeId(nodeToMove))).isEqualTo(NodeState.LEFT);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateJoiningNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ int nodeToMove = 4;
+ metadata = startJoining(nodeToMove, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(nodeToMove);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ // It should suggest to use abortbootstrap for node in joining state
+ assertThat(result.getStderr()).contains("abortbootstrap");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateNodeGettingReplaced() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeToReplaceId = new NodeId(4);
+ NodeId newNodeId = new NodeId(5);
+ metadata = startReplacing(nodeToReplaceId, newNodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ String nodeGettingReplaced = metadata.directory.getNodeAddresses(nodeToReplaceId)
+ .nativeAddress.getHostAddressAndPort();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeGettingReplaced,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("INVALID: Rejecting this plan as it interacts with a range locked");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateLeavingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startLeaving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String ip = metadata.directory.endpoint(nodeId).getHostAddressAndPort();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-ip",
+ ip,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+ assertThat(result.getStdout()).contains("Cancelling in-progress sequence");
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(nodeId)).isEqualTo(NodeState.LEFT);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testDescribe() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "describe",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+
+ int storagePort = getStoragePort();
+
+ String expectedOutput =
+ "Cluster Metadata Service:\n" +
+ "Members: /127.0.0.1:" + storagePort + ",/127.0.0.2:" + storagePort + ",/127.0.0.3:" + storagePort + '\n' +
+ "Needs reconfiguration: false\n" +
+ "Service State: LOCAL\n" +
+ "Epoch: 2\n" +
+ "Replication factor: ReplicationParams{class=org.apache.cassandra.locator.MetaStrategy, datacenter1=3}\n";
+ assertThat(result.getStdout()).isEqualTo(expectedOutput);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testDescribeRawString() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "print",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStdout()).isNotEmpty().startsWith("ClusterMetadata{");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMSUsingNodeId() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out" + new Random().nextLong() + ".dump";
+ NodeId nodeId = new NodeId(1);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStderr()).isEmpty();
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ InetAddressAndPort nodeAddress = metadata.directory.getNodeAddresses(nodeId).broadcastAddress;
+ assertThat(outMetadata.isCMSMember(nodeAddress)).isTrue();
+ assertThat(outMetadata.fullCMSMembers().size()).isEqualTo(1);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMS() throws IOException
+ {
+ assertResetCMS("127.0.0.1:" + getStoragePort());
+ }
+
+ @Test
+ public void testResetCMSUsingIp() throws IOException
+ {
+ assertResetCMS("127.0.0.1");
+ }
+
+ private void assertResetCMS(String ipAddress) throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out" + new Random().nextLong() + ".dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-ip",
+ ipAddress,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStderr()).isEmpty();
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+
+ // Check given ip address is added to CMS members
+ InetAddressAndPort candidate = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ assertThat(outMetadata.isCMSMember(candidate)).isTrue();
+ assertThat(outMetadata.fullCMSMembers().size()).isEqualTo(1);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMSUsingIpAddressThatDoesNotExist() throws IOException
+ {
+ // Node that doesn't exist in Cluster Metadata can be added as CMS member
+ String ipAddress = "127.0.0.55:" + getStoragePort();
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-ip",
+ ipAddress,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isNotEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("No node present").contains(ipAddress);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMSInvalidIpAddress() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidIpAddress = "/127.0.0.";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-ip",
+ invalidIpAddress,
+ "-o",
+ outputFile);
+
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).contains("java.net.UnknownHostException");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMSInvalidSerializationVersion() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidIpAddress = "127.0.0.3";
+ String serializationVersion = "-1";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-sv",
+ serializationVersion,
+ "-ip",
+ invalidIpAddress,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Invalid value for option '--serialization-version'");
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveToken() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata(16);
+ String nodeToMove = "127.0.0.4:" + getStoragePort();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeToMove,
+ "-t",
+ String.valueOf(metadata.partitioner.getRandomToken()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("This node has more than one token and cannot be moved thusly.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveTokenVnodes() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata(4);
+ metadata = addNewNode(metadata, 4, Set.of(metadata.partitioner.getRandomToken()));
+ String nodeToMove = "127.0.0.4:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(nodeToMove);
+ NodeId nodeId = metadata.directory.peerId(InetAddressAndPort.getByName(nodeToMove));
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token desiredToken = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeToMove,
+ "-t",
+ String.valueOf(desiredToken.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+ assertThat(outMetadata.directory.peerId(newNodeInetAddress)).isNotNull();
+ assertThat(outMetadata.tokenMap.tokens(nodeId)).contains(desiredToken);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveTokenWithoutToken() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String nodeToMove = "127.0.0.4:" + getStoragePort();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeToMove,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Token required").contains("MOVE");
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveTokenForNonExistingNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String unknownNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token tokenToAssign = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ unknownNodeIpWithPort,
+ "-t",
+ String.valueOf(tokenToAssign.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).contains("No node present with ip address")
+ .contains(unknownNodeIpWithPort);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveInvalidIpAddress() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String invalidIp = "127.0.0.";
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token tokenToAssign = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ invalidIp,
+ "-t",
+ String.valueOf(tokenToAssign.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).contains("java.net.UnknownHostException");
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveNodeToInvalidToken() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0.1:" + getStoragePort();
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidToken = "somegibberishinvalidtoken";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ invalidToken,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveNodeToSomeOtherNodeToken() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0.1:" + getStoragePort();
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ImmutableList<Token> secondNodeTokenList = metadata.tokenMap.tokens(new NodeId(2));
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ secondNodeTokenList.get(0).toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveNodeWhenMoveInProgressToAnotherToken() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ NodeId nodeId = new NodeId(2);
+ Token randomToken = metadata.partitioner.getRandomToken();
+ Set<Token> toTokenSet = Set.of(randomToken);
+ metadata = startMoving(nodeId, metadata, toTokenSet);
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0." + nodeId.id() + ':' + getStoragePort();
+
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ randomToken.toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.tokenMap.tokens(nodeId)).isEqualTo(ImmutableList.of(randomToken));
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveNodeFinishInProgress() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ NodeId nodeId = new NodeId(2);
+ metadata = startMoving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0." + nodeId.id() + ':' + getStoragePort();
+
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Move in progress for another token(s)");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoin() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ assertThat(metadata.directory.states.get(newNodeId)).isEqualTo(NodeState.REGISTERED);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+ assertThat(outMetadata.directory.peerId(newNodeInetAddress)).isNotNull();
+ assertThat(outMetadata.directory.states.get(newNodeId)).isEqualTo(NodeState.JOINED);
+ assertThat(outMetadata.tokenMap.tokens(newNodeId)).isNotEmpty();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinNewNodeWithoutTokens() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ assertThat(metadata.directory.states.get(newNodeId)).isEqualTo(NodeState.REGISTERED);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Tokens must be provided to force join a node.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinUnknownNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ // New node is not registered, it should fail
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isNotEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains(newNodeIpWithPort)
+ .contains("No node present with ip address");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinMovingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startMoving(nodeId, metadata);
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.MOVING);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Another sequence of kind MOVE is in progress for node " + nodeId.id() +
+ ". Cannot proceed with force join.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinAlreadyJoinedNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.JOINED);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Node " + nodeId.id() + " is already in JOINED state.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinLeftNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ int id = 4;
+ NodeId nodeId = new NodeId(id);
+ metadata = startLeaving(nodeId, metadata);
+ UnbootstrapAndLeave unbootstrapAndLeave = (UnbootstrapAndLeave) metadata.inProgressSequences.get(nodeId);
+ metadata = unbootstrapAndLeave.applyTo(metadata).success().metadata;
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.LEFT);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(nodeId)).isEqualTo(NodeState.JOINED);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinJoiningNodeTokenMismatch() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ int nodeNum = 4;
+ metadata = startJoining(nodeNum, metadata);
+ NodeId nodeId = new NodeId(nodeNum);
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.BOOTSTRAPPING);
+
+ BootstrapAndJoin bootstrapAndJoin = (BootstrapAndJoin) metadata.inProgressSequences.get(nodeId);
+ Set<Token> sequenceTokens = bootstrapAndJoin.finishJoin.tokens;
+
+ // Pick a token that is different from the in-progress sequence tokens
+ Token differentToken = metadata.partitioner.getRandomToken();
+ while (sequenceTokens.contains(differentToken))
+ differentToken = metadata.partitioner.getRandomToken();
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-t",
+ differentToken.toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("do not match with")
+ .contains("in progress BootstrapAndJoin sequence tokens");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinJoiningNodeTokenMatch() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ int nodeNum = 4;
+ metadata = startJoining(nodeNum, metadata);
+ NodeId nodeId = new NodeId(nodeNum);
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.BOOTSTRAPPING);
+
+ BootstrapAndJoin bootstrapAndJoin = (BootstrapAndJoin) metadata.inProgressSequences.get(nodeId);
+ Set<Token> sequenceTokens = bootstrapAndJoin.finishJoin.tokens;
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ // Build args: one -t per token in the sequence
+ List<String> args = new ArrayList<>(List.of("forcejoin", "-f", metadataFile,
+ "-id", String.valueOf(nodeId.id()),
+ "-o", outputFile));
+ for (Token t : sequenceTokens)
+ {
+ args.add("-t");
+ args.add(t.toString());
+ }
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ args.toArray(new String[0]));
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(nodeId)).isEqualTo(NodeState.JOINED);
+ assertThat(outMetadata.tokenMap.tokens(nodeId)).containsAll(sequenceTokens);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinNodeThatDoesNotExist() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = "-1";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).isNotEmpty();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinWithSerializationVersion() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ assertThat(metadata.directory.states.get(newNodeId)).isEqualTo(NodeState.REGISTERED);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ // Use V7 — a known valid version older than the current (V8)
+ Version targetVersion = Version.V7;
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-sv",
+ targetVersion.toString(),
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ // Verify that the output file was serialized using the requested version
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(outputFile))
+ {
+ int versionInt = fisp.readUnsignedVInt32();
+ assertThat(Version.fromInt(versionInt)).isEqualTo(targetVersion);
+ }
+
+ // Verify the node was force-joined in the output metadata
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(newNodeId)).isEqualTo(NodeState.JOINED);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinInvalidSerializationVersion() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidSerializationVersion = "-1";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-sv",
+ invalidSerializationVersion,
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Invalid value for option '--serialization-version'");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testPrintDataPlacements() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String keyspaceName = "ks1";
+ KeyspaceParams ksParams = KeyspaceParams.create(true,
+ Map.of("class", "NetworkTopologyStrategy",
+ "datacenter1", "3"));
+ Keyspaces keyspaces = Keyspaces.none().with(KeyspaceMetadata.create(keyspaceName, ksParams));
+ DistributedSchema newSchema = new DistributedSchema(keyspaces);
+ metadata = updateMetadata(metadata, newSchema);
+
+ InetAddressAndPort inetAddressAndPort = InetAddressAndPort.getByNameUnchecked("127.0.0.1:" + getStoragePort());
+ InetAddressAndPort i2 = InetAddressAndPort.getByNameUnchecked("127.0.0.2:" + getStoragePort());
+ InetAddressAndPort i3 = InetAddressAndPort.getByNameUnchecked("127.0.0.3:" + getStoragePort());
+
+ Range<Token> tokenRange = new Range<>(metadata.partitioner.getMinimumToken(), metadata.partitioner.getTokenFactory().fromString("0"));
+
+ DataPlacement dataPlacement = DataPlacement.builder()
+ .withWriteReplica(Epoch.FIRST, Replica.fullReplica(inetAddressAndPort, tokenRange))
+ .withWriteReplica(Epoch.FIRST, Replica.fullReplica(i2, tokenRange))
+ .withWriteReplica(Epoch.FIRST, Replica.fullReplica(i3, tokenRange))
+ .withReadReplica(Epoch.FIRST, Replica.fullReplica(inetAddressAndPort, tokenRange))
+ .withReadReplica(Epoch.FIRST, Replica.fullReplica(i2, tokenRange))
+ .withReadReplica(Epoch.FIRST, Replica.fullReplica(i3, tokenRange))
+ .build();
+
+ DataPlacements dataPlacements = DataPlacements.builder(1)
+ .with(ReplicationParams.fromMap(Map.of("class", "NetworkTopologyStrategy",
+ "datacenter1", "3")),
+ dataPlacement)
+ .build();
+ metadata = updateMetadata(metadata, dataPlacements);
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdataplacements",
+ "-f",
+ metadataFile,
+ "-ks",
+ keyspaceName);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStdout()).isNotEmpty();
+ assertThat(result.getStderr()).isNullOrEmpty();
+
+ String stdout = result.getStdout();
+ assertThat(stdout).isEqualTo(
+ "Token Range Type Endpoints\n" +
+ "(-9223372036854775808,0] read /127.0.0.1:" + getStoragePort() + ", /127.0.0.2:" + getStoragePort() + ", /127.0.0.3:" + getStoragePort() + '\n' +
+ "(-9223372036854775808,0] write /127.0.0.1:" + getStoragePort() + ", /127.0.0.2:" + getStoragePort() + ", /127.0.0.3:" + getStoragePort() + '\n');
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testPrintDataPlacementsKeyspaceDoesNotExist() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String keyspaceName = "ks1";
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdataplacements",
+ "-f",
+ metadataFile,
+ "-ks",
+ keyspaceName);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).isNotEmpty();
+ assertThat(result.getStderr()).contains("Keyspace " + keyspaceName + " not found in cluster metadata");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testPrintDirectory() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdirectory",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStdout()).isNotEmpty();
+ assertThat(result.getStderr()).isNullOrEmpty();
+
+ String stdout = result.getStdout();
+
+ String expectedSerializationVersion = NodeVersion.CURRENT.serializationVersion().toString();
+ assertThat(stdout).isEqualTo(
+ "NodeId: 1\n" +
+ " rack rack1\n" +
+ " local_port " + getStoragePort() + '\n' +
+ " broadcast_port " + getStoragePort() + '\n' +
+ " host_id 6d194555-f6eb-41d0-c000-000000000001\n" +
+ " broadcast_address /127.0.0.1\n" +
+ " native_address /127.0.0.1\n" +
+ " native_port " + getStoragePort() + '\n' +
+ " local_address /127.0.0.1\n" +
+ " state JOINED\n" +
+ " serialization_version " + expectedSerializationVersion + '\n' +
+ " cassandra_version " + metadata.directory.version(new NodeId(1)).cassandraVersion + '\n' +
+ " dc datacenter1\n" +
+ " is_cms_member true\n" +
+ "NodeId: 2\n" +
+ " rack rack2\n" +
+ " local_port " + getStoragePort() + '\n' +
+ " broadcast_port " + getStoragePort() + '\n' +
+ " host_id 6d194555-f6eb-41d0-c000-000000000002\n" +
+ " broadcast_address /127.0.0.2\n" +
+ " native_address /127.0.0.2\n" +
+ " native_port " + getStoragePort() + '\n' +
+ " local_address /127.0.0.2\n" +
+ " state JOINED\n" +
+ " serialization_version " + expectedSerializationVersion + '\n' +
+ " cassandra_version " + metadata.directory.version(new NodeId(2)).cassandraVersion + '\n' +
+ " dc datacenter1\n" +
+ " is_cms_member true\n" +
+ "NodeId: 3\n" +
+ " rack rack3\n" +
+ " local_port " + getStoragePort() + '\n' +
+ " broadcast_port " + getStoragePort() + '\n' +
+ " host_id 6d194555-f6eb-41d0-c000-000000000003\n" +
+ " broadcast_address /127.0.0.3\n" +
+ " native_address /127.0.0.3\n" +
+ " native_port " + getStoragePort() + '\n' +
+ " local_address /127.0.0.3\n" +
+ " state JOINED\n" +
+ " serialization_version " + expectedSerializationVersion + '\n' +
+ " cassandra_version " + metadata.directory.version(new NodeId(3)).cassandraVersion + '\n' +
+ " dc datacenter1\n" +
+ " is_cms_member true\n"
+ );
+
+ assertCorrectEnvPostTest();
+ }
+
+ private String dumpMetadata(ClusterMetadata metadata) throws IOException
+ {
+ String tempFile = temporaryFolder.newFile().getAbsolutePath();
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(tempFile))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ NodeVersion.CURRENT.serializationVersion());
+ }
+ return tempFile;
+ }
+
+ /**
+ * Creates a three-node cluster metadata for testing.
+ */
+
+ private ClusterMetadata getThreeNodeClusterMetadata()
+ {
+ return getThreeNodeClusterMetadata(1);
+ }
+
+ private ClusterMetadata getThreeNodeClusterMetadata(int tokenSize)
+ {
+ IPartitioner partitioner = Murmur3Partitioner.instance;
+ NodeId nodeId1 = new NodeId(1);
+ NodeId nodeId2 = new NodeId(2);
+ NodeId nodeId3 = new NodeId(3);
+
+ InetAddressAndPort addr1 = InetAddressAndPort.getByNameUnchecked("127.0.0.1:" + getStoragePort());
+ InetAddressAndPort addr2 = InetAddressAndPort.getByNameUnchecked("127.0.0.2:" + getStoragePort());
+ InetAddressAndPort addr3 = InetAddressAndPort.getByNameUnchecked("127.0.0.3:" + getStoragePort());
+
+ NodeVersion nodeVersion = NodeVersion.CURRENT;
+
+ Directory directory =
Review Comment:
nit: something got messed up with the indentation in the default coding style so it keeps formatting things like this. I'm not suggesting you fix that, but would you mind if I fix up the formatting a bit on commit?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org
Re: [PR] CASSANDRA-19151 Adding offline tool for cluster metadata [cassandra]
Posted by "nvharikrishna (via GitHub)" <gi...@apache.org>.
nvharikrishna commented on code in PR #4149:
URL: https://github.com/apache/cassandra/pull/4149#discussion_r2079923573
##########
test/unit/org/apache/cassandra/tools/CMSOfflineToolTest.java:
##########
@@ -0,0 +1,679 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Collections;
+import java.util.Map;
+import java.util.UUID;
+
+import com.google.common.collect.ImmutableMap;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Murmur3Partitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.DistributedSchema;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.KeyspaceParams;
+import org.apache.cassandra.schema.Keyspaces;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.schema.SchemaConstants;
+import org.apache.cassandra.service.accord.AccordFastPath;
+import org.apache.cassandra.service.accord.AccordStaleReplicas;
+import org.apache.cassandra.service.consensus.migration.ConsensusMigrationState;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.Epoch;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.DataPlacements;
+import org.apache.cassandra.tcm.ownership.TokenMap;
+import org.apache.cassandra.tcm.sequences.InProgressSequences;
+import org.apache.cassandra.tcm.sequences.LockedRanges;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.utils.CassandraVersion;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+public class CMSOfflineToolTest extends OfflineToolUtils
+{
+
+ @Rule
+ public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Test
+ public void testDefaultCmd()
+ {
+
+ ToolRunner.ToolResult tool = ToolRunner.invokeClass(CMSOfflineTool.class);
+
+ tool.assertOnCleanExit();
+ assertThat(tool.getExitCode()).isZero();
+ assertThat(tool.getStdout()).contains("usage");
+ assertThat(tool.getStderr()).isEmpty();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testRunCommandThatDoesNotExist()
+ {
+ ToolRunner.ToolResult tool = ToolRunner.invokeClass(CMSOfflineTool.class, "cmddoesnotexist");
+ assertThat(tool.getExitCode()).isOne();
+ }
+
+ @Test
+ public void testRunCommandWithMetadataFileThatDoesnotExist()
+ {
+ String metadataFile = temporaryFolder.getRoot().getAbsolutePath() + "/file-does-not-exists.dump";
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdirectory",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).isEqualTo(2);
+ }
+
+ @Test
+ public void testaddToCMS() throws IOException
+ {
+ assertAddToCMS("127.0.0.1:7000");
+ }
+
+ @Test
+ public void testaddToCMSIpAddressAlone() throws IOException
+ {
+ assertAddToCMS("127.0.0.1");
+ }
+
+ @Test
+ public void testaddToCMSNodeThatDoesNotExistEarlier() throws IOException
+ {
+ // Node that doesn't exist in Cluster Metadata can be added as CMS member
+ assertAddToCMS("127.0.0.55:7000");
Review Comment:
Not sure if it is the expected behavior. It worked with the minimal cluster metadata created for unit testing, so kept it. Can change if it is not intended.
##########
test/unit/org/apache/cassandra/tools/CMSOfflineToolTest.java:
##########
@@ -0,0 +1,679 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Collections;
+import java.util.Map;
+import java.util.UUID;
+
+import com.google.common.collect.ImmutableMap;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Murmur3Partitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.DistributedSchema;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.KeyspaceParams;
+import org.apache.cassandra.schema.Keyspaces;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.schema.SchemaConstants;
+import org.apache.cassandra.service.accord.AccordFastPath;
+import org.apache.cassandra.service.accord.AccordStaleReplicas;
+import org.apache.cassandra.service.consensus.migration.ConsensusMigrationState;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.Epoch;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.DataPlacements;
+import org.apache.cassandra.tcm.ownership.TokenMap;
+import org.apache.cassandra.tcm.sequences.InProgressSequences;
+import org.apache.cassandra.tcm.sequences.LockedRanges;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.utils.CassandraVersion;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+public class CMSOfflineToolTest extends OfflineToolUtils
+{
+
+ @Rule
+ public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Test
+ public void testDefaultCmd()
+ {
+
+ ToolRunner.ToolResult tool = ToolRunner.invokeClass(CMSOfflineTool.class);
+
+ tool.assertOnCleanExit();
+ assertThat(tool.getExitCode()).isZero();
+ assertThat(tool.getStdout()).contains("usage");
+ assertThat(tool.getStderr()).isEmpty();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testRunCommandThatDoesNotExist()
+ {
+ ToolRunner.ToolResult tool = ToolRunner.invokeClass(CMSOfflineTool.class, "cmddoesnotexist");
+ assertThat(tool.getExitCode()).isOne();
+ }
+
+ @Test
+ public void testRunCommandWithMetadataFileThatDoesnotExist()
+ {
+ String metadataFile = temporaryFolder.getRoot().getAbsolutePath() + "/file-does-not-exists.dump";
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdirectory",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).isEqualTo(2);
+ }
+
+ @Test
+ public void testaddToCMS() throws IOException
+ {
+ assertAddToCMS("127.0.0.1:7000");
+ }
+
+ @Test
+ public void testaddToCMSIpAddressAlone() throws IOException
+ {
+ assertAddToCMS("127.0.0.1");
Review Comment:
Not sure if it is expected.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org
Re: [PR] CASSANDRA-19151 Adding offline tool for cluster metadata [cassandra]
Posted by "nvharikrishna (via GitHub)" <gi...@apache.org>.
nvharikrishna commented on PR #4149:
URL: https://github.com/apache/cassandra/pull/4149#issuecomment-3139703727
> the patch needs to be rewritten to use picocli instead of airline
Yeah, observed it while rebasing. Working on it.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org
Re: [PR] CASSANDRA-19151 Adding offline tool for cluster metadata [cassandra]
Posted by "ifesdjeen (via GitHub)" <gi...@apache.org>.
ifesdjeen commented on code in PR #4149:
URL: https://github.com/apache/cassandra/pull/4149#discussion_r2230722794
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ipAddress;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ metadata = makeCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata makeCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ DataPlacement.Builder builder = metadata.placements.get(metaParams).unbuild();
Review Comment:
maybe worth adding a check if already CMS?
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ipAddress;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ metadata = makeCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata makeCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ DataPlacement.Builder builder = metadata.placements.get(metaParams).unbuild();
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ builder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+ return metadata.transformer().with(metadata.placements.unbuild().with(metaParams,
+ builder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ @Command(name = "assigntokens", description = "Assigns a token for given instance")
+ public static class AssignTokens extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ip;
+
+ @Option(name = { "-t", "--token" }, description = "Token to assign. Pass it multiple times to assign multiple tokens to node.", required = true)
+ private List<String> tokenList = new ArrayList<>();
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ip);
+ NodeId nodeId = metadata.directory.peerId(nodeAddress);
+ if (nodeId == null)
+ {
+ throw new IllegalArgumentException("Cassandra node with address " + ip + " does not exist.");
+ }
+
+ Token.TokenFactory tokenFactory = metadata.partitioner.getTokenFactory();
+ List<Token> tokens = tokenList.stream().map(tokenFactory::fromString).collect(Collectors.toList());
+ ClusterMetadata updateMetadata = metadata.transformer().proposeToken(nodeId, tokens).build().metadata;
+ writeMetadata(output, updateMetadata, outputFilePath);
+ }
+ }
+
+ @Command(name = "describe", description = "Describes the cluster metadata")
+ public static class Describe extends ClusterMetadataToolCmd
+ {
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ String members = metadata.fullCMSMembers().stream().sorted().map(Object::toString).collect(Collectors.joining(","));
+ output.out.printf("Cluster Metadata Service:%n");
+ output.out.printf("Members: %s%n", members);
+ output.out.printf("Needs reconfiguration: %s%n", needsReconfiguration(metadata));
+ output.out.printf("Service State: %s%n", ClusterMetadataService.state(metadata));
+ output.out.printf("Epoch: %s%n", metadata.epoch.getEpoch());
+ output.out.printf("Replication factor: %s%n", ReplicationParams.meta(metadata).toString());
+ }
+ }
+
+ @Command(name = "forcejoin", description = "Forces a node to move to JOINED stated")
+ public static class ForceJoin extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-id", "--node-id" }, description = "Node ID. It can be integer ID assigned to node or the node uuid", required = true)
+ private String id;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = NodeId.fromString(id);
+
+ if (!metadata.directory.peerIds().contains(nodeId))
+ {
+ throw new IllegalArgumentException("Node with id " + id + " does not exist.");
+ }
+
+ ClusterMetadata updatedMetadata = metadata.transformer().join(nodeId).build().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ @Command(name = "forgetnode", description = "Removes a nodes from given cluster metadata")
+ public static class ForgetNode extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-id", "--node-id" }, description = "Node ID to forget. It can be UUID of node as well.", required = true)
+ private String id;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = NodeId.fromString(id);
+
+ if (!metadata.directory.peerIds().contains(nodeId))
+ {
+ throw new IllegalArgumentException("Node with id " + id + " does not exist.");
+ }
+
+ ClusterMetadata updatedMetadata = metadata.transformer().unregister(nodeId).build().metadata;
+ output.out.println("Successfully forgot node having id " + id);
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ @Command(name = "printdirectory", description = "Prints directory information in cluster metadata file")
+ public static class PrintDirectoryCmd extends ClusterMetadataToolCmd
+ {
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ Directory directory = metadata.directory;
+ Set<NodeId> nodeIdList = directory.peerIds();
+ for (NodeId nodeId : nodeIdList)
+ {
+ NodeAddresses nodeAddresses = directory.getNodeAddresses(nodeId);
+ Location location = directory.location(nodeId);
+ output.out.println("NodeId: " + nodeId.id());
+ String format = " %-22s%s\n";
+ output.out.printf(format, "rack", location.rack);
+ output.out.printf(format, "local_port", nodeAddresses.localAddress.getPort());
+ output.out.printf(format, "broadcast_port", nodeAddresses.broadcastAddress.getPort());
+ output.out.printf(format, "host_id", nodeId.toUUID());
+ output.out.printf(format, "broadcast_address", nodeAddresses.broadcastAddress.getAddress().toString());
+ output.out.printf(format, "native_address", nodeAddresses.nativeAddress.getAddress().toString());
+ output.out.printf(format, "native_port", nodeAddresses.nativeAddress.getPort());
+ output.out.printf(format, "local_address", nodeAddresses.localAddress.getAddress().toString());
+ output.out.printf(format, "state", directory.peerState(nodeId));
+ output.out.printf(format, "serialization_version", directory.version(nodeId).serializationVersion());
+ output.out.printf(format, "cassandra_version", directory.version(nodeId).cassandraVersion);
+ output.out.printf(format, "dc", location.datacenter);
+ output.out.printf(format, "is_cms_member", metadata.isCMSMember(nodeAddresses.broadcastAddress));
+ }
+ }
+ }
+
+ @Command(name = "printdataplacements", description = "Prints data placements in cluster medata file")
Review Comment:
nit: typo in medata
##########
test/unit/org/apache/cassandra/tools/CMSOfflineToolTest.java:
##########
@@ -0,0 +1,688 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Collections;
+import java.util.Map;
+import java.util.UUID;
+
+import com.google.common.collect.ImmutableMap;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Murmur3Partitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.DistributedSchema;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.KeyspaceParams;
+import org.apache.cassandra.schema.Keyspaces;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.schema.SchemaConstants;
+import org.apache.cassandra.service.accord.AccordFastPath;
+import org.apache.cassandra.service.accord.AccordStaleReplicas;
+import org.apache.cassandra.service.consensus.migration.ConsensusMigrationState;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.Epoch;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.DataPlacements;
+import org.apache.cassandra.tcm.ownership.TokenMap;
+import org.apache.cassandra.tcm.sequences.InProgressSequences;
+import org.apache.cassandra.tcm.sequences.LockedRanges;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.utils.CassandraVersion;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+public class CMSOfflineToolTest extends OfflineToolUtils
+{
+
+ @Rule
+ public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Test
+ public void testDefaultCmd()
+ {
+
+ ToolRunner.ToolResult tool = ToolRunner.invokeClass(CMSOfflineTool.class);
+
+ tool.assertOnCleanExit();
+ assertThat(tool.getExitCode()).isZero();
+ assertThat(tool.getStdout()).contains("usage");
+ assertThat(tool.getStderr()).isEmpty();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testRunCommandThatDoesNotExist()
+ {
+ ToolRunner.ToolResult tool = ToolRunner.invokeClass(CMSOfflineTool.class, "cmddoesnotexist");
+ assertThat(tool.getExitCode()).isOne();
+ }
+
+ @Test
+ public void testRunCommandWithMetadataFileThatDoesnotExist()
+ {
+ String metadataFile = temporaryFolder.getRoot().getAbsolutePath() + "/file-does-not-exists.dump";
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdirectory",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).isEqualTo(2);
+ }
+
+ @Test
+ public void testaddToCMS() throws IOException
+ {
+ assertAddToCMS("127.0.0.1:7000");
+ }
+
+ @Test
+ public void testaddToCMSIpAddressAlone() throws IOException
+ {
+ assertAddToCMS("127.0.0.1");
+ }
+
+ @Test
+ public void testaddToCMSNodeThatDoesNotExistEarlier() throws IOException
+ {
+ // Node that doesn't exist in Cluster Metadata can be added as CMS member
+ assertAddToCMS("127.0.0.55:7000");
+ }
+
+ private void assertAddToCMS(String ipAddress) throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "addtocms",
+ "-f",
+ metadataFile,
+ "-ip",
+ ipAddress,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+
+ // Check given ip address is added to CMS members
+ InetAddressAndPort candidate = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ assertThat(outMetadata.isCMSMember(candidate)).isTrue();
+
+ // Check existing nodes retained as CMS members
+ ClusterMetadata inMetadata = deserializeMetadata(metadataFile);
+ for (InetAddressAndPort existingMember : inMetadata.fullCMSMembers())
+ {
+ assertThat(inMetadata.isCMSMember(existingMember)).isTrue();
+ }
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testaddToCMSInvalidIpAddress() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidIpAddress = "/127.0.0.";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "addtocms",
+ "-f",
+ metadataFile,
+ "-ip",
+ invalidIpAddress,
+ "-o",
+ outputFile);
+
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getExitCode()).isEqualTo(2);
+ assertThat(result.getStderr()).contains("java.net.UnknownHostException");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testaddToCMSInvalidSerializationVersion() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidIpAddress = "127.0.0.3";
+ String serializationVersion = "-1";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "addtocms",
+ "-f",
+ metadataFile,
+ "-sv",
+ serializationVersion,
+ "-ip",
+ invalidIpAddress,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(1);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("cmsofflinetool: serializationVersion: can not convert \"" +
+ serializationVersion + "\" to a Version");
+ }
+
+ @Test
+ public void testAssignTokens() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.4:7000";
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token token1ToAssign = metadata.partitioner.getRandomToken();
+ Token token2ToAssign = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assigntokens",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ String.valueOf(token1ToAssign.getTokenValue()),
+ "--token",
+ String.valueOf(token2ToAssign.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+ assertThat(outMetadata.directory.peerId(newNodeInetAddress)).isNotNull();
+ assertThat(outMetadata.tokenMap.tokens(newNodeId)).contains(token1ToAssign)
+ .contains(token2ToAssign);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssignTokensForNonExistingNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String unknownNodeIpWithPort = "127.0.0.5:7000";
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token tokenToAssign = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assigntokens",
+ "-f",
+ metadataFile,
+ "-ip",
+ unknownNodeIpWithPort,
+ "-t",
+ String.valueOf(tokenToAssign.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).contains(" Cassandra node with address " + unknownNodeIpWithPort
+ + " does not exist.");
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssignTokensInvalidIpAddress() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String invalidIp = "127.0.0.";
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token tokenToAssign = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assigntokens",
+ "-f",
+ metadataFile,
+ "-ip",
+ invalidIp,
+ "-t",
+ String.valueOf(tokenToAssign.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).contains("java.net.UnknownHostException");
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssignTokensInvalidToken() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0.1:7000";
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidToken = "somegibberishinvalidtoken";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assigntokens",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ invalidToken,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoin() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.4:7000";
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ assertThat(metadata.directory.states.get(newNodeId)).isNotEqualTo(NodeState.JOINED);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+ assertThat(outMetadata.directory.peerId(newNodeInetAddress)).isNotNull();
+ assertThat(outMetadata.directory.states.get(newNodeId)).isEqualTo(NodeState.JOINED);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinNodeThatDoesNotExist() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = "-1";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).isNotEmpty();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForgetNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+
+ String joinedNodeIpWithPort = "127.0.0.4:7000";
+ InetAddressAndPort joiningNodeInetAddress = InetAddressAndPort.getByNameUnchecked(joinedNodeIpWithPort);
+ NodeId joinedNodeId = NodeId.fromUUID(new UUID(0, 4L));
+
+ Directory newDirectory = metadata.directory
+ .with(joinedNodeId,
+ new NodeAddresses(joiningNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT)
+ .withNodeState(joinedNodeId, NodeState.JOINED);
+
+ TokenMap tokenMap = new TokenMap(metadata.partitioner)
+ .assignTokens(joinedNodeId, Collections.singleton(metadata.partitioner.getRandomToken()));
+
+ metadata = updateMetadata(metadata, newDirectory);
+ metadata = updateMetadata(metadata, tokenMap);
+
+ assertThat(metadata.tokenMap.tokens(joinedNodeId)).isNotEmpty();
+
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forgetnode",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(joinedNodeId.id()),
+ "-o",
+ outputFile);
+
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+ assertThat(outMetadata.directory.peerIds()).doesNotContain(joinedNodeId);
+ assertThat(outMetadata.tokenMap.tokens(joinedNodeId)).isEmpty();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForgetNodeInvalidNodeId() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(-1);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forgetnode",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Node with id " + nodeId + " does not exist.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testPrintDataPlacements() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String keyspaceName = "ks1";
+ KeyspaceParams ksParams = KeyspaceParams.create(true,
+ Map.of("class", "NetworkTopologyStrategy",
+ "datacenter1", "3"));
+ Keyspaces keyspaces = Keyspaces.none().with(KeyspaceMetadata.create(keyspaceName, ksParams));
+ DistributedSchema newSchema = new DistributedSchema(keyspaces);
+ metadata = updateMetadata(metadata, newSchema);
+
+ InetAddressAndPort inetAddressAndPort = InetAddressAndPort.getByNameUnchecked("127.0.0.1:7000");
+ InetAddressAndPort i2 = InetAddressAndPort.getByNameUnchecked("127.0.0.2:7000");
+ InetAddressAndPort i3 = InetAddressAndPort.getByNameUnchecked("127.0.0.3:7000");
+
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(metadata.partitioner);
+
+ DataPlacement dataPlacement = DataPlacement.builder()
+ .withWriteReplica(Epoch.FIRST, Replica.fullReplica(inetAddressAndPort, new Range<>(metadata.partitioner.getMinimumToken(), metadata.partitioner.getTokenFactory().fromString("0"))))
+ .withWriteReplica(Epoch.FIRST, Replica.fullReplica(i2, new Range<>(metadata.partitioner.getMinimumToken(), metadata.partitioner.getTokenFactory().fromString("0"))))
Review Comment:
nit: new Range<>(metadata.partitioner.getMinimumToken(), metadata.partitioner.getTokenFactory().fromString("0") can just be created once
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ipAddress;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ metadata = makeCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata makeCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ DataPlacement.Builder builder = metadata.placements.get(metaParams).unbuild();
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ builder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+ return metadata.transformer().with(metadata.placements.unbuild().with(metaParams,
+ builder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ @Command(name = "assigntokens", description = "Assigns a token for given instance")
+ public static class AssignTokens extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ip;
+
+ @Option(name = { "-t", "--token" }, description = "Token to assign. Pass it multiple times to assign multiple tokens to node.", required = true)
+ private List<String> tokenList = new ArrayList<>();
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ip);
+ NodeId nodeId = metadata.directory.peerId(nodeAddress);
+ if (nodeId == null)
+ {
+ throw new IllegalArgumentException("Cassandra node with address " + ip + " does not exist.");
+ }
+
+ Token.TokenFactory tokenFactory = metadata.partitioner.getTokenFactory();
+ List<Token> tokens = tokenList.stream().map(tokenFactory::fromString).collect(Collectors.toList());
+ ClusterMetadata updateMetadata = metadata.transformer().proposeToken(nodeId, tokens).build().metadata;
Review Comment:
should we add a check for whether this token is already in use?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org
Re: [PR] CASSANDRA-19151 Adding offline tool for cluster metadata [cassandra]
Posted by "nvharikrishna (via GitHub)" <gi...@apache.org>.
nvharikrishna commented on code in PR #4149:
URL: https://github.com/apache/cassandra/pull/4149#discussion_r2913723350
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
+ if (serializationVersion != null)
+ {
+ // Not a good idea to write metadata using lower serialization version. Ref: CASSANDRA-21174
+ if (currentVersion.isBefore(serializationVersion))
+ {
+ throw new IllegalArgumentException("Given serialization version " + serializationVersion +
+ " is newer compared to current version " + currentVersion + '.');
+ }
+
+ return serializationVersion;
+ }
+
+ Version metadataVersion = metadata.directory.commonSerializationVersion;
+ if (!currentVersion.isAtLeast(metadataVersion))
+ {
+ throw new IllegalArgumentException("Current version " + currentVersion +
+ " is older than version in cluster metadata (" +
+ metadataVersion + "). Cannot proceed further. " +
+ "Try modifying cluster metadata using binaries that support " +
+ "minimum serialization version: " + metadataVersion + '.');
+ }
+
+ return metadataVersion;
+ }
+ }
+
+ /**
+ * Cancels a JOIN or REPLACE bootstrap sequence for the given node and unregisters it
+ * from the cluster. Use this when a node is stuck in bootstrapping or replacement.
+ * Fails if no in-progress sequence exists, or if the sequence is not of kind JOIN or REPLACE.
+ */
+ @Command(name = "abortbootstrap",
+ description = "Aborts bootstrap for given node if in progress.")
+ public static class AbortBootstrap extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated cluster metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ if (!metadata.inProgressSequences.contains(nodeId))
+ {
+ throw new IllegalArgumentException("Did not find any sequences in progress for node " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (sequenceKind != MultiStepOperation.Kind.JOIN
+ && sequenceKind != MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("abortbootstrap is not a valid operation when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+
+ // Cancelling the sequence is not enough, but we need to unregister as well
+ Unregister unregister = new Unregister(nodeId, EnumSet.of(NodeState.REGISTERED),
+ ClusterMetadataService.instance().placementProvider());
+
+ updatedMetadata = unregister.execute(updatedMetadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress MOVE sequence for the given node, returning it to its
+ * pre-move token assignment. Fails if no in-progress sequence exists, or if the
+ * sequence is not of kind MOVE.
+ */
+ @Command(name = "abortmove", description = "Aborts in progress move sequence for given node.")
+ static class AbortMove extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.MOVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort move.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress LEAVE (decommission) sequence for the given node, keeping
+ * it as an active member of the ring. Fails if no in-progress sequence exists, or if
+ * the sequence is not of kind LEAVE.
+ */
+ @Command(name = "abortdecommission", description = "Aborts in progress decommission sequence for given node.")
+ static class AbortDecommission extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.LEAVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort decommission.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Removes a node from cluster metadata by applying the {@link Assassinate} transformation.
+ * If a MOVE or LEAVE sequence is in progress for the node, it is cancelled first.
+ * If the node is a CMS member, it is removed from CMS before assassination.
+ * Fails if the node is in a JOIN or REPLACE sequence; use {@code abortbootstrap} instead.
+ */
+ @Command(name = "assassinate", description = "Assassinates given node from Cluster metadata.")
+ static class AssassinateNode extends ClusterMetadataToolCmd
+ {
+ private final EnumSet<MultiStepOperation.Kind> supportedCancelSequences =
+ EnumSet.of(MultiStepOperation.Kind.MOVE, MultiStepOperation.Kind.LEAVE);
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ // Check if there are any in-progress sequences for given node
+ // If any, then cancel the sequence and then assassinate it
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (!supportedCancelSequences.contains(sequenceKind))
+ {
+ if (sequenceKind == MultiStepOperation.Kind.JOIN || sequenceKind == MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
Review Comment:
Assassinate is internally calling PrepareLeave, which checks if the node is in the JOINING state ([ref](https://github.com/apache/cassandra/blob/trunk/src/java/org/apache/cassandra/tcm/transformations/PrepareLeave.java#L92)). After cancelling the in-progress BootstrapAndJoin/BootstrapAndReplace sequence, node comes to the REGISTERED state. We just need to unregister it. So, suggesting to run `abortbootstrap` instead of `assassinate`.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org
Re: [PR] CASSANDRA-19151 Adding offline tool for cluster metadata [cassandra]
Posted by "beobal (via GitHub)" <gi...@apache.org>.
beobal commented on code in PR #4149:
URL: https://github.com/apache/cassandra/pull/4149#discussion_r2230736993
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
Review Comment:
I know we did this elsewhere in `TransformClusterMetadataHelper` but I think we should use this version to deserialize the partitioner.
We may want to specify a different version to for the commands which write an updated CM back out, but I'm not sure whether the best default is `NodeVersion.CURRENT.serializationVersion()` or the one read from the input file. wdyt?
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
Review Comment:
`addtocms` and `assigntokens` take an endpoint address to identify the node, whereas `forgetnode` and `forcejoin` take a node/host id. We should be consistent across the subcommands and I would recommend we use node ids everywhere.
At the moment, it's possible to add an endpoint to the CMS which doesn't belong to any node in the cluster, but that will change with https://issues.apache.org/jira/browse/CASSANDRA-20736 (patch for that is coming soon).
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ipAddress;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ metadata = makeCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata makeCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ DataPlacement.Builder builder = metadata.placements.get(metaParams).unbuild();
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ builder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+ return metadata.transformer().with(metadata.placements.unbuild().with(metaParams,
+ builder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ @Command(name = "assigntokens", description = "Assigns a token for given instance")
+ public static class AssignTokens extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ip;
+
+ @Option(name = { "-t", "--token" }, description = "Token to assign. Pass it multiple times to assign multiple tokens to node.", required = true)
+ private List<String> tokenList = new ArrayList<>();
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ip);
+ NodeId nodeId = metadata.directory.peerId(nodeAddress);
+ if (nodeId == null)
+ {
+ throw new IllegalArgumentException("Cassandra node with address " + ip + " does not exist.");
+ }
+
+ Token.TokenFactory tokenFactory = metadata.partitioner.getTokenFactory();
+ List<Token> tokens = tokenList.stream().map(tokenFactory::fromString).collect(Collectors.toList());
+ ClusterMetadata updateMetadata = metadata.transformer().proposeToken(nodeId, tokens).build().metadata;
+ writeMetadata(output, updateMetadata, outputFilePath);
+ }
+ }
+
+ @Command(name = "describe", description = "Describes the cluster metadata")
+ public static class Describe extends ClusterMetadataToolCmd
+ {
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
Review Comment:
It might be useful to add an option here to print the full `toString`. That would be overkill in the vast majority of cases, but could be handy for debugging. If we do add that, we should make it clear that the content/format of the output is not stable & shouldn't be relied on by tooling etc.
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ipAddress;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ metadata = makeCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata makeCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ DataPlacement.Builder builder = metadata.placements.get(metaParams).unbuild();
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ builder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+ return metadata.transformer().with(metadata.placements.unbuild().with(metaParams,
+ builder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ @Command(name = "assigntokens", description = "Assigns a token for given instance")
+ public static class AssignTokens extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ip;
+
+ @Option(name = { "-t", "--token" }, description = "Token to assign. Pass it multiple times to assign multiple tokens to node.", required = true)
+ private List<String> tokenList = new ArrayList<>();
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ip);
+ NodeId nodeId = metadata.directory.peerId(nodeAddress);
+ if (nodeId == null)
+ {
+ throw new IllegalArgumentException("Cassandra node with address " + ip + " does not exist.");
+ }
+
+ Token.TokenFactory tokenFactory = metadata.partitioner.getTokenFactory();
+ List<Token> tokens = tokenList.stream().map(tokenFactory::fromString).collect(Collectors.toList());
+ ClusterMetadata updateMetadata = metadata.transformer().proposeToken(nodeId, tokens).build().metadata;
+ writeMetadata(output, updateMetadata, outputFilePath);
+ }
+ }
+
+ @Command(name = "describe", description = "Describes the cluster metadata")
+ public static class Describe extends ClusterMetadataToolCmd
+ {
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ String members = metadata.fullCMSMembers().stream().sorted().map(Object::toString).collect(Collectors.joining(","));
+ output.out.printf("Cluster Metadata Service:%n");
+ output.out.printf("Members: %s%n", members);
+ output.out.printf("Needs reconfiguration: %s%n", needsReconfiguration(metadata));
+ output.out.printf("Service State: %s%n", ClusterMetadataService.state(metadata));
+ output.out.printf("Epoch: %s%n", metadata.epoch.getEpoch());
+ output.out.printf("Replication factor: %s%n", ReplicationParams.meta(metadata).toString());
+ }
+ }
+
+ @Command(name = "forcejoin", description = "Forces a node to move to JOINED stated")
+ public static class ForceJoin extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-id", "--node-id" }, description = "Node ID. It can be integer ID assigned to node or the node uuid", required = true)
+ private String id;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = NodeId.fromString(id);
+
+ if (!metadata.directory.peerIds().contains(nodeId))
+ {
+ throw new IllegalArgumentException("Node with id " + id + " does not exist.");
+ }
+
+ ClusterMetadata updatedMetadata = metadata.transformer().join(nodeId).build().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ @Command(name = "forgetnode", description = "Removes a nodes from given cluster metadata")
+ public static class ForgetNode extends ClusterMetadataToolCmd
Review Comment:
This is 100% a tool for superusers, but I'm a bit concerned that this can put the CM/cluster into a bad state which wouldn't be recoverable using the tool itself as it removes the node from the directory, but it doesn't update data placements/CMS membership/inprogress sequences etc. If an operator inadvertently removed the wrong node here, the only way to repair CM would be to revert to an earlier version entirely.
To make this a bit safer, if the node is `JOINED`, we could make this behave like an offline `assassinate`. If there are inflight operations for it (i.e. the node is moving/leaving/joining) we could cancel the op and revert any changes already made (i.e. what `CancelInProgressSequence::execute` does).
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ipAddress;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ metadata = makeCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata makeCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ DataPlacement.Builder builder = metadata.placements.get(metaParams).unbuild();
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ builder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+ return metadata.transformer().with(metadata.placements.unbuild().with(metaParams,
+ builder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ @Command(name = "assigntokens", description = "Assigns a token for given instance")
+ public static class AssignTokens extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ip;
+
+ @Option(name = { "-t", "--token" }, description = "Token to assign. Pass it multiple times to assign multiple tokens to node.", required = true)
+ private List<String> tokenList = new ArrayList<>();
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ip);
+ NodeId nodeId = metadata.directory.peerId(nodeAddress);
+ if (nodeId == null)
+ {
+ throw new IllegalArgumentException("Cassandra node with address " + ip + " does not exist.");
+ }
+
+ Token.TokenFactory tokenFactory = metadata.partitioner.getTokenFactory();
+ List<Token> tokens = tokenList.stream().map(tokenFactory::fromString).collect(Collectors.toList());
+ ClusterMetadata updateMetadata = metadata.transformer().proposeToken(nodeId, tokens).build().metadata;
Review Comment:
This adds a token to any existing tokens already owned by the node. So in a single-token config, using this command will result in the target node owning multiple tokens. There is no way to remove/unassign tokens at the moment, so that state isn't really fixable with the tooling alone.
I'm not sure this is going to be very intuitive, maybe it should fully replace the tokens currently assigned?
e.g. if the intention is to emulate a single-token move, the operator would just supply the new token. To add a new token to an existing set, *all* the desired tokens, including those already assigned, to the node would need to be passed.
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
Review Comment:
nit: as every subcommand requires an input file, does it make sense to make this an `@Argument` instead of an `@Option`?
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ipAddress;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ metadata = makeCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata makeCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ DataPlacement.Builder builder = metadata.placements.get(metaParams).unbuild();
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ builder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+ return metadata.transformer().with(metadata.placements.unbuild().with(metaParams,
+ builder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ @Command(name = "assigntokens", description = "Assigns a token for given instance")
+ public static class AssignTokens extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ip;
+
+ @Option(name = { "-t", "--token" }, description = "Token to assign. Pass it multiple times to assign multiple tokens to node.", required = true)
+ private List<String> tokenList = new ArrayList<>();
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ip);
+ NodeId nodeId = metadata.directory.peerId(nodeAddress);
+ if (nodeId == null)
+ {
+ throw new IllegalArgumentException("Cassandra node with address " + ip + " does not exist.");
+ }
+
+ Token.TokenFactory tokenFactory = metadata.partitioner.getTokenFactory();
+ List<Token> tokens = tokenList.stream().map(tokenFactory::fromString).collect(Collectors.toList());
+ ClusterMetadata updateMetadata = metadata.transformer().proposeToken(nodeId, tokens).build().metadata;
+ writeMetadata(output, updateMetadata, outputFilePath);
+ }
+ }
+
+ @Command(name = "describe", description = "Describes the cluster metadata")
+ public static class Describe extends ClusterMetadataToolCmd
+ {
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ String members = metadata.fullCMSMembers().stream().sorted().map(Object::toString).collect(Collectors.joining(","));
+ output.out.printf("Cluster Metadata Service:%n");
+ output.out.printf("Members: %s%n", members);
+ output.out.printf("Needs reconfiguration: %s%n", needsReconfiguration(metadata));
+ output.out.printf("Service State: %s%n", ClusterMetadataService.state(metadata));
+ output.out.printf("Epoch: %s%n", metadata.epoch.getEpoch());
+ output.out.printf("Replication factor: %s%n", ReplicationParams.meta(metadata).toString());
+ }
+ }
+
+ @Command(name = "forcejoin", description = "Forces a node to move to JOINED stated")
Review Comment:
How/when do you see this subcommand being used?
Forcing the states like this could put the cluster into a state which makes it harder to recover as it allows a node to be `JOINED` without actually having any tokens which is an unexpected state. Although that doesn't seem to actually cause any problems for C* , it does result in a missing `STATUS_WITH_PORT` gossip state for the node which was forcibly joined, which may cause issues elsewhere.
One thing to note, if the node in question is in the middle of a join/move/leave operation, I suspect this will leave the operation in a state where it can't progress as it will not expect the node to be `JOINED`. This is probably easier to mitigate than the similar issue with `forgetnode` as the in-flight operation should be cancellable after the cluster has been re-initialised with the modified CM.
If you have a specific scenario in mind where this would be useful, perhaps we should add restrictions tailored to that?
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ipAddress;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ metadata = makeCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata makeCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ DataPlacement.Builder builder = metadata.placements.get(metaParams).unbuild();
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ builder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+ return metadata.transformer().with(metadata.placements.unbuild().with(metaParams,
+ builder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ @Command(name = "assigntokens", description = "Assigns a token for given instance")
+ public static class AssignTokens extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ip;
+
+ @Option(name = { "-t", "--token" }, description = "Token to assign. Pass it multiple times to assign multiple tokens to node.", required = true)
+ private List<String> tokenList = new ArrayList<>();
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ip);
+ NodeId nodeId = metadata.directory.peerId(nodeAddress);
+ if (nodeId == null)
+ {
+ throw new IllegalArgumentException("Cassandra node with address " + ip + " does not exist.");
+ }
+
+ Token.TokenFactory tokenFactory = metadata.partitioner.getTokenFactory();
+ List<Token> tokens = tokenList.stream().map(tokenFactory::fromString).collect(Collectors.toList());
+ ClusterMetadata updateMetadata = metadata.transformer().proposeToken(nodeId, tokens).build().metadata;
+ writeMetadata(output, updateMetadata, outputFilePath);
+ }
+ }
+
+ @Command(name = "describe", description = "Describes the cluster metadata")
+ public static class Describe extends ClusterMetadataToolCmd
+ {
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
Review Comment:
```
@Option(name = { "-r", "--raw" }, description = "Output in raw string form. This is not a stable output format and is highly likely to change between releases")
private boolean raw;
```
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ipAddress;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ metadata = makeCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata makeCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ DataPlacement.Builder builder = metadata.placements.get(metaParams).unbuild();
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ builder.withReadReplica(metadata.epoch, newCMS)
Review Comment:
It may be more useful (at least in the first instance) to have this work like `TransformClusterMetadataHelper` and replace the CMS membership, rather than adding to it. In a disaster recovery scenario, you will usually want to start a single node first and make this the sole CMS node by initialising it with the modified CM. Then bring the rest of the cluster up and finally reconfigure the CMS to an appropriate size. Trying to start that first node if the CMS has multiple members will fail as it will try to trigger a metadata snapshot, which will fail as the rest of the CMS nodes are not available.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org
Re: [PR] CASSANDRA-19151 Adding offline tool for cluster metadata [cassandra]
Posted by "nvharikrishna (via GitHub)" <gi...@apache.org>.
nvharikrishna commented on code in PR #4149:
URL: https://github.com/apache/cassandra/pull/4149#discussion_r2236291688
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
Review Comment:
Can do. `@Argument` looked like positional, and thought of avoiding it. Looks like we are using arguments multiple other places. Will make the changes.
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
Review Comment:
Thanks for pointing to [CASSANDRA-20736](https://issues.apache.org/jira/browse/CASSANDRA-20736). I have added IP address arguments since I am able to make transformations without the need for a node ID; for example, I can make an ip address as CMS member (just by using IP address) even though it is not part of CM. Happy to use nodeIds across all commands.
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
Review Comment:
I was thinking like: dump the metadata and then run offline tool (using the same binaries). But makes sense to use different serialization versions for in and out. Will make the changes.
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ipAddress;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ metadata = makeCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata makeCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ DataPlacement.Builder builder = metadata.placements.get(metaParams).unbuild();
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ builder.withReadReplica(metadata.epoch, newCMS)
Review Comment:
Yeah, saw the impl in TransformClusterMetadataHelper. Since this command is called as "AddToCMS", not removing any existing CMS nodes. Makes sense to introduce separate command like `resetcms` to reset CMS to single node? If you think that `addtocms` has no use, then I can replace/rename it to `resetcms`.
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ipAddress;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ metadata = makeCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata makeCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ DataPlacement.Builder builder = metadata.placements.get(metaParams).unbuild();
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ builder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+ return metadata.transformer().with(metadata.placements.unbuild().with(metaParams,
+ builder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ @Command(name = "assigntokens", description = "Assigns a token for given instance")
+ public static class AssignTokens extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of the target endpoint. Port can be optionally specified using a colon after the IP address (e.g., 127.0.0.1:9042).", required = true)
+ private String ip;
+
+ @Option(name = { "-t", "--token" }, description = "Token to assign. Pass it multiple times to assign multiple tokens to node.", required = true)
+ private List<String> tokenList = new ArrayList<>();
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ip);
+ NodeId nodeId = metadata.directory.peerId(nodeAddress);
+ if (nodeId == null)
+ {
+ throw new IllegalArgumentException("Cassandra node with address " + ip + " does not exist.");
+ }
+
+ Token.TokenFactory tokenFactory = metadata.partitioner.getTokenFactory();
+ List<Token> tokens = tokenList.stream().map(tokenFactory::fromString).collect(Collectors.toList());
+ ClusterMetadata updateMetadata = metadata.transformer().proposeToken(nodeId, tokens).build().metadata;
+ writeMetadata(output, updateMetadata, outputFilePath);
+ }
+ }
+
+ @Command(name = "describe", description = "Describes the cluster metadata")
+ public static class Describe extends ClusterMetadataToolCmd
+ {
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ String members = metadata.fullCMSMembers().stream().sorted().map(Object::toString).collect(Collectors.joining(","));
+ output.out.printf("Cluster Metadata Service:%n");
+ output.out.printf("Members: %s%n", members);
+ output.out.printf("Needs reconfiguration: %s%n", needsReconfiguration(metadata));
+ output.out.printf("Service State: %s%n", ClusterMetadataService.state(metadata));
+ output.out.printf("Epoch: %s%n", metadata.epoch.getEpoch());
+ output.out.printf("Replication factor: %s%n", ReplicationParams.meta(metadata).toString());
+ }
+ }
+
+ @Command(name = "forcejoin", description = "Forces a node to move to JOINED stated")
+ public static class ForceJoin extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-id", "--node-id" }, description = "Node ID. It can be integer ID assigned to node or the node uuid", required = true)
+ private String id;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = NodeId.fromString(id);
+
+ if (!metadata.directory.peerIds().contains(nodeId))
+ {
+ throw new IllegalArgumentException("Node with id " + id + " does not exist.");
+ }
+
+ ClusterMetadata updatedMetadata = metadata.transformer().join(nodeId).build().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ @Command(name = "forgetnode", description = "Removes a nodes from given cluster metadata")
+ public static class ForgetNode extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-id", "--node-id" }, description = "Node ID to forget. It can be UUID of node as well.", required = true)
+ private String id;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = NodeId.fromString(id);
+
+ if (!metadata.directory.peerIds().contains(nodeId))
+ {
+ throw new IllegalArgumentException("Node with id " + id + " does not exist.");
+ }
+
+ ClusterMetadata updatedMetadata = metadata.transformer().unregister(nodeId).build().metadata;
+ output.out.println("Successfully forgot node having id " + id);
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ @Command(name = "printdirectory", description = "Prints directory information in cluster metadata file")
+ public static class PrintDirectoryCmd extends ClusterMetadataToolCmd
+ {
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ Directory directory = metadata.directory;
+ Set<NodeId> nodeIdList = directory.peerIds();
+ for (NodeId nodeId : nodeIdList)
+ {
+ NodeAddresses nodeAddresses = directory.getNodeAddresses(nodeId);
+ Location location = directory.location(nodeId);
+ output.out.println("NodeId: " + nodeId.id());
+ String format = " %-22s%s\n";
+ output.out.printf(format, "rack", location.rack);
+ output.out.printf(format, "local_port", nodeAddresses.localAddress.getPort());
+ output.out.printf(format, "broadcast_port", nodeAddresses.broadcastAddress.getPort());
+ output.out.printf(format, "host_id", nodeId.toUUID());
+ output.out.printf(format, "broadcast_address", nodeAddresses.broadcastAddress.getAddress().toString());
+ output.out.printf(format, "native_address", nodeAddresses.nativeAddress.getAddress().toString());
+ output.out.printf(format, "native_port", nodeAddresses.nativeAddress.getPort());
+ output.out.printf(format, "local_address", nodeAddresses.localAddress.getAddress().toString());
+ output.out.printf(format, "state", directory.peerState(nodeId));
+ output.out.printf(format, "serialization_version", directory.version(nodeId).serializationVersion());
+ output.out.printf(format, "cassandra_version", directory.version(nodeId).cassandraVersion);
+ output.out.printf(format, "dc", location.datacenter);
+ output.out.printf(format, "is_cms_member", metadata.isCMSMember(nodeAddresses.broadcastAddress));
+ }
+ }
+ }
+
+ @Command(name = "printdataplacements", description = "Prints data placements in cluster medata file")
Review Comment:
done
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org
Re: [PR] CASSANDRA-19151 Adding offline tool for cluster metadata [cassandra]
Posted by "beobal (via GitHub)" <gi...@apache.org>.
beobal closed pull request #4149: CASSANDRA-19151 Adding offline tool for cluster metadata
URL: https://github.com/apache/cassandra/pull/4149
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org
Re: [PR] CASSANDRA-19151 Adding offline tool for cluster metadata [cassandra]
Posted by "ifesdjeen (via GitHub)" <gi...@apache.org>.
ifesdjeen commented on code in PR #4149:
URL: https://github.com/apache/cassandra/pull/4149#discussion_r3073099749
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
Review Comment:
should this be ".dump" ?
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
+ if (serializationVersion != null)
+ {
+ // Not a good idea to write metadata using lower serialization version. Ref: CASSANDRA-21174
+ if (currentVersion.isBefore(serializationVersion))
+ {
+ throw new IllegalArgumentException("Given serialization version " + serializationVersion +
+ " is newer compared to current version " + currentVersion + '.');
+ }
+
+ return serializationVersion;
+ }
+
+ Version metadataVersion = metadata.directory.commonSerializationVersion;
+ if (!currentVersion.isAtLeast(metadataVersion))
+ {
+ throw new IllegalArgumentException("Current version " + currentVersion +
+ " is older than version in cluster metadata (" +
+ metadataVersion + "). Cannot proceed further. " +
+ "Try modifying cluster metadata using binaries that support " +
+ "minimum serialization version: " + metadataVersion + '.');
+ }
+
+ return metadataVersion;
+ }
+ }
+
+ /**
+ * Cancels a JOIN or REPLACE bootstrap sequence for the given node and unregisters it
+ * from the cluster. Use this when a node is stuck in bootstrapping or replacement.
+ * Fails if no in-progress sequence exists, or if the sequence is not of kind JOIN or REPLACE.
Review Comment:
why not join, replace, or move?
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
+ if (serializationVersion != null)
+ {
+ // Not a good idea to write metadata using lower serialization version. Ref: CASSANDRA-21174
+ if (currentVersion.isBefore(serializationVersion))
+ {
+ throw new IllegalArgumentException("Given serialization version " + serializationVersion +
+ " is newer compared to current version " + currentVersion + '.');
+ }
+
+ return serializationVersion;
+ }
+
+ Version metadataVersion = metadata.directory.commonSerializationVersion;
+ if (!currentVersion.isAtLeast(metadataVersion))
+ {
+ throw new IllegalArgumentException("Current version " + currentVersion +
+ " is older than version in cluster metadata (" +
+ metadataVersion + "). Cannot proceed further. " +
+ "Try modifying cluster metadata using binaries that support " +
+ "minimum serialization version: " + metadataVersion + '.');
+ }
+
+ return metadataVersion;
+ }
+ }
+
+ /**
+ * Cancels a JOIN or REPLACE bootstrap sequence for the given node and unregisters it
+ * from the cluster. Use this when a node is stuck in bootstrapping or replacement.
+ * Fails if no in-progress sequence exists, or if the sequence is not of kind JOIN or REPLACE.
+ */
+ @Command(name = "abortbootstrap",
+ description = "Aborts bootstrap for given node if in progress.")
+ public static class AbortBootstrap extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated cluster metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ if (!metadata.inProgressSequences.contains(nodeId))
+ {
+ throw new IllegalArgumentException("Did not find any sequences in progress for node " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (sequenceKind != MultiStepOperation.Kind.JOIN
+ && sequenceKind != MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("abortbootstrap is not a valid operation when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+
+ // Cancelling the sequence is not enough, but we need to unregister as well
+ Unregister unregister = new Unregister(nodeId, EnumSet.of(NodeState.REGISTERED),
+ ClusterMetadataService.instance().placementProvider());
+
+ updatedMetadata = unregister.execute(updatedMetadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress MOVE sequence for the given node, returning it to its
+ * pre-move token assignment. Fails if no in-progress sequence exists, or if the
+ * sequence is not of kind MOVE.
+ */
+ @Command(name = "abortmove", description = "Aborts in progress move sequence for given node.")
+ static class AbortMove extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.MOVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort move.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress LEAVE (decommission) sequence for the given node, keeping
+ * it as an active member of the ring. Fails if no in-progress sequence exists, or if
+ * the sequence is not of kind LEAVE.
+ */
+ @Command(name = "abortdecommission", description = "Aborts in progress decommission sequence for given node.")
+ static class AbortDecommission extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.LEAVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort decommission.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Removes a node from cluster metadata by applying the {@link Assassinate} transformation.
+ * If a MOVE or LEAVE sequence is in progress for the node, it is cancelled first.
+ * If the node is a CMS member, it is removed from CMS before assassination.
+ * Fails if the node is in a JOIN or REPLACE sequence; use {@code abortbootstrap} instead.
+ */
+ @Command(name = "assassinate", description = "Assassinates given node from Cluster metadata.")
+ static class AssassinateNode extends ClusterMetadataToolCmd
+ {
+ private final EnumSet<MultiStepOperation.Kind> supportedCancelSequences =
+ EnumSet.of(MultiStepOperation.Kind.MOVE, MultiStepOperation.Kind.LEAVE);
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ // Check if there are any in-progress sequences for given node
+ // If any, then cancel the sequence and then assassinate it
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (!supportedCancelSequences.contains(sequenceKind))
+ {
+ if (sequenceKind == MultiStepOperation.Kind.JOIN || sequenceKind == MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
+ sequenceKind + " is in progress. " +
+ "Run abortbootstrap instead.");
+ }
+ else
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+ }
+
+ output.out.printf("Cancelling in-progress sequence of kind %s before assassinating node.\n",
+ metadata.inProgressSequences.get(nodeId).kind());
+ metadata = new CancelInProgressSequence(nodeId).execute(metadata).success().metadata;
+ }
+
+ metadata = maybeReconfigureCMS(metadata, nodeId);
+ Assassinate transformation = new Assassinate(nodeId, ClusterMetadataService.instance().placementProvider());
+ ClusterMetadata updatedMetadata = transformation.execute(metadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+
+ ClusterMetadata maybeReconfigureCMS(ClusterMetadata metadata, NodeId nodeId)
+ {
+ InetAddressAndPort addressAndPort = metadata.directory.endpoint(nodeId);
+ if (!metadata.isCMSMember(addressAndPort))
+ {
+ return metadata;
+ }
+
+ // Ref: org.apache.cassandra.tcm.sequences.ReconfigureCMS.maybeReconfigureCMS
+ PrepareCMSReconfiguration.Simple transformation = new PrepareCMSReconfiguration.Simple(nodeId, Set.of());
+ ClusterMetadata updatedMetadata = transformation.execute(metadata).success().metadata;
+ updatedMetadata = updatedMetadata.inProgressSequences.get(ReconfigureCMS.SequenceKey.instance)
+ .applyTo(updatedMetadata).success().metadata;
+ if (updatedMetadata.isCMSMember(addressAndPort))
+ {
+ throw new IllegalStateException("Could not remove node " + nodeIdentifierOption.getNodeIpOrId() +
+ " from CMS.");
+ }
+ return updatedMetadata;
+ }
+ }
+
+ /**
+ * Replaces the entire CMS membership with a single node. All existing CMS replicas are
+ * removed and the specified node becomes the sole CMS member. Useful for recovering from
+ * a state where the CMS is unreachable.
+ */
+ @Command(name = "resetcms",
+ description = "Replaces all CMS members with the specified node. WARNING: all existing CMS replicas are removed.")
+ static class ResetCMS extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ public void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ InetAddressAndPort nodeAddress = metadata.directory.getNodeAddresses(nodeId).broadcastAddress;
+ metadata = resetCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata resetCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ Iterable<Replica> currentReplicas = metadata.placements.get(metaParams).writes.byEndpoint().flattenValues();
+ DataPlacement.Builder placementBuilder = metadata.placements.get(metaParams).unbuild();
+ for (Replica replica : currentReplicas)
+ {
+ placementBuilder.withoutReadReplica(metadata.epoch, replica)
+ .withoutWriteReplica(metadata.epoch, replica);
+ }
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ placementBuilder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+
+ return metadata.transformer()
+ .with(metadata.placements.unbuild()
+ .with(metaParams, placementBuilder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ /**
+ * Moves a node to a new token. Only supports single-token nodes.
+ * If a MOVE sequence is already in progress for the node, it is completed rather than started fresh;
+ * in that case the provided token must match the token of the in-progress sequence.
+ */
+ @Command(name = "move",
+ description = "Moves node to given token. Works only for cluster having nodes with single token.")
+ static class MoveToken extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-t", "--token" },
+ description = "Token to assign.")
+ private String token;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ // Took the reference from org.apache.cassandra.tcm.sequences.SingleNodeSequences.move
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ ClusterMetadata updatedMetadata = finishInProgressSequence(nodeId, token, metadata);
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ return;
+ }
+
+ if (null == token)
+ {
+ throw new IllegalArgumentException("Token required when no MOVE sequence is in progress.");
+ }
+ metadata.partitioner.getTokenFactory().validate(token);
+ Token toToken = metadata.partitioner.getTokenFactory().fromString(token);
+ if (metadata.tokenMap.tokens().contains(toToken))
+ {
+ throw new IllegalArgumentException("Target token " + toToken + " is already owned by another node.");
+ }
+ if (metadata.tokenMap.tokens(nodeId).size() > 1)
+ {
+ throw new UnsupportedOperationException("This node has more than one token and cannot be moved thusly.");
+ }
+
+ PrepareMove prepareMove = new PrepareMove(nodeId, Set.of(toToken), new UniformRangePlacement(), false);
+ ClusterMetadata updatedMetadata = prepareMove.execute(metadata).success().metadata;
Review Comment:
This is going to throw; should we make sure to check if it is success before dereferencing success?
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
+ if (serializationVersion != null)
+ {
+ // Not a good idea to write metadata using lower serialization version. Ref: CASSANDRA-21174
+ if (currentVersion.isBefore(serializationVersion))
+ {
+ throw new IllegalArgumentException("Given serialization version " + serializationVersion +
+ " is newer compared to current version " + currentVersion + '.');
+ }
+
+ return serializationVersion;
+ }
+
+ Version metadataVersion = metadata.directory.commonSerializationVersion;
+ if (!currentVersion.isAtLeast(metadataVersion))
+ {
+ throw new IllegalArgumentException("Current version " + currentVersion +
+ " is older than version in cluster metadata (" +
+ metadataVersion + "). Cannot proceed further. " +
+ "Try modifying cluster metadata using binaries that support " +
+ "minimum serialization version: " + metadataVersion + '.');
+ }
+
+ return metadataVersion;
+ }
+ }
+
+ /**
+ * Cancels a JOIN or REPLACE bootstrap sequence for the given node and unregisters it
+ * from the cluster. Use this when a node is stuck in bootstrapping or replacement.
+ * Fails if no in-progress sequence exists, or if the sequence is not of kind JOIN or REPLACE.
+ */
+ @Command(name = "abortbootstrap",
+ description = "Aborts bootstrap for given node if in progress.")
+ public static class AbortBootstrap extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated cluster metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ if (!metadata.inProgressSequences.contains(nodeId))
+ {
+ throw new IllegalArgumentException("Did not find any sequences in progress for node " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (sequenceKind != MultiStepOperation.Kind.JOIN
+ && sequenceKind != MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("abortbootstrap is not a valid operation when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+
+ // Cancelling the sequence is not enough, but we need to unregister as well
+ Unregister unregister = new Unregister(nodeId, EnumSet.of(NodeState.REGISTERED),
+ ClusterMetadataService.instance().placementProvider());
+
+ updatedMetadata = unregister.execute(updatedMetadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress MOVE sequence for the given node, returning it to its
+ * pre-move token assignment. Fails if no in-progress sequence exists, or if the
+ * sequence is not of kind MOVE.
+ */
+ @Command(name = "abortmove", description = "Aborts in progress move sequence for given node.")
+ static class AbortMove extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.MOVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort move.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress LEAVE (decommission) sequence for the given node, keeping
+ * it as an active member of the ring. Fails if no in-progress sequence exists, or if
+ * the sequence is not of kind LEAVE.
+ */
+ @Command(name = "abortdecommission", description = "Aborts in progress decommission sequence for given node.")
+ static class AbortDecommission extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.LEAVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort decommission.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Removes a node from cluster metadata by applying the {@link Assassinate} transformation.
+ * If a MOVE or LEAVE sequence is in progress for the node, it is cancelled first.
+ * If the node is a CMS member, it is removed from CMS before assassination.
+ * Fails if the node is in a JOIN or REPLACE sequence; use {@code abortbootstrap} instead.
+ */
+ @Command(name = "assassinate", description = "Assassinates given node from Cluster metadata.")
+ static class AssassinateNode extends ClusterMetadataToolCmd
+ {
+ private final EnumSet<MultiStepOperation.Kind> supportedCancelSequences =
+ EnumSet.of(MultiStepOperation.Kind.MOVE, MultiStepOperation.Kind.LEAVE);
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ // Check if there are any in-progress sequences for given node
+ // If any, then cancel the sequence and then assassinate it
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (!supportedCancelSequences.contains(sequenceKind))
+ {
+ if (sequenceKind == MultiStepOperation.Kind.JOIN || sequenceKind == MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
+ sequenceKind + " is in progress. " +
+ "Run abortbootstrap instead.");
+ }
+ else
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+ }
+
+ output.out.printf("Cancelling in-progress sequence of kind %s before assassinating node.\n",
+ metadata.inProgressSequences.get(nodeId).kind());
+ metadata = new CancelInProgressSequence(nodeId).execute(metadata).success().metadata;
+ }
+
+ metadata = maybeReconfigureCMS(metadata, nodeId);
+ Assassinate transformation = new Assassinate(nodeId, ClusterMetadataService.instance().placementProvider());
+ ClusterMetadata updatedMetadata = transformation.execute(metadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+
+ ClusterMetadata maybeReconfigureCMS(ClusterMetadata metadata, NodeId nodeId)
+ {
+ InetAddressAndPort addressAndPort = metadata.directory.endpoint(nodeId);
+ if (!metadata.isCMSMember(addressAndPort))
+ {
+ return metadata;
+ }
+
+ // Ref: org.apache.cassandra.tcm.sequences.ReconfigureCMS.maybeReconfigureCMS
+ PrepareCMSReconfiguration.Simple transformation = new PrepareCMSReconfiguration.Simple(nodeId, Set.of());
+ ClusterMetadata updatedMetadata = transformation.execute(metadata).success().metadata;
+ updatedMetadata = updatedMetadata.inProgressSequences.get(ReconfigureCMS.SequenceKey.instance)
+ .applyTo(updatedMetadata).success().metadata;
+ if (updatedMetadata.isCMSMember(addressAndPort))
+ {
+ throw new IllegalStateException("Could not remove node " + nodeIdentifierOption.getNodeIpOrId() +
+ " from CMS.");
+ }
+ return updatedMetadata;
+ }
+ }
+
+ /**
+ * Replaces the entire CMS membership with a single node. All existing CMS replicas are
+ * removed and the specified node becomes the sole CMS member. Useful for recovering from
+ * a state where the CMS is unreachable.
+ */
+ @Command(name = "resetcms",
+ description = "Replaces all CMS members with the specified node. WARNING: all existing CMS replicas are removed.")
+ static class ResetCMS extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ public void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ InetAddressAndPort nodeAddress = metadata.directory.getNodeAddresses(nodeId).broadcastAddress;
+ metadata = resetCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata resetCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ Iterable<Replica> currentReplicas = metadata.placements.get(metaParams).writes.byEndpoint().flattenValues();
+ DataPlacement.Builder placementBuilder = metadata.placements.get(metaParams).unbuild();
+ for (Replica replica : currentReplicas)
+ {
+ placementBuilder.withoutReadReplica(metadata.epoch, replica)
+ .withoutWriteReplica(metadata.epoch, replica);
+ }
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ placementBuilder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+
+ return metadata.transformer()
+ .with(metadata.placements.unbuild()
+ .with(metaParams, placementBuilder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ /**
+ * Moves a node to a new token. Only supports single-token nodes.
+ * If a MOVE sequence is already in progress for the node, it is completed rather than started fresh;
+ * in that case the provided token must match the token of the in-progress sequence.
+ */
+ @Command(name = "move",
+ description = "Moves node to given token. Works only for cluster having nodes with single token.")
+ static class MoveToken extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-t", "--token" },
+ description = "Token to assign.")
+ private String token;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ // Took the reference from org.apache.cassandra.tcm.sequences.SingleNodeSequences.move
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ ClusterMetadata updatedMetadata = finishInProgressSequence(nodeId, token, metadata);
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ return;
+ }
+
+ if (null == token)
+ {
+ throw new IllegalArgumentException("Token required when no MOVE sequence is in progress.");
+ }
+ metadata.partitioner.getTokenFactory().validate(token);
+ Token toToken = metadata.partitioner.getTokenFactory().fromString(token);
+ if (metadata.tokenMap.tokens().contains(toToken))
+ {
+ throw new IllegalArgumentException("Target token " + toToken + " is already owned by another node.");
Review Comment:
I'm probably being too pedantic here, but if you try to move own token, it's not techincally owned by another node. Should we instead write by _a_ node, and maybe even show which one specficically?
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
+ if (serializationVersion != null)
+ {
+ // Not a good idea to write metadata using lower serialization version. Ref: CASSANDRA-21174
+ if (currentVersion.isBefore(serializationVersion))
+ {
+ throw new IllegalArgumentException("Given serialization version " + serializationVersion +
+ " is newer compared to current version " + currentVersion + '.');
+ }
+
+ return serializationVersion;
+ }
+
+ Version metadataVersion = metadata.directory.commonSerializationVersion;
+ if (!currentVersion.isAtLeast(metadataVersion))
+ {
+ throw new IllegalArgumentException("Current version " + currentVersion +
+ " is older than version in cluster metadata (" +
+ metadataVersion + "). Cannot proceed further. " +
+ "Try modifying cluster metadata using binaries that support " +
+ "minimum serialization version: " + metadataVersion + '.');
+ }
+
+ return metadataVersion;
+ }
+ }
+
+ /**
+ * Cancels a JOIN or REPLACE bootstrap sequence for the given node and unregisters it
+ * from the cluster. Use this when a node is stuck in bootstrapping or replacement.
+ * Fails if no in-progress sequence exists, or if the sequence is not of kind JOIN or REPLACE.
+ */
+ @Command(name = "abortbootstrap",
+ description = "Aborts bootstrap for given node if in progress.")
+ public static class AbortBootstrap extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated cluster metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ if (!metadata.inProgressSequences.contains(nodeId))
+ {
+ throw new IllegalArgumentException("Did not find any sequences in progress for node " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (sequenceKind != MultiStepOperation.Kind.JOIN
+ && sequenceKind != MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("abortbootstrap is not a valid operation when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+
+ // Cancelling the sequence is not enough, but we need to unregister as well
+ Unregister unregister = new Unregister(nodeId, EnumSet.of(NodeState.REGISTERED),
+ ClusterMetadataService.instance().placementProvider());
+
+ updatedMetadata = unregister.execute(updatedMetadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress MOVE sequence for the given node, returning it to its
+ * pre-move token assignment. Fails if no in-progress sequence exists, or if the
+ * sequence is not of kind MOVE.
+ */
+ @Command(name = "abortmove", description = "Aborts in progress move sequence for given node.")
+ static class AbortMove extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.MOVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort move.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress LEAVE (decommission) sequence for the given node, keeping
+ * it as an active member of the ring. Fails if no in-progress sequence exists, or if
+ * the sequence is not of kind LEAVE.
+ */
+ @Command(name = "abortdecommission", description = "Aborts in progress decommission sequence for given node.")
+ static class AbortDecommission extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.LEAVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort decommission.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Removes a node from cluster metadata by applying the {@link Assassinate} transformation.
+ * If a MOVE or LEAVE sequence is in progress for the node, it is cancelled first.
+ * If the node is a CMS member, it is removed from CMS before assassination.
+ * Fails if the node is in a JOIN or REPLACE sequence; use {@code abortbootstrap} instead.
+ */
+ @Command(name = "assassinate", description = "Assassinates given node from Cluster metadata.")
+ static class AssassinateNode extends ClusterMetadataToolCmd
+ {
+ private final EnumSet<MultiStepOperation.Kind> supportedCancelSequences =
+ EnumSet.of(MultiStepOperation.Kind.MOVE, MultiStepOperation.Kind.LEAVE);
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ // Check if there are any in-progress sequences for given node
+ // If any, then cancel the sequence and then assassinate it
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (!supportedCancelSequences.contains(sequenceKind))
+ {
+ if (sequenceKind == MultiStepOperation.Kind.JOIN || sequenceKind == MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
+ sequenceKind + " is in progress. " +
+ "Run abortbootstrap instead.");
+ }
+ else
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+ }
+
+ output.out.printf("Cancelling in-progress sequence of kind %s before assassinating node.\n",
+ metadata.inProgressSequences.get(nodeId).kind());
+ metadata = new CancelInProgressSequence(nodeId).execute(metadata).success().metadata;
+ }
+
+ metadata = maybeReconfigureCMS(metadata, nodeId);
+ Assassinate transformation = new Assassinate(nodeId, ClusterMetadataService.instance().placementProvider());
+ ClusterMetadata updatedMetadata = transformation.execute(metadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+
+ ClusterMetadata maybeReconfigureCMS(ClusterMetadata metadata, NodeId nodeId)
+ {
+ InetAddressAndPort addressAndPort = metadata.directory.endpoint(nodeId);
+ if (!metadata.isCMSMember(addressAndPort))
+ {
+ return metadata;
+ }
+
+ // Ref: org.apache.cassandra.tcm.sequences.ReconfigureCMS.maybeReconfigureCMS
+ PrepareCMSReconfiguration.Simple transformation = new PrepareCMSReconfiguration.Simple(nodeId, Set.of());
+ ClusterMetadata updatedMetadata = transformation.execute(metadata).success().metadata;
+ updatedMetadata = updatedMetadata.inProgressSequences.get(ReconfigureCMS.SequenceKey.instance)
+ .applyTo(updatedMetadata).success().metadata;
+ if (updatedMetadata.isCMSMember(addressAndPort))
+ {
+ throw new IllegalStateException("Could not remove node " + nodeIdentifierOption.getNodeIpOrId() +
+ " from CMS.");
+ }
+ return updatedMetadata;
+ }
+ }
+
+ /**
+ * Replaces the entire CMS membership with a single node. All existing CMS replicas are
+ * removed and the specified node becomes the sole CMS member. Useful for recovering from
+ * a state where the CMS is unreachable.
+ */
+ @Command(name = "resetcms",
+ description = "Replaces all CMS members with the specified node. WARNING: all existing CMS replicas are removed.")
+ static class ResetCMS extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ public void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ InetAddressAndPort nodeAddress = metadata.directory.getNodeAddresses(nodeId).broadcastAddress;
+ metadata = resetCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata resetCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
Review Comment:
We might want to assert that endpoint is fully joined here.
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
+ if (serializationVersion != null)
+ {
+ // Not a good idea to write metadata using lower serialization version. Ref: CASSANDRA-21174
+ if (currentVersion.isBefore(serializationVersion))
+ {
+ throw new IllegalArgumentException("Given serialization version " + serializationVersion +
+ " is newer compared to current version " + currentVersion + '.');
+ }
+
+ return serializationVersion;
+ }
+
+ Version metadataVersion = metadata.directory.commonSerializationVersion;
+ if (!currentVersion.isAtLeast(metadataVersion))
+ {
+ throw new IllegalArgumentException("Current version " + currentVersion +
+ " is older than version in cluster metadata (" +
+ metadataVersion + "). Cannot proceed further. " +
+ "Try modifying cluster metadata using binaries that support " +
+ "minimum serialization version: " + metadataVersion + '.');
+ }
+
+ return metadataVersion;
+ }
+ }
+
+ /**
+ * Cancels a JOIN or REPLACE bootstrap sequence for the given node and unregisters it
+ * from the cluster. Use this when a node is stuck in bootstrapping or replacement.
+ * Fails if no in-progress sequence exists, or if the sequence is not of kind JOIN or REPLACE.
+ */
+ @Command(name = "abortbootstrap",
+ description = "Aborts bootstrap for given node if in progress.")
+ public static class AbortBootstrap extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated cluster metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ if (!metadata.inProgressSequences.contains(nodeId))
+ {
+ throw new IllegalArgumentException("Did not find any sequences in progress for node " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (sequenceKind != MultiStepOperation.Kind.JOIN
+ && sequenceKind != MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("abortbootstrap is not a valid operation when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+
+ // Cancelling the sequence is not enough, but we need to unregister as well
+ Unregister unregister = new Unregister(nodeId, EnumSet.of(NodeState.REGISTERED),
+ ClusterMetadataService.instance().placementProvider());
+
+ updatedMetadata = unregister.execute(updatedMetadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress MOVE sequence for the given node, returning it to its
+ * pre-move token assignment. Fails if no in-progress sequence exists, or if the
+ * sequence is not of kind MOVE.
+ */
+ @Command(name = "abortmove", description = "Aborts in progress move sequence for given node.")
+ static class AbortMove extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.MOVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort move.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress LEAVE (decommission) sequence for the given node, keeping
+ * it as an active member of the ring. Fails if no in-progress sequence exists, or if
+ * the sequence is not of kind LEAVE.
+ */
+ @Command(name = "abortdecommission", description = "Aborts in progress decommission sequence for given node.")
+ static class AbortDecommission extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.LEAVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort decommission.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Removes a node from cluster metadata by applying the {@link Assassinate} transformation.
+ * If a MOVE or LEAVE sequence is in progress for the node, it is cancelled first.
+ * If the node is a CMS member, it is removed from CMS before assassination.
+ * Fails if the node is in a JOIN or REPLACE sequence; use {@code abortbootstrap} instead.
+ */
+ @Command(name = "assassinate", description = "Assassinates given node from Cluster metadata.")
+ static class AssassinateNode extends ClusterMetadataToolCmd
+ {
+ private final EnumSet<MultiStepOperation.Kind> supportedCancelSequences =
+ EnumSet.of(MultiStepOperation.Kind.MOVE, MultiStepOperation.Kind.LEAVE);
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ // Check if there are any in-progress sequences for given node
+ // If any, then cancel the sequence and then assassinate it
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (!supportedCancelSequences.contains(sequenceKind))
+ {
+ if (sequenceKind == MultiStepOperation.Kind.JOIN || sequenceKind == MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
+ sequenceKind + " is in progress. " +
+ "Run abortbootstrap instead.");
+ }
+ else
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+ }
+
+ output.out.printf("Cancelling in-progress sequence of kind %s before assassinating node.\n",
+ metadata.inProgressSequences.get(nodeId).kind());
+ metadata = new CancelInProgressSequence(nodeId).execute(metadata).success().metadata;
+ }
+
+ metadata = maybeReconfigureCMS(metadata, nodeId);
+ Assassinate transformation = new Assassinate(nodeId, ClusterMetadataService.instance().placementProvider());
+ ClusterMetadata updatedMetadata = transformation.execute(metadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+
+ ClusterMetadata maybeReconfigureCMS(ClusterMetadata metadata, NodeId nodeId)
+ {
+ InetAddressAndPort addressAndPort = metadata.directory.endpoint(nodeId);
+ if (!metadata.isCMSMember(addressAndPort))
+ {
+ return metadata;
+ }
+
+ // Ref: org.apache.cassandra.tcm.sequences.ReconfigureCMS.maybeReconfigureCMS
+ PrepareCMSReconfiguration.Simple transformation = new PrepareCMSReconfiguration.Simple(nodeId, Set.of());
+ ClusterMetadata updatedMetadata = transformation.execute(metadata).success().metadata;
+ updatedMetadata = updatedMetadata.inProgressSequences.get(ReconfigureCMS.SequenceKey.instance)
+ .applyTo(updatedMetadata).success().metadata;
+ if (updatedMetadata.isCMSMember(addressAndPort))
+ {
+ throw new IllegalStateException("Could not remove node " + nodeIdentifierOption.getNodeIpOrId() +
+ " from CMS.");
+ }
+ return updatedMetadata;
+ }
+ }
+
+ /**
+ * Replaces the entire CMS membership with a single node. All existing CMS replicas are
+ * removed and the specified node becomes the sole CMS member. Useful for recovering from
+ * a state where the CMS is unreachable.
+ */
+ @Command(name = "resetcms",
+ description = "Replaces all CMS members with the specified node. WARNING: all existing CMS replicas are removed.")
+ static class ResetCMS extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ public void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ InetAddressAndPort nodeAddress = metadata.directory.getNodeAddresses(nodeId).broadcastAddress;
+ metadata = resetCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata resetCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ Iterable<Replica> currentReplicas = metadata.placements.get(metaParams).writes.byEndpoint().flattenValues();
+ DataPlacement.Builder placementBuilder = metadata.placements.get(metaParams).unbuild();
+ for (Replica replica : currentReplicas)
+ {
+ placementBuilder.withoutReadReplica(metadata.epoch, replica)
+ .withoutWriteReplica(metadata.epoch, replica);
+ }
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ placementBuilder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+
+ return metadata.transformer()
+ .with(metadata.placements.unbuild()
+ .with(metaParams, placementBuilder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ /**
+ * Moves a node to a new token. Only supports single-token nodes.
+ * If a MOVE sequence is already in progress for the node, it is completed rather than started fresh;
+ * in that case the provided token must match the token of the in-progress sequence.
+ */
+ @Command(name = "move",
+ description = "Moves node to given token. Works only for cluster having nodes with single token.")
+ static class MoveToken extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-t", "--token" },
+ description = "Token to assign.")
+ private String token;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ // Took the reference from org.apache.cassandra.tcm.sequences.SingleNodeSequences.move
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ ClusterMetadata updatedMetadata = finishInProgressSequence(nodeId, token, metadata);
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ return;
+ }
+
+ if (null == token)
+ {
+ throw new IllegalArgumentException("Token required when no MOVE sequence is in progress.");
+ }
+ metadata.partitioner.getTokenFactory().validate(token);
+ Token toToken = metadata.partitioner.getTokenFactory().fromString(token);
+ if (metadata.tokenMap.tokens().contains(toToken))
+ {
+ throw new IllegalArgumentException("Target token " + toToken + " is already owned by another node.");
+ }
+ if (metadata.tokenMap.tokens(nodeId).size() > 1)
+ {
+ throw new UnsupportedOperationException("This node has more than one token and cannot be moved thusly.");
+ }
+
+ PrepareMove prepareMove = new PrepareMove(nodeId, Set.of(toToken), new UniformRangePlacement(), false);
+ ClusterMetadata updatedMetadata = prepareMove.execute(metadata).success().metadata;
+ updatedMetadata = updatedMetadata.inProgressSequences.get(nodeId).applyTo(updatedMetadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+
+ ClusterMetadata finishInProgressSequence(NodeId nodeId, String token, ClusterMetadata metadata)
+ {
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.MOVE)
+ {
+ throw new IllegalArgumentException("Another sequence of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with move.");
+ }
+
+ Move moveInProgress = (Move) multiStepOperation;
+ Collection<Token> inProgressTokens = moveInProgress.tokens;
+ Collection<Token> givenTokenSet = token == null
+ ? Set.of()
+ : Set.of(metadata.partitioner.getTokenFactory().fromString(token));
+ if (givenTokenSet.isEmpty() || new HashSet<>(moveInProgress.tokens).equals(givenTokenSet))
+ {
+ return moveInProgress.applyTo(metadata).success().metadata;
+ }
+
+ throw new IllegalArgumentException("Move in progress for another token(s) " + inProgressTokens + '.');
+ }
+ }
+
+ /**
+ * Identifies a target node using either its IP address or its integer/UUID node ID.
+ * Exactly one of {@code -ip} or {@code -id} must be provided.
+ */
+ static class NodeIdentifierOption
+ {
+ @Option(names = { "-ip", "--ip-address" }, required = true,
+ description = "IP address of the target endpoint. Port can be optionally specified " +
+ "using a colon after the IP address (e.g., 127.0.0.1:9042).")
+ private String ip;
+
+ @Option(names = { "-id", "--node-id" }, required = true,
+ description = "Node ID. It can be integer ID assigned to node or the node uuid.")
+ private String id;
+
+ String getNodeIpOrId()
+ {
+ return ip != null ? ip : id;
+ }
+
+ NodeId getNodeId(ClusterMetadata metadata)
+ {
+ if (id != null)
+ {
+ NodeId nodeId = NodeId.fromString(id);
+ if (!metadata.directory.peerIds().contains(nodeId))
+ {
+ throw new IllegalArgumentException("No node present with id " + id +
+ " in the given cluster metadata.");
+ }
+ return nodeId;
+ }
+ else if (ip != null)
+ {
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ip);
+ NodeId nodeId = metadata.directory.peerId(nodeAddress);
+
+ if (null == nodeId)
+ {
+ throw new IllegalArgumentException("No node present with ip address " + ip +
+ " in given cluster metadata.");
+ }
+ return nodeId;
+ }
+
+ throw new IllegalArgumentException("Neither node id nor ip address specified to fetch NodeId from metadata.");
+ }
+ }
+
+
+ /**
+ * Prints a high-level summary of the CMS state: members, epoch, replication factor,
+ * service state, and whether reconfiguration is needed.
+ */
+ @Command(name = "describe", description = "Describes the cluster metadata.")
+ static class Describe extends ClusterMetadataToolCmd
+ {
+ @Override
+ public void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ String members = metadata.fullCMSMembers()
+ .stream()
+ .sorted()
+ .map(Object::toString)
+ .collect(Collectors.joining(","));
+
+ output.out.printf("Cluster Metadata Service:%n");
+ output.out.printf("Members: %s%n", members);
+ output.out.printf("Needs reconfiguration: %s%n", needsReconfiguration(metadata));
+ output.out.printf("Service State: %s%n", ClusterMetadataService.state(metadata));
+ output.out.printf("Epoch: %s%n", metadata.epoch.getEpoch());
+ output.out.printf("Replication factor: %s%n", ReplicationParams.meta(metadata).toString());
+ }
+ }
+
+ /**
+ * Forces a node directly to JOINED state, bypassing the normal bootstrap sequence.
+ * <ul>
+ * <li>If the node has an in-progress JOIN sequence, it is completed; provided tokens
+ * must match the sequence tokens (or be omitted to use the sequence tokens).</li>
+ * <li>If no sequence is in progress, tokens must be explicitly provided and
+ * an {@link UnsafeJoin} is applied.</li>
+ * </ul>
+ * Fails if the node is already JOINED, or if a non-JOIN sequence is in progress.
+ */
+ @Command(name = "forcejoin", description = "Forces a node to move to JOINED state.")
+ static class ForceJoin extends ClusterMetadataToolCmd
+ {
+ @SuppressWarnings("MismatchedQueryAndUpdateOfCollection")
+ @Option(names = { "-t", "--token" },
+ description = "Token to assign. Pass it multiple times to assign multiple tokens to node.")
+ private final List<String> tokens = new ArrayList<>();
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ public void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ Set<Token> tokenSet = new HashSet<>(tokens.size());
+ Token.TokenFactory tokenFactory = metadata.partitioner.getTokenFactory();
+ tokens.forEach(t -> tokenSet.add(tokenFactory.fromString(t)));
+
+ NodeState nodeState = metadata.directory.peerState(nodeId);
+ if (nodeState == NodeState.JOINED)
+ {
+ throw new IllegalArgumentException("Node " + nodeIdentifierOption.getNodeIpOrId() +
+ " is already in JOINED state.");
+ }
+
+ ClusterMetadata updatedMetadata;
+ if (metadata.inProgressSequences.get(nodeId) != null)
+ {
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.JOIN)
+ {
+ throw new IllegalArgumentException("Another sequence of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with force join.");
+ }
+ BootstrapAndJoin bootstrapAndJoin = (BootstrapAndJoin) multiStepOperation;
+ Set<Token> sequenceTokens = bootstrapAndJoin.finishJoin.tokens;
+ if (tokens.isEmpty()
+ || (tokenSet.size() == sequenceTokens.size() && sequenceTokens.containsAll(tokenSet)))
+ {
+ updatedMetadata = bootstrapAndJoin.applyTo(metadata).success().metadata;
+ }
+ else
+ {
+ // If tokens are provided, then it should match with the in-progress sequence tokens
+ throw new IllegalArgumentException("The tokens provided " + tokens + " do not match with " +
+ " in progress BootstrapAndJoin sequence tokens " +
+ sequenceTokens + ". Cannot proceed further.");
+ }
+ }
+ else
+ {
+ // There are no in-progress sequences, force join by using UnsafeJoin transformation
+ if (tokenSet.isEmpty())
+ {
+ throw new IllegalArgumentException("Tokens must be provided to force join a node.");
+ }
+ UnsafeJoin unsafeJoin = new UnsafeJoin(nodeId, tokenSet, new UniformRangePlacement());
+ updatedMetadata = unsafeJoin.execute(metadata).success().metadata;
+ }
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Prints the full {@code toString()} representation of the cluster metadata to stdout.
+ * Useful for a quick human-readable overview of the entire metadata state.
+ * <p>
+ * <b>Note:</b> The output format is not stable and may change between versions.
+ * Do not rely on it for programmatic parsing.
+ */
+ @Command(name = "print", description = "Prints string output of the cluster metadata. " +
+ "Output format is subject to change and should not be relied on for parsing.")
+ static class Print extends ClusterMetadataToolCmd
+ {
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ // It supports only toString output of the ClusterMetadata for now
+ ClusterMetadata metadata = parseClusterMetadata();
+ output.out.println(metadata);
+ }
+ }
+
+ /**
+ * Prints per-node directory information: addresses, ports, rack, DC, state,
+ * serialization version, and CMS membership status.
+ */
+ @Command(name = "printdirectory", description = "Prints directory information in cluster metadata file.")
+ static class PrintDirectoryCmd extends ClusterMetadataToolCmd
+ {
+ @Override
+ public void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ Directory directory = metadata.directory;
+ Set<NodeId> nodeIdList = directory.peerIds();
+ for (NodeId nodeId : nodeIdList)
+ {
+ NodeAddresses nodeAddresses = directory.getNodeAddresses(nodeId);
+ Location location = directory.location(nodeId);
+ output.out.println("NodeId: " + nodeId.id());
+ String format = " %-22s%s\n";
+ output.out.printf(format, "rack", location.rack);
+ output.out.printf(format, "local_port", nodeAddresses.localAddress.getPort());
+ output.out.printf(format, "broadcast_port", nodeAddresses.broadcastAddress.getPort());
+ output.out.printf(format, "host_id", nodeId.toUUID());
+ output.out.printf(format, "broadcast_address", nodeAddresses.broadcastAddress.getAddress().toString());
+ output.out.printf(format, "native_address", nodeAddresses.nativeAddress.getAddress().toString());
+ output.out.printf(format, "native_port", nodeAddresses.nativeAddress.getPort());
+ output.out.printf(format, "local_address", nodeAddresses.localAddress.getAddress().toString());
+ output.out.printf(format, "state", directory.peerState(nodeId));
+ output.out.printf(format, "serialization_version", directory.version(nodeId).serializationVersion());
+ output.out.printf(format, "cassandra_version", directory.version(nodeId).cassandraVersion);
+ output.out.printf(format, "dc", location.datacenter);
+ output.out.printf(format, "is_cms_member", metadata.isCMSMember(nodeAddresses.broadcastAddress));
+ }
+ }
+ }
+
+ /**
+ * Prints read and write replica placements for a specific keyspace, sorted by token range.
+ * Requires the {@code -ks} option to specify the target keyspace.
+ */
+ @Command(name = "printdataplacements", description = "Prints data placements in cluster metadata file.")
+ static class PrintDataPlacements extends ClusterMetadataToolCmd
+ {
+ @Option(names = { "-ks", "--keyspace" }, required = true,
+ description = "Keyspace to use for printing data placements.")
+ private String keyspace;
+
+ @Override
+ public void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+
+ KeyspaceMetadata keyspaceMetadata = metadata.schema.getKeyspaces().getNullable(keyspace);
+ if (keyspaceMetadata == null)
+ {
+ throw new IllegalArgumentException("Keyspace " + keyspace + " not found in cluster metadata.");
+ }
+
+ DataPlacement placement = metadata.placements.get(keyspaceMetadata.params.replication);
+ List<Object[]> rows = new ArrayList<>();
+ rows.addAll(replicaGroupsToRows(placement.reads, "read"));
+ rows.addAll(replicaGroupsToRows(placement.writes, "write"));
+
+ rows.sort((o1, o2) -> {
+ Range<Token> left = (Range<Token>) o1[0];
+ Range<Token> right = (Range<Token>) o2[0];
+ return left.compareTo(right);
+ });
+
+ int rangeMaxLength = 0;
+ for (Object[] objects : rows)
+ {
+ rangeMaxLength = Math.max(rangeMaxLength, objects[0].toString().length());
+ }
+
+ String rowFormat = String.format("%%-%ds %%-7s %%s\n", (rangeMaxLength + 2));
+ output.out.printf(rowFormat, "Token Range", "Type", "Endpoints");
+
+ rows.forEach(row -> output.out.printf(rowFormat, row));
+ }
+
+ List<Object[]> replicaGroupsToRows(ReplicaGroups replicaGroups, String replicaGroupType)
+ {
+ List<Object[]> rows = new ArrayList<>();
+ replicaGroups.forEach(((tokenRange, forRange) -> {
+ Range<Token> range = forRange.get().range();
Review Comment:
nit: can use `tokenRange` from above, right?
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
+ if (serializationVersion != null)
+ {
+ // Not a good idea to write metadata using lower serialization version. Ref: CASSANDRA-21174
Review Comment:
If I understand it correctly, forced serialization version in this case is _after_ node's current version, or?
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
Review Comment:
I am trying to understand the intent here: `currentVersion` should be the highest version we should be able to serialize with. Which means that current version would always be before the forced serialization version, am I missing something?
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
+ if (serializationVersion != null)
+ {
+ // Not a good idea to write metadata using lower serialization version. Ref: CASSANDRA-21174
+ if (currentVersion.isBefore(serializationVersion))
+ {
+ throw new IllegalArgumentException("Given serialization version " + serializationVersion +
+ " is newer compared to current version " + currentVersion + '.');
+ }
+
+ return serializationVersion;
+ }
+
+ Version metadataVersion = metadata.directory.commonSerializationVersion;
+ if (!currentVersion.isAtLeast(metadataVersion))
+ {
+ throw new IllegalArgumentException("Current version " + currentVersion +
+ " is older than version in cluster metadata (" +
+ metadataVersion + "). Cannot proceed further. " +
+ "Try modifying cluster metadata using binaries that support " +
+ "minimum serialization version: " + metadataVersion + '.');
+ }
+
+ return metadataVersion;
+ }
+ }
+
+ /**
+ * Cancels a JOIN or REPLACE bootstrap sequence for the given node and unregisters it
+ * from the cluster. Use this when a node is stuck in bootstrapping or replacement.
+ * Fails if no in-progress sequence exists, or if the sequence is not of kind JOIN or REPLACE.
+ */
+ @Command(name = "abortbootstrap",
+ description = "Aborts bootstrap for given node if in progress.")
+ public static class AbortBootstrap extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated cluster metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ if (!metadata.inProgressSequences.contains(nodeId))
+ {
+ throw new IllegalArgumentException("Did not find any sequences in progress for node " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (sequenceKind != MultiStepOperation.Kind.JOIN
+ && sequenceKind != MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("abortbootstrap is not a valid operation when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+
+ // Cancelling the sequence is not enough, but we need to unregister as well
+ Unregister unregister = new Unregister(nodeId, EnumSet.of(NodeState.REGISTERED),
+ ClusterMetadataService.instance().placementProvider());
+
+ updatedMetadata = unregister.execute(updatedMetadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress MOVE sequence for the given node, returning it to its
+ * pre-move token assignment. Fails if no in-progress sequence exists, or if the
+ * sequence is not of kind MOVE.
+ */
+ @Command(name = "abortmove", description = "Aborts in progress move sequence for given node.")
+ static class AbortMove extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.MOVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort move.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Cancels an in-progress LEAVE (decommission) sequence for the given node, keeping
+ * it as an active member of the ring. Fails if no in-progress sequence exists, or if
+ * the sequence is not of kind LEAVE.
+ */
+ @Command(name = "abortdecommission", description = "Aborts in progress decommission sequence for given node.")
+ static class AbortDecommission extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation == null)
+ {
+ throw new IllegalArgumentException("No transformation sequence is in progress for " +
+ nodeIdentifierOption.getNodeIpOrId() + '.');
+ }
+
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.LEAVE)
+ {
+ throw new IllegalArgumentException("Multi step operation of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with abort decommission.");
+ }
+
+ CancelInProgressSequence cancelSequence = new CancelInProgressSequence(nodeId);
+ ClusterMetadata updatedMetadata = cancelSequence.execute(metadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+ }
+
+ /**
+ * Removes a node from cluster metadata by applying the {@link Assassinate} transformation.
+ * If a MOVE or LEAVE sequence is in progress for the node, it is cancelled first.
+ * If the node is a CMS member, it is removed from CMS before assassination.
+ * Fails if the node is in a JOIN or REPLACE sequence; use {@code abortbootstrap} instead.
+ */
+ @Command(name = "assassinate", description = "Assassinates given node from Cluster metadata.")
+ static class AssassinateNode extends ClusterMetadataToolCmd
+ {
+ private final EnumSet<MultiStepOperation.Kind> supportedCancelSequences =
+ EnumSet.of(MultiStepOperation.Kind.MOVE, MultiStepOperation.Kind.LEAVE);
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ // Check if there are any in-progress sequences for given node
+ // If any, then cancel the sequence and then assassinate it
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ MultiStepOperation.Kind sequenceKind = multiStepOperation.kind();
+ if (!supportedCancelSequences.contains(sequenceKind))
+ {
+ if (sequenceKind == MultiStepOperation.Kind.JOIN || sequenceKind == MultiStepOperation.Kind.REPLACE)
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
+ sequenceKind + " is in progress. " +
+ "Run abortbootstrap instead.");
+ }
+ else
+ {
+ throw new IllegalArgumentException("Cannot assassinate the node when sequence of kind " +
+ sequenceKind + " is in progress.");
+ }
+ }
+
+ output.out.printf("Cancelling in-progress sequence of kind %s before assassinating node.\n",
+ metadata.inProgressSequences.get(nodeId).kind());
+ metadata = new CancelInProgressSequence(nodeId).execute(metadata).success().metadata;
+ }
+
+ metadata = maybeReconfigureCMS(metadata, nodeId);
+ Assassinate transformation = new Assassinate(nodeId, ClusterMetadataService.instance().placementProvider());
+ ClusterMetadata updatedMetadata = transformation.execute(metadata).success().metadata;
+
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+
+ ClusterMetadata maybeReconfigureCMS(ClusterMetadata metadata, NodeId nodeId)
+ {
+ InetAddressAndPort addressAndPort = metadata.directory.endpoint(nodeId);
+ if (!metadata.isCMSMember(addressAndPort))
+ {
+ return metadata;
+ }
+
+ // Ref: org.apache.cassandra.tcm.sequences.ReconfigureCMS.maybeReconfigureCMS
+ PrepareCMSReconfiguration.Simple transformation = new PrepareCMSReconfiguration.Simple(nodeId, Set.of());
+ ClusterMetadata updatedMetadata = transformation.execute(metadata).success().metadata;
+ updatedMetadata = updatedMetadata.inProgressSequences.get(ReconfigureCMS.SequenceKey.instance)
+ .applyTo(updatedMetadata).success().metadata;
+ if (updatedMetadata.isCMSMember(addressAndPort))
+ {
+ throw new IllegalStateException("Could not remove node " + nodeIdentifierOption.getNodeIpOrId() +
+ " from CMS.");
+ }
+ return updatedMetadata;
+ }
+ }
+
+ /**
+ * Replaces the entire CMS membership with a single node. All existing CMS replicas are
+ * removed and the specified node becomes the sole CMS member. Useful for recovering from
+ * a state where the CMS is unreachable.
+ */
+ @Command(name = "resetcms",
+ description = "Replaces all CMS members with the specified node. WARNING: all existing CMS replicas are removed.")
+ static class ResetCMS extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ public void execute(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+ InetAddressAndPort nodeAddress = metadata.directory.getNodeAddresses(nodeId).broadcastAddress;
+ metadata = resetCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata resetCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ Iterable<Replica> currentReplicas = metadata.placements.get(metaParams).writes.byEndpoint().flattenValues();
+ DataPlacement.Builder placementBuilder = metadata.placements.get(metaParams).unbuild();
+ for (Replica replica : currentReplicas)
+ {
+ placementBuilder.withoutReadReplica(metadata.epoch, replica)
+ .withoutWriteReplica(metadata.epoch, replica);
+ }
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ placementBuilder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+
+ return metadata.transformer()
+ .with(metadata.placements.unbuild()
+ .with(metaParams, placementBuilder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ /**
+ * Moves a node to a new token. Only supports single-token nodes.
+ * If a MOVE sequence is already in progress for the node, it is completed rather than started fresh;
+ * in that case the provided token must match the token of the in-progress sequence.
+ */
+ @Command(name = "move",
+ description = "Moves node to given token. Works only for cluster having nodes with single token.")
+ static class MoveToken extends ClusterMetadataToolCmd
+ {
+ @CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+ NodeIdentifierOption nodeIdentifierOption;
+
+ @Option(names = { "-t", "--token" },
+ description = "Token to assign.")
+ private String token;
+
+ @Option(names = { "-o", "--output-file" },
+ description = "Output file path for storing the updated Cluster Metadata.")
+ private String outputFilePath;
+
+ @Override
+ protected void execute(Output output) throws IOException
+ {
+ // Took the reference from org.apache.cassandra.tcm.sequences.SingleNodeSequences.move
+ ClusterMetadata metadata = parseClusterMetadata();
+ NodeId nodeId = nodeIdentifierOption.getNodeId(metadata);
+
+ if (metadata.inProgressSequences.contains(nodeId))
+ {
+ ClusterMetadata updatedMetadata = finishInProgressSequence(nodeId, token, metadata);
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ return;
+ }
+
+ if (null == token)
+ {
+ throw new IllegalArgumentException("Token required when no MOVE sequence is in progress.");
+ }
+ metadata.partitioner.getTokenFactory().validate(token);
+ Token toToken = metadata.partitioner.getTokenFactory().fromString(token);
+ if (metadata.tokenMap.tokens().contains(toToken))
+ {
+ throw new IllegalArgumentException("Target token " + toToken + " is already owned by another node.");
+ }
+ if (metadata.tokenMap.tokens(nodeId).size() > 1)
+ {
+ throw new UnsupportedOperationException("This node has more than one token and cannot be moved thusly.");
+ }
+
+ PrepareMove prepareMove = new PrepareMove(nodeId, Set.of(toToken), new UniformRangePlacement(), false);
+ ClusterMetadata updatedMetadata = prepareMove.execute(metadata).success().metadata;
+ updatedMetadata = updatedMetadata.inProgressSequences.get(nodeId).applyTo(updatedMetadata).success().metadata;
+ writeMetadata(output, updatedMetadata, outputFilePath);
+ }
+
+ ClusterMetadata finishInProgressSequence(NodeId nodeId, String token, ClusterMetadata metadata)
+ {
+ MultiStepOperation<?> multiStepOperation = metadata.inProgressSequences.get(nodeId);
+ if (multiStepOperation.kind() != MultiStepOperation.Kind.MOVE)
+ {
+ throw new IllegalArgumentException("Another sequence of kind " + multiStepOperation.kind() +
+ " is in progress for node " + nodeIdentifierOption.getNodeIpOrId() +
+ ". Cannot proceed with move.");
+ }
+
+ Move moveInProgress = (Move) multiStepOperation;
+ Collection<Token> inProgressTokens = moveInProgress.tokens;
+ Collection<Token> givenTokenSet = token == null
+ ? Set.of()
+ : Set.of(metadata.partitioner.getTokenFactory().fromString(token));
Review Comment:
nit: should we call `factory#validate` first?
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
+ if (serializationVersion != null)
+ {
+ // Not a good idea to write metadata using lower serialization version. Ref: CASSANDRA-21174
+ if (currentVersion.isBefore(serializationVersion))
+ {
+ throw new IllegalArgumentException("Given serialization version " + serializationVersion +
+ " is newer compared to current version " + currentVersion + '.');
+ }
+
+ return serializationVersion;
+ }
+
+ Version metadataVersion = metadata.directory.commonSerializationVersion;
+ if (!currentVersion.isAtLeast(metadataVersion))
+ {
+ throw new IllegalArgumentException("Current version " + currentVersion +
+ " is older than version in cluster metadata (" +
+ metadataVersion + "). Cannot proceed further. " +
+ "Try modifying cluster metadata using binaries that support " +
+ "minimum serialization version: " + metadataVersion + '.');
+ }
+
+ return metadataVersion;
+ }
+ }
+
+ /**
+ * Cancels a JOIN or REPLACE bootstrap sequence for the given node and unregisters it
+ * from the cluster. Use this when a node is stuck in bootstrapping or replacement.
+ * Fails if no in-progress sequence exists, or if the sequence is not of kind JOIN or REPLACE.
Review Comment:
move is kind of bootstrap for a new range too
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion(metadata));
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion(ClusterMetadata metadata)
+ {
+ Version currentVersion = NodeVersion.CURRENT.serializationVersion();
+ if (serializationVersion != null)
+ {
+ // Not a good idea to write metadata using lower serialization version. Ref: CASSANDRA-21174
+ if (currentVersion.isBefore(serializationVersion))
+ {
+ throw new IllegalArgumentException("Given serialization version " + serializationVersion +
+ " is newer compared to current version " + currentVersion + '.');
+ }
+
+ return serializationVersion;
+ }
+
+ Version metadataVersion = metadata.directory.commonSerializationVersion;
+ if (!currentVersion.isAtLeast(metadataVersion))
+ {
+ throw new IllegalArgumentException("Current version " + currentVersion +
+ " is older than version in cluster metadata (" +
+ metadataVersion + "). Cannot proceed further. " +
+ "Try modifying cluster metadata using binaries that support " +
+ "minimum serialization version: " + metadataVersion + '.');
+ }
+
+ return metadataVersion;
+ }
+ }
+
+ /**
+ * Cancels a JOIN or REPLACE bootstrap sequence for the given node and unregisters it
+ * from the cluster. Use this when a node is stuck in bootstrapping or replacement.
+ * Fails if no in-progress sequence exists, or if the sequence is not of kind JOIN or REPLACE.
Review Comment:
in other words, it's unclear for me why we separate AbortMove
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org
Re: [PR] CASSANDRA-19151 Adding offline tool for cluster metadata [cassandra]
Posted by "nvharikrishna (via GitHub)" <gi...@apache.org>.
nvharikrishna commented on code in PR #4149:
URL: https://github.com/apache/cassandra/pull/4149#discussion_r2916230568
##########
test/unit/org/apache/cassandra/tools/CMSOfflineToolTest.java:
##########
@@ -0,0 +1,1834 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Murmur3Partitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.DistributedSchema;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.KeyspaceParams;
+import org.apache.cassandra.schema.Keyspaces;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.schema.SchemaConstants;
+import org.apache.cassandra.service.accord.AccordFastPath;
+import org.apache.cassandra.service.accord.AccordStaleReplicas;
+import org.apache.cassandra.service.consensus.migration.ConsensusMigrationState;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.Epoch;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.DataPlacements;
+import org.apache.cassandra.tcm.ownership.TokenMap;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.BootstrapAndReplace;
+import org.apache.cassandra.tcm.sequences.InProgressSequences;
+import org.apache.cassandra.tcm.sequences.LeaveStreams;
+import org.apache.cassandra.tcm.sequences.LockedRanges;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.UnbootstrapAndLeave;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.PrepareJoin;
+import org.apache.cassandra.tcm.transformations.PrepareLeave;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.PrepareReplace;
+import org.apache.cassandra.tcm.transformations.Register;
+
+import static org.apache.cassandra.config.DatabaseDescriptor.getStoragePort;
+import static org.apache.cassandra.distributed.test.log.ClusterMetadataTestHelper.prepareJoin;
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+public class CMSOfflineToolTest extends OfflineToolUtils
Review Comment:
I see the same issue with other tooling test cases as well. I believe it started failing after https://github.com/apache/cassandra/commit/940739a4889794a5f198731b152c70e86bc95976#diff-2a22a979edab7acc8cc1067910e1a1e3db8866c61c4d207b53c6e15d409fe103L115 which removed the removal of initial threads by name. Now `org.apache.cassandra.tools.OfflineToolUtils#assertNoUnexpectedThreadsStarted` is using only regex comparison and RMI thread names as regex are not matching themselves, leading to the unwanted thread issue.
Thought this is the desired behavior and did not fix it. But, pushed the change now as a separate commit https://github.com/apache/cassandra/pull/4149/changes/80765145f49f9e8169e5bc9d06856233955fdeb5.
Surprisingly, I did not face this issue when I ran in debug mode or from the command line. I found that adding `-XX:+DisableAttachMechanism` to the JVM args helped to fix the issue (without the commit mentioned above), even when I ran it from IntelliJ. I am not sure if it is the expected behavior though.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org
Re: [PR] CASSANDRA-19151 Adding offline tool for cluster metadata [cassandra]
Posted by "nvharikrishna (via GitHub)" <gi...@apache.org>.
nvharikrishna commented on code in PR #4149:
URL: https://github.com/apache/cassandra/pull/4149#discussion_r2230036164
##########
src/java/org/apache/cassandra/tools/nodetool/CMSAdmin.java:
##########
@@ -269,4 +271,76 @@ public void execute(NodeProbe probe)
probe.getCMSOperationsProxy().resumeDropAccordTable(tableId);
}
}
+
+ @Command(name = "dumpclustermetadata", description = "Dumps Cluster Metadata into a file")
Review Comment:
What abut just `dump` ?
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,434 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+
+ Cli.CliBuilder<ClusterMetadataToolRunnable> builder = Cli.builder(TOOL_NAME);
+
+ List<Class<? extends ClusterMetadataToolRunnable>> commands = new ArrayList<>()
+ {{
+ add(ClusterMetadataToolHelp.class);
+ add(AddToCMS.class);
+ add(AssignTokens.class);
+ add(Describe.class);
+ add(ForceJoin.class);
+ add(ForgetNode.class);
+ add(PrintDataPlacements.class);
+ add(PrintDirectoryCmd.class);
+ }};
+
+ builder.withDescription("Offline tool to print or update cluster metadata dump")
+ .withDefaultCommand(ClusterMetadataToolHelp.class)
+ .withCommands(commands);
+
+ Cli<ClusterMetadataToolRunnable> parser = builder.build();
+ int status = 0;
+ try
+ {
+ ClusterMetadataToolRunnable parse = parser.parse(args);
+ parse.run(output);
+ }
+ catch (ParseException pe)
+ {
+ status = 1;
+ badUse(pe);
+ }
+ catch (Exception e)
+ {
+ status = 2;
+ err(e);
+ }
+ return status;
+ }
+
+
+ private void badUse(Exception e)
+ {
+ output.err.println(TOOL_NAME + ": " + e.getMessage());
+ output.err.printf("See '%s help' or '%s help <command>'.%n", TOOL_NAME, TOOL_NAME);
+ }
+
+ private void err(Exception e)
+ {
+ output.err.println("error: " + e.getMessage());
+ output.err.println("-- StackTrace --");
+ output.err.println(getStackTraceAsString(e));
+ }
+
+
+ interface ClusterMetadataToolRunnable
+ {
+ void run(Output output) throws IOException;
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements ClusterMetadataToolRunnable
+ {
+ @Option(type = OptionType.COMMAND, name = { "-f", "--file" }, description = "Cluster metadata dump file path", required = true)
+ protected String metadataDumpPath;
+
+ @Option(type = OptionType.COMMAND, name = { "-sv", "--serialization-version" }, description = "Serialization version to use")
+ private Version serializationVersion;
+
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpPath);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpPath + " does not exist");
+ }
+
+ Version serializationVersion = NodeVersion.CURRENT.serializationVersion();
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpPath))
+ {
+ // skip over the prefix specifying the metadata version
+ fisp.readUnsignedVInt32();
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, serializationVersion);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpPath);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
+
+
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(p))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ getSerializationVersion());
+ output.out.println("Updated cluster metadata written to file " + p.toAbsolutePath());
+ }
+ }
+
+ Version getSerializationVersion()
+ {
+ return serializationVersion != null ? serializationVersion : NodeVersion.CURRENT.serializationVersion();
+ }
+ }
+
+ public static class ClusterMetadataToolHelp extends Help implements ClusterMetadataToolRunnable
+ {
+
+ @Override
+ public void run(Output output)
+ {
+ run();
+ }
+ }
+
+ @Command(name = "addtocms", description = "Makes a node as CMS member")
+ public static class AddToCMS extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of node to make CMS", required = true)
+ private String ipAddress;
+
+ @Option(type = OptionType.COMMAND, name = { "-o", "--output-file" }, description = "Ouput file path for storing the updated Cluster Metadata")
+ private String outputFilePath;
+
+ @Override
+ public void run(Output output) throws IOException
+ {
+ ClusterMetadata metadata = parseClusterMetadata();
+ InetAddressAndPort nodeAddress = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ metadata = makeCMS(metadata, nodeAddress);
+ writeMetadata(output, metadata, outputFilePath);
+ }
+
+ ClusterMetadata makeCMS(ClusterMetadata metadata, InetAddressAndPort endpoint)
+ {
+ ReplicationParams metaParams = ReplicationParams.meta(metadata);
+ DataPlacement.Builder builder = metadata.placements.get(metaParams).unbuild();
+
+ Replica newCMS = MetaStrategy.replica(endpoint);
+ builder.withReadReplica(metadata.epoch, newCMS)
+ .withWriteReplica(metadata.epoch, newCMS);
+ return metadata.transformer().with(metadata.placements.unbuild().with(metaParams,
+ builder.build())
+ .build())
+ .build().metadata;
+ }
+ }
+
+ @Command(name = "assigntokens", description = "Assigns a token for given instance")
+ public static class AssignTokens extends ClusterMetadataToolCmd
+ {
+ @Option(name = { "-ip", "--ip-address" }, description = "IP address of endpoint. Port can be specified as well.", required = true)
Review Comment:
Done
##########
src/java/org/apache/cassandra/tools/nodetool/CMSAdmin.java:
##########
@@ -269,4 +271,76 @@ public void execute(NodeProbe probe)
probe.getCMSOperationsProxy().resumeDropAccordTable(tableId);
}
}
+
+ @Command(name = "dumpclustermetadata", description = "Dumps Cluster Metadata into a file")
+ public static class DumpClusterMetadata extends NodeTool.NodeToolCmd
+ {
+
+ @Option(title = "Epoch", name = { "-e", "--epoch" }, required = false,
+ description = "Epoch at which cluster metadata should be dumped")
+ private Long epoch;
+
+ @Option(title = "Transform Epoch", name = { "-te", "--transform-epoch" }, required = false,
+ description = "The epoch to which the cluster meta data should be transformed before dumping")
Review Comment:
done
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,434 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.airlift.airline.Cli;
+import io.airlift.airline.Command;
+import io.airlift.airline.Help;
+import io.airlift.airline.Option;
+import io.airlift.airline.OptionType;
+import io.airlift.airline.ParseException;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.utils.FBUtilities;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata dump.
+ */
+public class CMSOfflineTool
+{
+
+ private static final String TOOL_NAME = "cmsofflinetool";
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ //noinspection UseOfSystemOutOrSystemErr
+ System.exit(new CMSOfflineTool(new Output(System.out, System.err)).execute(args));
+ }
+
+ public int execute(String... args)
+ {
+
Review Comment:
done
##########
src/java/org/apache/cassandra/tools/nodetool/CMSAdmin.java:
##########
@@ -269,4 +271,76 @@ public void execute(NodeProbe probe)
probe.getCMSOperationsProxy().resumeDropAccordTable(tableId);
}
}
+
+ @Command(name = "dumpclustermetadata", description = "Dumps Cluster Metadata into a file")
+ public static class DumpClusterMetadata extends NodeTool.NodeToolCmd
+ {
+
+ @Option(title = "Epoch", name = { "-e", "--epoch" }, required = false,
+ description = "Epoch at which cluster metadata should be dumped")
+ private Long epoch;
+
+ @Option(title = "Transform Epoch", name = { "-te", "--transform-epoch" }, required = false,
+ description = "The epoch to which the cluster meta data should be transformed before dumping")
+ private Long transformEpoch;
+
+ @Option(title = "Searialization Version", name = { "-sv", "--serialization-version" }, required = false,
+ description = "Searialization Version")
Review Comment:
done
##########
src/java/org/apache/cassandra/tools/nodetool/CMSAdmin.java:
##########
@@ -269,4 +271,76 @@ public void execute(NodeProbe probe)
probe.getCMSOperationsProxy().resumeDropAccordTable(tableId);
}
}
+
+ @Command(name = "dumpclustermetadata", description = "Dumps Cluster Metadata into a file")
+ public static class DumpClusterMetadata extends NodeTool.NodeToolCmd
+ {
+
+ @Option(title = "Epoch", name = { "-e", "--epoch" }, required = false,
+ description = "Epoch at which cluster metadata should be dumped")
+ private Long epoch;
+
+ @Option(title = "Transform Epoch", name = { "-te", "--transform-epoch" }, required = false,
+ description = "The epoch to which the cluster meta data should be transformed before dumping")
+ private Long transformEpoch;
+
+ @Option(title = "Searialization Version", name = { "-sv", "--serialization-version" }, required = false,
+ description = "Searialization Version")
+ private Version version;
+
+ protected void execute(NodeProbe probe)
+ {
+ if (epoch == null && transformEpoch == null && version == null)
+ {
+ try
+ {
+ String fileLocation = probe.getCMSOperationsProxy().dumpClusterMetadata();
+ printCMSDumpLocation(probe, fileLocation);
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+ else if (epoch != null && transformEpoch != null && version != null)
+ {
+ try
+ {
+ String fileLocation = probe.getCMSOperationsProxy().dumpClusterMetadata(epoch,
+ transformEpoch,
+ version.name());
+ printCMSDumpLocation(probe, fileLocation);
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+ else
+ {
+ List<String> invalidArgs = new ArrayList<>(2);
+ if (null == epoch)
+ {
Review Comment:
done
##########
src/java/org/apache/cassandra/tools/nodetool/CMSAdmin.java:
##########
@@ -269,4 +271,76 @@ public void execute(NodeProbe probe)
probe.getCMSOperationsProxy().resumeDropAccordTable(tableId);
}
}
+
+ @Command(name = "dumpclustermetadata", description = "Dumps Cluster Metadata into a file")
+ public static class DumpClusterMetadata extends NodeTool.NodeToolCmd
+ {
+
+ @Option(title = "Epoch", name = { "-e", "--epoch" }, required = false,
+ description = "Epoch at which cluster metadata should be dumped")
+ private Long epoch;
+
+ @Option(title = "Transform Epoch", name = { "-te", "--transform-epoch" }, required = false,
+ description = "The epoch to which the cluster meta data should be transformed before dumping")
+ private Long transformEpoch;
+
+ @Option(title = "Searialization Version", name = { "-sv", "--serialization-version" }, required = false,
Review Comment:
done
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org
Re: [PR] CASSANDRA-19151 Adding offline tool for cluster metadata [cassandra]
Posted by "nvharikrishna (via GitHub)" <gi...@apache.org>.
nvharikrishna commented on code in PR #4149:
URL: https://github.com/apache/cassandra/pull/4149#discussion_r2915678095
##########
test/unit/org/apache/cassandra/tools/CMSOfflineToolTest.java:
##########
@@ -0,0 +1,1834 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Murmur3Partitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.DistributedSchema;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.KeyspaceParams;
+import org.apache.cassandra.schema.Keyspaces;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.schema.SchemaConstants;
+import org.apache.cassandra.service.accord.AccordFastPath;
+import org.apache.cassandra.service.accord.AccordStaleReplicas;
+import org.apache.cassandra.service.consensus.migration.ConsensusMigrationState;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.Epoch;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.DataPlacements;
+import org.apache.cassandra.tcm.ownership.TokenMap;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.BootstrapAndReplace;
+import org.apache.cassandra.tcm.sequences.InProgressSequences;
+import org.apache.cassandra.tcm.sequences.LeaveStreams;
+import org.apache.cassandra.tcm.sequences.LockedRanges;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.UnbootstrapAndLeave;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.PrepareJoin;
+import org.apache.cassandra.tcm.transformations.PrepareLeave;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.PrepareReplace;
+import org.apache.cassandra.tcm.transformations.Register;
+
+import static org.apache.cassandra.config.DatabaseDescriptor.getStoragePort;
+import static org.apache.cassandra.distributed.test.log.ClusterMetadataTestHelper.prepareJoin;
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+public class CMSOfflineToolTest extends OfflineToolUtils
+{
+
+ public static final String DC = "datacenter1";
+ @Rule
+ public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ private static ClusterMetadata getClusterMetadata(Keyspaces keyspaces, IPartitioner partitioner, Directory directory)
+ {
+ DistributedSchema distributedSchema = new DistributedSchema(keyspaces);
+
+ return new ClusterMetadata(Epoch.EMPTY,
+ partitioner,
+ distributedSchema,
+ directory,
+ new TokenMap(partitioner),
+ DataPlacements.empty(),
+ AccordFastPath.EMPTY,
+ LockedRanges.EMPTY,
+ InProgressSequences.EMPTY,
+ ConsensusMigrationState.EMPTY,
+ ImmutableMap.of(),
+ AccordStaleReplicas.EMPTY);
+ }
+
+ @Before
+ public void setup()
+ {
+ DatabaseDescriptor.toolInitialization();
+ ClusterMetadataService.initializeForTools(true);
+ }
+
+ @Test
+ public void testDefaultCmd()
+ {
+ ToolRunner.ToolResult tool = ToolRunner.invokeClass(CMSOfflineTool.class);
+
+ tool.assertOnCleanExit();
+ assertThat(tool.getExitCode()).isZero();
+ assertThat(tool.getStderr()).isEmpty();
+ assertThat(tool.getStdout()).withFailMessage(tool.getStderr()).contains("Usage");
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testRunCommandThatDoesNotExist()
+ {
+ ToolRunner.ToolResult tool = ToolRunner.invokeClass(CMSOfflineTool.class, "cmddoesnotexist");
+ assertThat(tool.getExitCode()).isEqualTo(2);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testRunCommandWithMetadataFileThatDoesnotExist()
+ {
+ String metadataFile = temporaryFolder.getRoot().getAbsolutePath() + "/file-does-not-exists.dump";
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdirectory",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).isEqualTo(2);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapJoiningNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ int nodeToMove = 4;
+ metadata = startJoining(nodeToMove, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(nodeToMove);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerIds()).doesNotContain(new NodeId(nodeToMove));
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapReplacingNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ NodeId nodeToReplaceId = new NodeId(3);
+ NodeId newNodeId = new NodeId(4);
+ metadata = startReplacing(nodeToReplaceId, newNodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ String newNodeAddr = metadata.directory.getNodeAddresses(newNodeId).nativeAddress.getHostAddressAndPort();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeAddr,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerIds()).doesNotContain(newNodeId);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapNodeGettingReplaced() throws IOException
+ {
+ // Assuming that operator unintentionally invokes abortbootstrap on node getting replaced
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ NodeId nodeToReplaceId = new NodeId(3);
+ NodeId newNodeId = new NodeId(4);
+ metadata = startReplacing(nodeToReplaceId, newNodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ String nodeGettingReplaced = metadata.directory.getNodeAddresses(nodeToReplaceId).nativeAddress.getHostAddressAndPort();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeGettingReplaced,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Did not find any sequences in progress for node " + nodeGettingReplaced);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapWhenLeavingSequenceIsInProgress() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startLeaving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("abortbootstrap is not a valid operation when sequence of kind LEAVE");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapJoinedNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ int nodeId = 4;
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortMoveNoInProgressSequence() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ int nodeId = 4;
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortmove",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("No transformation sequence is in progress for");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortMoveNonMovingSequence() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startLeaving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortmove",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Multi step operation of kind")
+ .contains("LEAVE")
+ .contains("Cannot proceed with abort move");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortMoveMovingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startMoving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortmove",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.inProgressSequences.contains(nodeId)).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortDecommissionNoInProgressSequence() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ int nodeId = 4;
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortdecommission",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("No transformation sequence is in progress for");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortDecommissionNonLeavingSequence() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startMoving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortdecommission",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Multi step operation of kind")
+ .contains("MOVE")
+ .contains("Cannot proceed with abort decommission");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortDecommissionLeavingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startLeaving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortdecommission",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.inProgressSequences.contains(nodeId)).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateCMSMember() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ // Verify that we have CMS members
+ assertThat(metadata.fullCMSMembers()).isNotEmpty();
+
+ // Pick the first CMS member to assassinate
+ NodeId cmsNodeId = metadata.directory.peerIds().stream().findFirst().orElseThrow();
+ assertThat(metadata.isCMSMember(metadata.directory.endpoint(cmsNodeId))).isTrue();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(cmsNodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(cmsNodeId)).isEqualTo(NodeState.LEFT);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateNodeNonCMSMember() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ // Verify that we have CMS members
+ assertThat(metadata.fullCMSMembers()).isNotEmpty();
+
+ // Select non-CMS node
+ NodeId nonCMSNodeId = metadata.directory.peerIds().stream()
+ .filter(id -> !metadata.isCMSMember(metadata.directory.endpoint(id)))
+ .findFirst().orElseThrow();
+ assertThat(metadata.isCMSMember(metadata.directory.endpoint(nonCMSNodeId))).isFalse();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nonCMSNodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(nonCMSNodeId)).isEqualTo(NodeState.LEFT);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateNodeInvalidNodeId() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(-1);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("No node present with id " + nodeId +
+ " in the given cluster metadata");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateMovingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ int nodeToMove = 4;
+ metadata = startMoving(new NodeId(nodeToMove), metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(nodeToMove);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+ assertThat(result.getStdout()).contains("Cancelling in-progress sequence");
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(new NodeId(nodeToMove))).isEqualTo(NodeState.LEFT);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateJoiningNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ int nodeToMove = 4;
+ metadata = startJoining(nodeToMove, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(nodeToMove);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ // It should suggest to use abortbootstrap for node in joining state
+ assertThat(result.getStderr()).contains("abortbootstrap");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateNodeGettingReplaced() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeToReplaceId = new NodeId(4);
+ NodeId newNodeId = new NodeId(5);
+ metadata = startReplacing(nodeToReplaceId, newNodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ String nodeGettingReplaced = metadata.directory.getNodeAddresses(nodeToReplaceId)
+ .nativeAddress.getHostAddressAndPort();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeGettingReplaced,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("INVALID: Rejecting this plan as it interacts with a range locked");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateLeavingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startLeaving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String ip = metadata.directory.endpoint(nodeId).getHostAddressAndPort();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-ip",
+ ip,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+ assertThat(result.getStdout()).contains("Cancelling in-progress sequence");
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(nodeId)).isEqualTo(NodeState.LEFT);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testDescribe() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "describe",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+
+ int storagePort = getStoragePort();
+
+ String expectedOutput =
+ "Cluster Metadata Service:\n" +
+ "Members: /127.0.0.1:" + storagePort + ",/127.0.0.2:" + storagePort + ",/127.0.0.3:" + storagePort + '\n' +
+ "Needs reconfiguration: false\n" +
+ "Service State: LOCAL\n" +
+ "Epoch: 2\n" +
+ "Replication factor: ReplicationParams{class=org.apache.cassandra.locator.MetaStrategy, datacenter1=3}\n";
+ assertThat(result.getStdout()).isEqualTo(expectedOutput);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testDescribeRawString() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "print",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStdout()).isNotEmpty().startsWith("ClusterMetadata{");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMSUsingNodeId() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out" + new Random().nextLong() + ".dump";
+ NodeId nodeId = new NodeId(1);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStderr()).isEmpty();
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ InetAddressAndPort nodeAddress = metadata.directory.getNodeAddresses(nodeId).broadcastAddress;
+ assertThat(outMetadata.isCMSMember(nodeAddress)).isTrue();
+ assertThat(outMetadata.fullCMSMembers().size()).isEqualTo(1);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMS() throws IOException
+ {
+ assertResetCMS("127.0.0.1:" + getStoragePort());
+ }
+
+ @Test
+ public void testResetCMSUsingIp() throws IOException
+ {
+ assertResetCMS("127.0.0.1");
+ }
+
+ private void assertResetCMS(String ipAddress) throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out" + new Random().nextLong() + ".dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-ip",
+ ipAddress,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStderr()).isEmpty();
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+
+ // Check given ip address is added to CMS members
+ InetAddressAndPort candidate = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ assertThat(outMetadata.isCMSMember(candidate)).isTrue();
+ assertThat(outMetadata.fullCMSMembers().size()).isEqualTo(1);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMSUsingIpAddressThatDoesNotExist() throws IOException
+ {
+ // Node that doesn't exist in Cluster Metadata can be added as CMS member
+ String ipAddress = "127.0.0.55:" + getStoragePort();
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-ip",
+ ipAddress,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isNotEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("No node present").contains(ipAddress);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMSInvalidIpAddress() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidIpAddress = "/127.0.0.";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-ip",
+ invalidIpAddress,
+ "-o",
+ outputFile);
+
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).contains("java.net.UnknownHostException");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMSInvalidSerializationVersion() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidIpAddress = "127.0.0.3";
+ String serializationVersion = "-1";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-sv",
+ serializationVersion,
+ "-ip",
+ invalidIpAddress,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Invalid value for option '--serialization-version'");
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveToken() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata(16);
+ String nodeToMove = "127.0.0.4:" + getStoragePort();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeToMove,
+ "-t",
+ String.valueOf(metadata.partitioner.getRandomToken()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("This node has more than one token and cannot be moved thusly.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveTokenVnodes() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata(4);
+ metadata = addNewNode(metadata, 4, Set.of(metadata.partitioner.getRandomToken()));
+ String nodeToMove = "127.0.0.4:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(nodeToMove);
+ NodeId nodeId = metadata.directory.peerId(InetAddressAndPort.getByName(nodeToMove));
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token desiredToken = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeToMove,
+ "-t",
+ String.valueOf(desiredToken.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+ assertThat(outMetadata.directory.peerId(newNodeInetAddress)).isNotNull();
+ assertThat(outMetadata.tokenMap.tokens(nodeId)).contains(desiredToken);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveTokenWithoutToken() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String nodeToMove = "127.0.0.4:" + getStoragePort();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeToMove,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Token required").contains("MOVE");
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveTokenForNonExistingNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String unknownNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token tokenToAssign = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ unknownNodeIpWithPort,
+ "-t",
+ String.valueOf(tokenToAssign.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).contains("No node present with ip address")
+ .contains(unknownNodeIpWithPort);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveInvalidIpAddress() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String invalidIp = "127.0.0.";
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token tokenToAssign = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ invalidIp,
+ "-t",
+ String.valueOf(tokenToAssign.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).contains("java.net.UnknownHostException");
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveNodeToInvalidToken() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0.1:" + getStoragePort();
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidToken = "somegibberishinvalidtoken";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ invalidToken,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveNodeToSomeOtherNodeToken() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0.1:" + getStoragePort();
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ImmutableList<Token> secondNodeTokenList = metadata.tokenMap.tokens(new NodeId(2));
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ secondNodeTokenList.get(0).toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveNodeWhenMoveInProgressToAnotherToken() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ NodeId nodeId = new NodeId(2);
+ Token randomToken = metadata.partitioner.getRandomToken();
+ Set<Token> toTokenSet = Set.of(randomToken);
+ metadata = startMoving(nodeId, metadata, toTokenSet);
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0." + nodeId.id() + ':' + getStoragePort();
+
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ randomToken.toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.tokenMap.tokens(nodeId)).isEqualTo(ImmutableList.of(randomToken));
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveNodeFinishInProgress() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ NodeId nodeId = new NodeId(2);
+ metadata = startMoving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0." + nodeId.id() + ':' + getStoragePort();
+
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Move in progress for another token(s)");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoin() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ assertThat(metadata.directory.states.get(newNodeId)).isEqualTo(NodeState.REGISTERED);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+ assertThat(outMetadata.directory.peerId(newNodeInetAddress)).isNotNull();
+ assertThat(outMetadata.directory.states.get(newNodeId)).isEqualTo(NodeState.JOINED);
+ assertThat(outMetadata.tokenMap.tokens(newNodeId)).isNotEmpty();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinNewNodeWithoutTokens() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ assertThat(metadata.directory.states.get(newNodeId)).isEqualTo(NodeState.REGISTERED);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Tokens must be provided to force join a node.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinUnknownNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ // New node is not registered, it should fail
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isNotEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains(newNodeIpWithPort)
+ .contains("No node present with ip address");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinMovingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startMoving(nodeId, metadata);
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.MOVING);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Another sequence of kind MOVE is in progress for node " + nodeId.id() +
+ ". Cannot proceed with force join.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinAlreadyJoinedNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.JOINED);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Node " + nodeId.id() + " is already in JOINED state.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinLeftNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ int id = 4;
+ NodeId nodeId = new NodeId(id);
+ metadata = startLeaving(nodeId, metadata);
+ UnbootstrapAndLeave unbootstrapAndLeave = (UnbootstrapAndLeave) metadata.inProgressSequences.get(nodeId);
+ metadata = unbootstrapAndLeave.applyTo(metadata).success().metadata;
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.LEFT);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(nodeId)).isEqualTo(NodeState.JOINED);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinJoiningNodeTokenMismatch() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ int nodeNum = 4;
+ metadata = startJoining(nodeNum, metadata);
+ NodeId nodeId = new NodeId(nodeNum);
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.BOOTSTRAPPING);
+
+ BootstrapAndJoin bootstrapAndJoin = (BootstrapAndJoin) metadata.inProgressSequences.get(nodeId);
+ Set<Token> sequenceTokens = bootstrapAndJoin.finishJoin.tokens;
+
+ // Pick a token that is different from the in-progress sequence tokens
+ Token differentToken = metadata.partitioner.getRandomToken();
+ while (sequenceTokens.contains(differentToken))
+ differentToken = metadata.partitioner.getRandomToken();
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-t",
+ differentToken.toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("do not match with")
+ .contains("in progress BootstrapAndJoin sequence tokens");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinJoiningNodeTokenMatch() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ int nodeNum = 4;
+ metadata = startJoining(nodeNum, metadata);
+ NodeId nodeId = new NodeId(nodeNum);
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.BOOTSTRAPPING);
+
+ BootstrapAndJoin bootstrapAndJoin = (BootstrapAndJoin) metadata.inProgressSequences.get(nodeId);
+ Set<Token> sequenceTokens = bootstrapAndJoin.finishJoin.tokens;
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ // Build args: one -t per token in the sequence
+ List<String> args = new ArrayList<>(List.of("forcejoin", "-f", metadataFile,
+ "-id", String.valueOf(nodeId.id()),
+ "-o", outputFile));
+ for (Token t : sequenceTokens)
+ {
+ args.add("-t");
+ args.add(t.toString());
+ }
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ args.toArray(new String[0]));
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(nodeId)).isEqualTo(NodeState.JOINED);
+ assertThat(outMetadata.tokenMap.tokens(nodeId)).containsAll(sequenceTokens);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinNodeThatDoesNotExist() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = "-1";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).isNotEmpty();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinWithSerializationVersion() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ assertThat(metadata.directory.states.get(newNodeId)).isEqualTo(NodeState.REGISTERED);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ // Use V7 — a known valid version older than the current (V8)
+ Version targetVersion = Version.V7;
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-sv",
+ targetVersion.toString(),
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ // Verify that the output file was serialized using the requested version
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(outputFile))
+ {
+ int versionInt = fisp.readUnsignedVInt32();
+ assertThat(Version.fromInt(versionInt)).isEqualTo(targetVersion);
+ }
+
+ // Verify the node was force-joined in the output metadata
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(newNodeId)).isEqualTo(NodeState.JOINED);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinInvalidSerializationVersion() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidSerializationVersion = "-1";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-sv",
+ invalidSerializationVersion,
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Invalid value for option '--serialization-version'");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testPrintDataPlacements() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String keyspaceName = "ks1";
+ KeyspaceParams ksParams = KeyspaceParams.create(true,
+ Map.of("class", "NetworkTopologyStrategy",
+ "datacenter1", "3"));
+ Keyspaces keyspaces = Keyspaces.none().with(KeyspaceMetadata.create(keyspaceName, ksParams));
+ DistributedSchema newSchema = new DistributedSchema(keyspaces);
+ metadata = updateMetadata(metadata, newSchema);
+
+ InetAddressAndPort inetAddressAndPort = InetAddressAndPort.getByNameUnchecked("127.0.0.1:" + getStoragePort());
+ InetAddressAndPort i2 = InetAddressAndPort.getByNameUnchecked("127.0.0.2:" + getStoragePort());
+ InetAddressAndPort i3 = InetAddressAndPort.getByNameUnchecked("127.0.0.3:" + getStoragePort());
+
+ Range<Token> tokenRange = new Range<>(metadata.partitioner.getMinimumToken(), metadata.partitioner.getTokenFactory().fromString("0"));
+
+ DataPlacement dataPlacement = DataPlacement.builder()
+ .withWriteReplica(Epoch.FIRST, Replica.fullReplica(inetAddressAndPort, tokenRange))
+ .withWriteReplica(Epoch.FIRST, Replica.fullReplica(i2, tokenRange))
+ .withWriteReplica(Epoch.FIRST, Replica.fullReplica(i3, tokenRange))
+ .withReadReplica(Epoch.FIRST, Replica.fullReplica(inetAddressAndPort, tokenRange))
+ .withReadReplica(Epoch.FIRST, Replica.fullReplica(i2, tokenRange))
+ .withReadReplica(Epoch.FIRST, Replica.fullReplica(i3, tokenRange))
+ .build();
+
+ DataPlacements dataPlacements = DataPlacements.builder(1)
+ .with(ReplicationParams.fromMap(Map.of("class", "NetworkTopologyStrategy",
+ "datacenter1", "3")),
+ dataPlacement)
+ .build();
+ metadata = updateMetadata(metadata, dataPlacements);
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdataplacements",
+ "-f",
+ metadataFile,
+ "-ks",
+ keyspaceName);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStdout()).isNotEmpty();
+ assertThat(result.getStderr()).isNullOrEmpty();
+
+ String stdout = result.getStdout();
+ assertThat(stdout).isEqualTo(
+ "Token Range Type Endpoints\n" +
+ "(-9223372036854775808,0] read /127.0.0.1:" + getStoragePort() + ", /127.0.0.2:" + getStoragePort() + ", /127.0.0.3:" + getStoragePort() + '\n' +
+ "(-9223372036854775808,0] write /127.0.0.1:" + getStoragePort() + ", /127.0.0.2:" + getStoragePort() + ", /127.0.0.3:" + getStoragePort() + '\n');
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testPrintDataPlacementsKeyspaceDoesNotExist() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String keyspaceName = "ks1";
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdataplacements",
+ "-f",
+ metadataFile,
+ "-ks",
+ keyspaceName);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).isNotEmpty();
+ assertThat(result.getStderr()).contains("Keyspace " + keyspaceName + " not found in cluster metadata");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testPrintDirectory() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdirectory",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStdout()).isNotEmpty();
+ assertThat(result.getStderr()).isNullOrEmpty();
+
+ String stdout = result.getStdout();
+
+ String expectedSerializationVersion = NodeVersion.CURRENT.serializationVersion().toString();
+ assertThat(stdout).isEqualTo(
+ "NodeId: 1\n" +
+ " rack rack1\n" +
+ " local_port " + getStoragePort() + '\n' +
+ " broadcast_port " + getStoragePort() + '\n' +
+ " host_id 6d194555-f6eb-41d0-c000-000000000001\n" +
+ " broadcast_address /127.0.0.1\n" +
+ " native_address /127.0.0.1\n" +
+ " native_port " + getStoragePort() + '\n' +
+ " local_address /127.0.0.1\n" +
+ " state JOINED\n" +
+ " serialization_version " + expectedSerializationVersion + '\n' +
+ " cassandra_version " + metadata.directory.version(new NodeId(1)).cassandraVersion + '\n' +
+ " dc datacenter1\n" +
+ " is_cms_member true\n" +
+ "NodeId: 2\n" +
+ " rack rack2\n" +
+ " local_port " + getStoragePort() + '\n' +
+ " broadcast_port " + getStoragePort() + '\n' +
+ " host_id 6d194555-f6eb-41d0-c000-000000000002\n" +
+ " broadcast_address /127.0.0.2\n" +
+ " native_address /127.0.0.2\n" +
+ " native_port " + getStoragePort() + '\n' +
+ " local_address /127.0.0.2\n" +
+ " state JOINED\n" +
+ " serialization_version " + expectedSerializationVersion + '\n' +
+ " cassandra_version " + metadata.directory.version(new NodeId(2)).cassandraVersion + '\n' +
+ " dc datacenter1\n" +
+ " is_cms_member true\n" +
+ "NodeId: 3\n" +
+ " rack rack3\n" +
+ " local_port " + getStoragePort() + '\n' +
+ " broadcast_port " + getStoragePort() + '\n' +
+ " host_id 6d194555-f6eb-41d0-c000-000000000003\n" +
+ " broadcast_address /127.0.0.3\n" +
+ " native_address /127.0.0.3\n" +
+ " native_port " + getStoragePort() + '\n' +
+ " local_address /127.0.0.3\n" +
+ " state JOINED\n" +
+ " serialization_version " + expectedSerializationVersion + '\n' +
+ " cassandra_version " + metadata.directory.version(new NodeId(3)).cassandraVersion + '\n' +
+ " dc datacenter1\n" +
+ " is_cms_member true\n"
+ );
+
+ assertCorrectEnvPostTest();
+ }
+
+ private String dumpMetadata(ClusterMetadata metadata) throws IOException
+ {
+ String tempFile = temporaryFolder.newFile().getAbsolutePath();
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(tempFile))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ NodeVersion.CURRENT.serializationVersion());
+ }
+ return tempFile;
+ }
+
+ /**
+ * Creates a three-node cluster metadata for testing.
+ */
+
+ private ClusterMetadata getThreeNodeClusterMetadata()
+ {
+ return getThreeNodeClusterMetadata(1);
+ }
+
+ private ClusterMetadata getThreeNodeClusterMetadata(int tokenSize)
+ {
+ IPartitioner partitioner = Murmur3Partitioner.instance;
+ NodeId nodeId1 = new NodeId(1);
+ NodeId nodeId2 = new NodeId(2);
+ NodeId nodeId3 = new NodeId(3);
+
+ InetAddressAndPort addr1 = InetAddressAndPort.getByNameUnchecked("127.0.0.1:" + getStoragePort());
+ InetAddressAndPort addr2 = InetAddressAndPort.getByNameUnchecked("127.0.0.2:" + getStoragePort());
+ InetAddressAndPort addr3 = InetAddressAndPort.getByNameUnchecked("127.0.0.3:" + getStoragePort());
+
+ NodeVersion nodeVersion = NodeVersion.CURRENT;
+
+ Directory directory =
Review Comment:
Formatted it!
##########
test/unit/org/apache/cassandra/tools/CMSOfflineToolTest.java:
##########
@@ -0,0 +1,1834 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Murmur3Partitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.DistributedSchema;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.KeyspaceParams;
+import org.apache.cassandra.schema.Keyspaces;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.schema.SchemaConstants;
+import org.apache.cassandra.service.accord.AccordFastPath;
+import org.apache.cassandra.service.accord.AccordStaleReplicas;
+import org.apache.cassandra.service.consensus.migration.ConsensusMigrationState;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.Epoch;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.DataPlacements;
+import org.apache.cassandra.tcm.ownership.TokenMap;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.BootstrapAndReplace;
+import org.apache.cassandra.tcm.sequences.InProgressSequences;
+import org.apache.cassandra.tcm.sequences.LeaveStreams;
+import org.apache.cassandra.tcm.sequences.LockedRanges;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.UnbootstrapAndLeave;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.PrepareJoin;
+import org.apache.cassandra.tcm.transformations.PrepareLeave;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.PrepareReplace;
+import org.apache.cassandra.tcm.transformations.Register;
+
+import static org.apache.cassandra.config.DatabaseDescriptor.getStoragePort;
+import static org.apache.cassandra.distributed.test.log.ClusterMetadataTestHelper.prepareJoin;
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+public class CMSOfflineToolTest extends OfflineToolUtils
+{
+
+ public static final String DC = "datacenter1";
+ @Rule
+ public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ private static ClusterMetadata getClusterMetadata(Keyspaces keyspaces, IPartitioner partitioner, Directory directory)
+ {
+ DistributedSchema distributedSchema = new DistributedSchema(keyspaces);
+
+ return new ClusterMetadata(Epoch.EMPTY,
+ partitioner,
+ distributedSchema,
+ directory,
+ new TokenMap(partitioner),
+ DataPlacements.empty(),
+ AccordFastPath.EMPTY,
+ LockedRanges.EMPTY,
+ InProgressSequences.EMPTY,
+ ConsensusMigrationState.EMPTY,
+ ImmutableMap.of(),
+ AccordStaleReplicas.EMPTY);
+ }
+
+ @Before
+ public void setup()
+ {
+ DatabaseDescriptor.toolInitialization();
+ ClusterMetadataService.initializeForTools(true);
+ }
+
+ @Test
+ public void testDefaultCmd()
+ {
+ ToolRunner.ToolResult tool = ToolRunner.invokeClass(CMSOfflineTool.class);
+
+ tool.assertOnCleanExit();
+ assertThat(tool.getExitCode()).isZero();
+ assertThat(tool.getStderr()).isEmpty();
+ assertThat(tool.getStdout()).withFailMessage(tool.getStderr()).contains("Usage");
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testRunCommandThatDoesNotExist()
+ {
+ ToolRunner.ToolResult tool = ToolRunner.invokeClass(CMSOfflineTool.class, "cmddoesnotexist");
+ assertThat(tool.getExitCode()).isEqualTo(2);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testRunCommandWithMetadataFileThatDoesnotExist()
+ {
+ String metadataFile = temporaryFolder.getRoot().getAbsolutePath() + "/file-does-not-exists.dump";
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdirectory",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).isEqualTo(2);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapJoiningNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ int nodeToMove = 4;
+ metadata = startJoining(nodeToMove, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(nodeToMove);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerIds()).doesNotContain(new NodeId(nodeToMove));
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapReplacingNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ NodeId nodeToReplaceId = new NodeId(3);
+ NodeId newNodeId = new NodeId(4);
+ metadata = startReplacing(nodeToReplaceId, newNodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ String newNodeAddr = metadata.directory.getNodeAddresses(newNodeId).nativeAddress.getHostAddressAndPort();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeAddr,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerIds()).doesNotContain(newNodeId);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapNodeGettingReplaced() throws IOException
+ {
+ // Assuming that operator unintentionally invokes abortbootstrap on node getting replaced
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ NodeId nodeToReplaceId = new NodeId(3);
+ NodeId newNodeId = new NodeId(4);
+ metadata = startReplacing(nodeToReplaceId, newNodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ String nodeGettingReplaced = metadata.directory.getNodeAddresses(nodeToReplaceId).nativeAddress.getHostAddressAndPort();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeGettingReplaced,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Did not find any sequences in progress for node " + nodeGettingReplaced);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapWhenLeavingSequenceIsInProgress() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startLeaving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("abortbootstrap is not a valid operation when sequence of kind LEAVE");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortbootstrapJoinedNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ int nodeId = 4;
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortbootstrap",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortMoveNoInProgressSequence() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ int nodeId = 4;
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortmove",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("No transformation sequence is in progress for");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortMoveNonMovingSequence() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startLeaving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortmove",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Multi step operation of kind")
+ .contains("LEAVE")
+ .contains("Cannot proceed with abort move");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortMoveMovingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startMoving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortmove",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.inProgressSequences.contains(nodeId)).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortDecommissionNoInProgressSequence() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ int nodeId = 4;
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortdecommission",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("No transformation sequence is in progress for");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortDecommissionNonLeavingSequence() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startMoving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortdecommission",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Multi step operation of kind")
+ .contains("MOVE")
+ .contains("Cannot proceed with abort decommission");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAbortDecommissionLeavingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startLeaving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "abortdecommission",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.inProgressSequences.contains(nodeId)).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateCMSMember() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ // Verify that we have CMS members
+ assertThat(metadata.fullCMSMembers()).isNotEmpty();
+
+ // Pick the first CMS member to assassinate
+ NodeId cmsNodeId = metadata.directory.peerIds().stream().findFirst().orElseThrow();
+ assertThat(metadata.isCMSMember(metadata.directory.endpoint(cmsNodeId))).isTrue();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(cmsNodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(cmsNodeId)).isEqualTo(NodeState.LEFT);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateNodeNonCMSMember() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ // Verify that we have CMS members
+ assertThat(metadata.fullCMSMembers()).isNotEmpty();
+
+ // Select non-CMS node
+ NodeId nonCMSNodeId = metadata.directory.peerIds().stream()
+ .filter(id -> !metadata.isCMSMember(metadata.directory.endpoint(id)))
+ .findFirst().orElseThrow();
+ assertThat(metadata.isCMSMember(metadata.directory.endpoint(nonCMSNodeId))).isFalse();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nonCMSNodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(nonCMSNodeId)).isEqualTo(NodeState.LEFT);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateNodeInvalidNodeId() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(-1);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("No node present with id " + nodeId +
+ " in the given cluster metadata");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateMovingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ int nodeToMove = 4;
+ metadata = startMoving(new NodeId(nodeToMove), metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(nodeToMove);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+ assertThat(result.getStdout()).contains("Cancelling in-progress sequence");
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(new NodeId(nodeToMove))).isEqualTo(NodeState.LEFT);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateJoiningNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ int nodeToMove = 4;
+ metadata = startJoining(nodeToMove, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = String.valueOf(nodeToMove);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ // It should suggest to use abortbootstrap for node in joining state
+ assertThat(result.getStderr()).contains("abortbootstrap");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateNodeGettingReplaced() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeToReplaceId = new NodeId(4);
+ NodeId newNodeId = new NodeId(5);
+ metadata = startReplacing(nodeToReplaceId, newNodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ String nodeGettingReplaced = metadata.directory.getNodeAddresses(nodeToReplaceId)
+ .nativeAddress.getHostAddressAndPort();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeGettingReplaced,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("INVALID: Rejecting this plan as it interacts with a range locked");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testAssassinateLeavingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startLeaving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String ip = metadata.directory.endpoint(nodeId).getHostAddressAndPort();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "assassinate",
+ "-f",
+ metadataFile,
+ "-ip",
+ ip,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+ assertThat(result.getStdout()).contains("Cancelling in-progress sequence");
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(nodeId)).isEqualTo(NodeState.LEFT);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testDescribe() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "describe",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+
+ int storagePort = getStoragePort();
+
+ String expectedOutput =
+ "Cluster Metadata Service:\n" +
+ "Members: /127.0.0.1:" + storagePort + ",/127.0.0.2:" + storagePort + ",/127.0.0.3:" + storagePort + '\n' +
+ "Needs reconfiguration: false\n" +
+ "Service State: LOCAL\n" +
+ "Epoch: 2\n" +
+ "Replication factor: ReplicationParams{class=org.apache.cassandra.locator.MetaStrategy, datacenter1=3}\n";
+ assertThat(result.getStdout()).isEqualTo(expectedOutput);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testDescribeRawString() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "print",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStdout()).isNotEmpty().startsWith("ClusterMetadata{");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMSUsingNodeId() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out" + new Random().nextLong() + ".dump";
+ NodeId nodeId = new NodeId(1);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStderr()).isEmpty();
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ InetAddressAndPort nodeAddress = metadata.directory.getNodeAddresses(nodeId).broadcastAddress;
+ assertThat(outMetadata.isCMSMember(nodeAddress)).isTrue();
+ assertThat(outMetadata.fullCMSMembers().size()).isEqualTo(1);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMS() throws IOException
+ {
+ assertResetCMS("127.0.0.1:" + getStoragePort());
+ }
+
+ @Test
+ public void testResetCMSUsingIp() throws IOException
+ {
+ assertResetCMS("127.0.0.1");
+ }
+
+ private void assertResetCMS(String ipAddress) throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out" + new Random().nextLong() + ".dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-ip",
+ ipAddress,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStderr()).isEmpty();
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+
+ // Check given ip address is added to CMS members
+ InetAddressAndPort candidate = InetAddressAndPort.getByNameUnchecked(ipAddress);
+ assertThat(outMetadata.isCMSMember(candidate)).isTrue();
+ assertThat(outMetadata.fullCMSMembers().size()).isEqualTo(1);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMSUsingIpAddressThatDoesNotExist() throws IOException
+ {
+ // Node that doesn't exist in Cluster Metadata can be added as CMS member
+ String ipAddress = "127.0.0.55:" + getStoragePort();
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-ip",
+ ipAddress,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isNotEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("No node present").contains(ipAddress);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMSInvalidIpAddress() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidIpAddress = "/127.0.0.";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-ip",
+ invalidIpAddress,
+ "-o",
+ outputFile);
+
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).contains("java.net.UnknownHostException");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testResetCMSInvalidSerializationVersion() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidIpAddress = "127.0.0.3";
+ String serializationVersion = "-1";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "resetcms",
+ "-f",
+ metadataFile,
+ "-sv",
+ serializationVersion,
+ "-ip",
+ invalidIpAddress,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Invalid value for option '--serialization-version'");
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveToken() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata(16);
+ String nodeToMove = "127.0.0.4:" + getStoragePort();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeToMove,
+ "-t",
+ String.valueOf(metadata.partitioner.getRandomToken()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("This node has more than one token and cannot be moved thusly.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveTokenVnodes() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata(4);
+ metadata = addNewNode(metadata, 4, Set.of(metadata.partitioner.getRandomToken()));
+ String nodeToMove = "127.0.0.4:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(nodeToMove);
+ NodeId nodeId = metadata.directory.peerId(InetAddressAndPort.getByName(nodeToMove));
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token desiredToken = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeToMove,
+ "-t",
+ String.valueOf(desiredToken.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+ assertThat(outMetadata.directory.peerId(newNodeInetAddress)).isNotNull();
+ assertThat(outMetadata.tokenMap.tokens(nodeId)).contains(desiredToken);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveTokenWithoutToken() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ String nodeToMove = "127.0.0.4:" + getStoragePort();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ nodeToMove,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Token required").contains("MOVE");
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveTokenForNonExistingNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String unknownNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token tokenToAssign = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ unknownNodeIpWithPort,
+ "-t",
+ String.valueOf(tokenToAssign.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).contains("No node present with ip address")
+ .contains(unknownNodeIpWithPort);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveInvalidIpAddress() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String invalidIp = "127.0.0.";
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ Token tokenToAssign = metadata.partitioner.getRandomToken();
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ invalidIp,
+ "-t",
+ String.valueOf(tokenToAssign.getTokenValue()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).contains("java.net.UnknownHostException");
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveNodeToInvalidToken() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0.1:" + getStoragePort();
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidToken = "somegibberishinvalidtoken";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ invalidToken,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveNodeToSomeOtherNodeToken() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0.1:" + getStoragePort();
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ImmutableList<Token> secondNodeTokenList = metadata.tokenMap.tokens(new NodeId(2));
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ secondNodeTokenList.get(0).toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveNodeWhenMoveInProgressToAnotherToken() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ NodeId nodeId = new NodeId(2);
+ Token randomToken = metadata.partitioner.getRandomToken();
+ Set<Token> toTokenSet = Set.of(randomToken);
+ metadata = startMoving(nodeId, metadata, toTokenSet);
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0." + nodeId.id() + ':' + getStoragePort();
+
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ randomToken.toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.tokenMap.tokens(nodeId)).isEqualTo(ImmutableList.of(randomToken));
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testMoveNodeFinishInProgress() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ NodeId nodeId = new NodeId(2);
+ metadata = startMoving(nodeId, metadata);
+ String metadataFile = dumpMetadata(metadata);
+ String newNodeIpWithPort = "127.0.0." + nodeId.id() + ':' + getStoragePort();
+
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "move",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Move in progress for another token(s)");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoin() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ assertThat(metadata.directory.states.get(newNodeId)).isEqualTo(NodeState.REGISTERED);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata).isNotNull();
+ assertThat(outMetadata.directory.peerId(newNodeInetAddress)).isNotNull();
+ assertThat(outMetadata.directory.states.get(newNodeId)).isEqualTo(NodeState.JOINED);
+ assertThat(outMetadata.tokenMap.tokens(newNodeId)).isNotEmpty();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinNewNodeWithoutTokens() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ assertThat(metadata.directory.states.get(newNodeId)).isEqualTo(NodeState.REGISTERED);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Tokens must be provided to force join a node.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinUnknownNode() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ // New node is not registered, it should fail
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-ip",
+ newNodeIpWithPort,
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isNotEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains(newNodeIpWithPort)
+ .contains("No node present with ip address");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinMovingNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ metadata = startMoving(nodeId, metadata);
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.MOVING);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Another sequence of kind MOVE is in progress for node " + nodeId.id() +
+ ". Cannot proceed with force join.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinAlreadyJoinedNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ NodeId nodeId = new NodeId(4);
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.JOINED);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Node " + nodeId.id() + " is already in JOINED state.");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinLeftNode() throws IOException
+ {
+ ClusterMetadata metadata = getFourNodeMetadata();
+ int id = 4;
+ NodeId nodeId = new NodeId(id);
+ metadata = startLeaving(nodeId, metadata);
+ UnbootstrapAndLeave unbootstrapAndLeave = (UnbootstrapAndLeave) metadata.inProgressSequences.get(nodeId);
+ metadata = unbootstrapAndLeave.applyTo(metadata).success().metadata;
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.LEFT);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(nodeId)).isEqualTo(NodeState.JOINED);
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinJoiningNodeTokenMismatch() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ int nodeNum = 4;
+ metadata = startJoining(nodeNum, metadata);
+ NodeId nodeId = new NodeId(nodeNum);
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.BOOTSTRAPPING);
+
+ BootstrapAndJoin bootstrapAndJoin = (BootstrapAndJoin) metadata.inProgressSequences.get(nodeId);
+ Set<Token> sequenceTokens = bootstrapAndJoin.finishJoin.tokens;
+
+ // Pick a token that is different from the in-progress sequence tokens
+ Token differentToken = metadata.partitioner.getRandomToken();
+ while (sequenceTokens.contains(differentToken))
+ differentToken = metadata.partitioner.getRandomToken();
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ String.valueOf(nodeId.id()),
+ "-t",
+ differentToken.toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("do not match with")
+ .contains("in progress BootstrapAndJoin sequence tokens");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinJoiningNodeTokenMatch() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ int nodeNum = 4;
+ metadata = startJoining(nodeNum, metadata);
+ NodeId nodeId = new NodeId(nodeNum);
+ assertThat(metadata.directory.peerState(nodeId)).isEqualTo(NodeState.BOOTSTRAPPING);
+
+ BootstrapAndJoin bootstrapAndJoin = (BootstrapAndJoin) metadata.inProgressSequences.get(nodeId);
+ Set<Token> sequenceTokens = bootstrapAndJoin.finishJoin.tokens;
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ // Build args: one -t per token in the sequence
+ List<String> args = new ArrayList<>(List.of("forcejoin", "-f", metadataFile,
+ "-id", String.valueOf(nodeId.id()),
+ "-o", outputFile));
+ for (Token t : sequenceTokens)
+ {
+ args.add("-t");
+ args.add(t.toString());
+ }
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ args.toArray(new String[0]));
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(nodeId)).isEqualTo(NodeState.JOINED);
+ assertThat(outMetadata.tokenMap.tokens(nodeId)).containsAll(sequenceTokens);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinNodeThatDoesNotExist() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String nodeId = "-1";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-id",
+ nodeId,
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).isNotEmpty();
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinWithSerializationVersion() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ assertThat(metadata.directory.states.get(newNodeId)).isEqualTo(NodeState.REGISTERED);
+
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+
+ // Use V7 — a known valid version older than the current (V8)
+ Version targetVersion = Version.V7;
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-sv",
+ targetVersion.toString(),
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(Files.exists(Paths.get(outputFile))).isTrue();
+
+ // Verify that the output file was serialized using the requested version
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(outputFile))
+ {
+ int versionInt = fisp.readUnsignedVInt32();
+ assertThat(Version.fromInt(versionInt)).isEqualTo(targetVersion);
+ }
+
+ // Verify the node was force-joined in the output metadata
+ ClusterMetadata outMetadata = deserializeMetadata(outputFile);
+ assertThat(outMetadata.directory.peerState(newNodeId)).isEqualTo(NodeState.JOINED);
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testForceJoinInvalidSerializationVersion() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String newNodeIpWithPort = "127.0.0.5:" + getStoragePort();
+ InetAddressAndPort newNodeInetAddress = InetAddressAndPort.getByNameUnchecked(newNodeIpWithPort);
+ Directory newDirectory = metadata.directory.with(new NodeAddresses(newNodeInetAddress),
+ new Location("datacenter1", "rack4"),
+ NodeVersion.CURRENT);
+ metadata = updateMetadata(metadata, newDirectory);
+
+ NodeId newNodeId = metadata.directory.peerId(newNodeInetAddress);
+ String metadataFile = dumpMetadata(metadata);
+ String outputFile = temporaryFolder.getRoot() + "/metadata-out.dump";
+ String invalidSerializationVersion = "-1";
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "forcejoin",
+ "-f",
+ metadataFile,
+ "-sv",
+ invalidSerializationVersion,
+ "-id",
+ String.valueOf(newNodeId.id()),
+ "-t",
+ metadata.partitioner.getRandomToken().toString(),
+ "-o",
+ outputFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStdout()).isEqualTo(2);
+ assertThat(Files.exists(Paths.get(outputFile))).isFalse();
+ assertThat(result.getStderr()).contains("Invalid value for option '--serialization-version'");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testPrintDataPlacements() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String keyspaceName = "ks1";
+ KeyspaceParams ksParams = KeyspaceParams.create(true,
+ Map.of("class", "NetworkTopologyStrategy",
+ "datacenter1", "3"));
+ Keyspaces keyspaces = Keyspaces.none().with(KeyspaceMetadata.create(keyspaceName, ksParams));
+ DistributedSchema newSchema = new DistributedSchema(keyspaces);
+ metadata = updateMetadata(metadata, newSchema);
+
+ InetAddressAndPort inetAddressAndPort = InetAddressAndPort.getByNameUnchecked("127.0.0.1:" + getStoragePort());
+ InetAddressAndPort i2 = InetAddressAndPort.getByNameUnchecked("127.0.0.2:" + getStoragePort());
+ InetAddressAndPort i3 = InetAddressAndPort.getByNameUnchecked("127.0.0.3:" + getStoragePort());
+
+ Range<Token> tokenRange = new Range<>(metadata.partitioner.getMinimumToken(), metadata.partitioner.getTokenFactory().fromString("0"));
+
+ DataPlacement dataPlacement = DataPlacement.builder()
+ .withWriteReplica(Epoch.FIRST, Replica.fullReplica(inetAddressAndPort, tokenRange))
+ .withWriteReplica(Epoch.FIRST, Replica.fullReplica(i2, tokenRange))
+ .withWriteReplica(Epoch.FIRST, Replica.fullReplica(i3, tokenRange))
+ .withReadReplica(Epoch.FIRST, Replica.fullReplica(inetAddressAndPort, tokenRange))
+ .withReadReplica(Epoch.FIRST, Replica.fullReplica(i2, tokenRange))
+ .withReadReplica(Epoch.FIRST, Replica.fullReplica(i3, tokenRange))
+ .build();
+
+ DataPlacements dataPlacements = DataPlacements.builder(1)
+ .with(ReplicationParams.fromMap(Map.of("class", "NetworkTopologyStrategy",
+ "datacenter1", "3")),
+ dataPlacement)
+ .build();
+ metadata = updateMetadata(metadata, dataPlacements);
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdataplacements",
+ "-f",
+ metadataFile,
+ "-ks",
+ keyspaceName);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStdout()).isNotEmpty();
+ assertThat(result.getStderr()).isNullOrEmpty();
+
+ String stdout = result.getStdout();
+ assertThat(stdout).isEqualTo(
+ "Token Range Type Endpoints\n" +
+ "(-9223372036854775808,0] read /127.0.0.1:" + getStoragePort() + ", /127.0.0.2:" + getStoragePort() + ", /127.0.0.3:" + getStoragePort() + '\n' +
+ "(-9223372036854775808,0] write /127.0.0.1:" + getStoragePort() + ", /127.0.0.2:" + getStoragePort() + ", /127.0.0.3:" + getStoragePort() + '\n');
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testPrintDataPlacementsKeyspaceDoesNotExist() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String keyspaceName = "ks1";
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdataplacements",
+ "-f",
+ metadataFile,
+ "-ks",
+ keyspaceName);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(2);
+ assertThat(result.getStderr()).isNotEmpty();
+ assertThat(result.getStderr()).contains("Keyspace " + keyspaceName + " not found in cluster metadata");
+
+ assertCorrectEnvPostTest();
+ }
+
+ @Test
+ public void testPrintDirectory() throws IOException
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ String metadataFile = dumpMetadata(metadata);
+
+ ToolRunner.ToolResult result = ToolRunner.invokeClass(CMSOfflineTool.class,
+ "printdirectory",
+ "-f",
+ metadataFile);
+
+ assertThat(result.getExitCode()).withFailMessage(result.getStderr()).isEqualTo(0);
+ assertThat(result.getStdout()).isNotEmpty();
+ assertThat(result.getStderr()).isNullOrEmpty();
+
+ String stdout = result.getStdout();
+
+ String expectedSerializationVersion = NodeVersion.CURRENT.serializationVersion().toString();
+ assertThat(stdout).isEqualTo(
+ "NodeId: 1\n" +
+ " rack rack1\n" +
+ " local_port " + getStoragePort() + '\n' +
+ " broadcast_port " + getStoragePort() + '\n' +
+ " host_id 6d194555-f6eb-41d0-c000-000000000001\n" +
+ " broadcast_address /127.0.0.1\n" +
+ " native_address /127.0.0.1\n" +
+ " native_port " + getStoragePort() + '\n' +
+ " local_address /127.0.0.1\n" +
+ " state JOINED\n" +
+ " serialization_version " + expectedSerializationVersion + '\n' +
+ " cassandra_version " + metadata.directory.version(new NodeId(1)).cassandraVersion + '\n' +
+ " dc datacenter1\n" +
+ " is_cms_member true\n" +
+ "NodeId: 2\n" +
+ " rack rack2\n" +
+ " local_port " + getStoragePort() + '\n' +
+ " broadcast_port " + getStoragePort() + '\n' +
+ " host_id 6d194555-f6eb-41d0-c000-000000000002\n" +
+ " broadcast_address /127.0.0.2\n" +
+ " native_address /127.0.0.2\n" +
+ " native_port " + getStoragePort() + '\n' +
+ " local_address /127.0.0.2\n" +
+ " state JOINED\n" +
+ " serialization_version " + expectedSerializationVersion + '\n' +
+ " cassandra_version " + metadata.directory.version(new NodeId(2)).cassandraVersion + '\n' +
+ " dc datacenter1\n" +
+ " is_cms_member true\n" +
+ "NodeId: 3\n" +
+ " rack rack3\n" +
+ " local_port " + getStoragePort() + '\n' +
+ " broadcast_port " + getStoragePort() + '\n' +
+ " host_id 6d194555-f6eb-41d0-c000-000000000003\n" +
+ " broadcast_address /127.0.0.3\n" +
+ " native_address /127.0.0.3\n" +
+ " native_port " + getStoragePort() + '\n' +
+ " local_address /127.0.0.3\n" +
+ " state JOINED\n" +
+ " serialization_version " + expectedSerializationVersion + '\n' +
+ " cassandra_version " + metadata.directory.version(new NodeId(3)).cassandraVersion + '\n' +
+ " dc datacenter1\n" +
+ " is_cms_member true\n"
+ );
+
+ assertCorrectEnvPostTest();
+ }
+
+ private String dumpMetadata(ClusterMetadata metadata) throws IOException
+ {
+ String tempFile = temporaryFolder.newFile().getAbsolutePath();
+ try (FileOutputStreamPlus out = new FileOutputStreamPlus(tempFile))
+ {
+ VerboseMetadataSerializer.serialize(ClusterMetadata.serializer,
+ metadata,
+ out,
+ NodeVersion.CURRENT.serializationVersion());
+ }
+ return tempFile;
+ }
+
+ /**
+ * Creates a three-node cluster metadata for testing.
+ */
+
+ private ClusterMetadata getThreeNodeClusterMetadata()
+ {
+ return getThreeNodeClusterMetadata(1);
+ }
+
+ private ClusterMetadata getThreeNodeClusterMetadata(int tokenSize)
+ {
+ IPartitioner partitioner = Murmur3Partitioner.instance;
+ NodeId nodeId1 = new NodeId(1);
+ NodeId nodeId2 = new NodeId(2);
+ NodeId nodeId3 = new NodeId(3);
+
+ InetAddressAndPort addr1 = InetAddressAndPort.getByNameUnchecked("127.0.0.1:" + getStoragePort());
+ InetAddressAndPort addr2 = InetAddressAndPort.getByNameUnchecked("127.0.0.2:" + getStoragePort());
+ InetAddressAndPort addr3 = InetAddressAndPort.getByNameUnchecked("127.0.0.3:" + getStoragePort());
+
+ NodeVersion nodeVersion = NodeVersion.CURRENT;
+
+ Directory directory =
+ new Directory()
+ .unsafeWithNodeForTesting(nodeId1, new NodeAddresses(addr1), new Location(DC, "rack1"), nodeVersion)
+ .unsafeWithNodeForTesting(nodeId2, new NodeAddresses(addr2), new Location(DC, "rack2"), nodeVersion)
+ .unsafeWithNodeForTesting(nodeId3, new NodeAddresses(addr3), new Location(DC, "rack3"), nodeVersion)
+ .withRackAndDC(nodeId1)
+ .withRackAndDC(nodeId2)
+ .withRackAndDC(nodeId3);
+
+ KeyspaceParams metaKsParams = KeyspaceParams.create(true,
+ Map.of("class", "MetaStrategy", "datacenter1", "3"));
+ KeyspaceMetadata metaKeyspace = KeyspaceMetadata.create(SchemaConstants.METADATA_KEYSPACE_NAME, metaKsParams);
+ KeyspaceMetadata normalKeyspace = KeyspaceMetadata.create("ks", KeyspaceParams.simple(3));
+ Keyspaces keyspaces = Keyspaces.none().with(metaKeyspace).with(normalKeyspace);
+
+ ClusterMetadata clusterMetadata = getClusterMetadata(keyspaces, partitioner, directory);
+
+
+ ClusterMetadata metadata = clusterMetadata
+ .transformer()
+ .with(directory)
+ .join(nodeId1)
+ .proposeToken(nodeId1, getRandomTokens(partitioner, tokenSize))
+ .join(nodeId2)
+ .proposeToken(nodeId2, getRandomTokens(partitioner, tokenSize))
+ .join(nodeId3)
+ .proposeToken(nodeId3, getRandomTokens(partitioner, tokenSize))
+ .build().metadata;
+
+ // Create replicas for the metadata keyspace on all three nodes
+ ReplicationParams metaParams = ReplicationParams.ntsMeta(Collections.singletonMap(DC, 3));
+ DataPlacements placements = DataPlacements.empty().unbuild()
+ .with(metaParams, getCMSMemberPlacement(metadata, List.of(addr1, addr2, addr3)))
+ .with(ReplicationParams.simple(3), getKeyspacePlacement(metadata, normalKeyspace))
+ .build();
+
+ return updateMetadata(metadata, placements);
+ }
+
+ List<Token> getRandomTokens(IPartitioner partitioner, int size)
+ {
+ List<Token> tokens = new ArrayList<>(size);
+ for (int i = 0; i < size; i++)
+ {
+ tokens.add(partitioner.getRandomToken());
+ }
+
+ return tokens;
+ }
+
+ DataPlacement getCMSMemberPlacement(ClusterMetadata clusterMetadata, List<InetAddressAndPort> inetAddressAndPorts)
+ {
+ IPartitioner partitioner = clusterMetadata.partitioner;
+ // Create replicas for the metadata keyspace on all three nodes
+ Range<Token> fullRange = new Range<>(partitioner.getMinimumToken(), partitioner.getMinimumToken());
+ DataPlacement.Builder placementBuilder = DataPlacement.builder();
+ for (InetAddressAndPort addr : inetAddressAndPorts)
+ {
+ Replica replica = Replica.fullReplica(addr, fullRange);
+ placementBuilder.withReadReplica(Epoch.EMPTY, replica)
+ .withWriteReplica(Epoch.EMPTY, replica);
+ }
+
+ return placementBuilder.build();
+ }
+
+ DataPlacement getKeyspacePlacement(ClusterMetadata metadata, KeyspaceMetadata keyspaceMetadata)
+ {
+ UniformRangePlacement uniformRangePlacement = new UniformRangePlacement();
+ List<Range<Token>> tokenRanges = uniformRangePlacement.calculateRanges(metadata.tokenMap);
+
+ return keyspaceMetadata.replicationStrategy.calculateDataPlacement(metadata.epoch, tokenRanges, metadata);
+ }
+
+ ClusterMetadata getFourNodeMetadata()
+ {
+ return getFourNodeMetadata(1);
+ }
+
+ ClusterMetadata getFourNodeMetadata(int tokenSize)
+ {
+ ClusterMetadata metadata = getThreeNodeClusterMetadata();
+ return addNewNode(metadata, 4, new HashSet<>(getRandomTokens(metadata.partitioner, tokenSize)));
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ ClusterMetadata addNewNode(ClusterMetadata prev, int newNodeId, Set<Token> tokens)
+ {
+ NodeId nodeId = new NodeId(newNodeId);
+ Directory directory = prev.directory
+ .unsafeWithNodeForTesting(nodeId, getNodeAddresses(newNodeId),
+ new Location(DC, "rack" + newNodeId),
+ NodeVersion.CURRENT);
Review Comment:
Thanks for catching this! I was mostly concentrating on whether the assassinated node is in LEFT state or not. Added few assertions to verify the CMS members.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org
Re: [PR] CASSANDRA-19151 Adding offline tool for cluster metadata [cassandra]
Posted by "nvharikrishna (via GitHub)" <gi...@apache.org>.
nvharikrishna commented on code in PR #4149:
URL: https://github.com/apache/cassandra/pull/4149#discussion_r3083889923
##########
src/java/org/apache/cassandra/tools/CMSOfflineTool.java:
##########
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.tools;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileInputStreamPlus;
+import org.apache.cassandra.io.util.FileOutputStreamPlus;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.MetaStrategy;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.schema.KeyspaceMetadata;
+import org.apache.cassandra.schema.ReplicationParams;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.tcm.ClusterMetadataService;
+import org.apache.cassandra.tcm.MultiStepOperation;
+import org.apache.cassandra.tcm.membership.Directory;
+import org.apache.cassandra.tcm.membership.Location;
+import org.apache.cassandra.tcm.membership.NodeAddresses;
+import org.apache.cassandra.tcm.membership.NodeId;
+import org.apache.cassandra.tcm.membership.NodeState;
+import org.apache.cassandra.tcm.membership.NodeVersion;
+import org.apache.cassandra.tcm.ownership.DataPlacement;
+import org.apache.cassandra.tcm.ownership.ReplicaGroups;
+import org.apache.cassandra.tcm.ownership.UniformRangePlacement;
+import org.apache.cassandra.tcm.sequences.BootstrapAndJoin;
+import org.apache.cassandra.tcm.sequences.Move;
+import org.apache.cassandra.tcm.sequences.ReconfigureCMS;
+import org.apache.cassandra.tcm.serialization.VerboseMetadataSerializer;
+import org.apache.cassandra.tcm.serialization.Version;
+import org.apache.cassandra.tcm.transformations.Assassinate;
+import org.apache.cassandra.tcm.transformations.CancelInProgressSequence;
+import org.apache.cassandra.tcm.transformations.PrepareMove;
+import org.apache.cassandra.tcm.transformations.Unregister;
+import org.apache.cassandra.tcm.transformations.UnsafeJoin;
+import org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration;
+import org.apache.cassandra.utils.FBUtilities;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+import static com.google.common.base.Throwables.getStackTraceAsString;
+import static org.apache.cassandra.tcm.transformations.cms.PrepareCMSReconfiguration.needsReconfiguration;
+
+/**
+ * Offline tool to print or update cluster metadata stored in a dump file.
+ * <p>
+ * The tool operates entirely offline: it reads a metadata dump file produced by
+ * {@code nodetool cms dump} (or equivalent), applies the requested transformation,
+ * and writes the result to a new file. The original dump file is never modified.
+ * <p>
+ * Run without a subcommand to print usage information.
+ */
+@SuppressWarnings({ "unused", "DefaultAnnotationParam", "UseOfSystemOutOrSystemErr" })
+@Command(name = "cmsofflinetool",
+mixinStandardHelpOptions = true,
+description = "Offline tool to print or update cluster metadata dump.",
+subcommands = { CMSOfflineTool.AbortBootstrap.class,
+ CMSOfflineTool.AbortDecommission.class,
+ CMSOfflineTool.AbortMove.class,
+ CMSOfflineTool.AssassinateNode.class,
+ CMSOfflineTool.Describe.class,
+ CMSOfflineTool.ForceJoin.class,
+ CommandLine.HelpCommand.class,
+ CMSOfflineTool.MoveToken.class,
+ CMSOfflineTool.Print.class,
+ CMSOfflineTool.PrintDataPlacements.class,
+ CMSOfflineTool.PrintDirectoryCmd.class,
+ CMSOfflineTool.ResetCMS.class })
+public class CMSOfflineTool implements Runnable
+{
+ private final Output output;
+
+ public CMSOfflineTool(Output output)
+ {
+ this.output = output;
+ }
+
+ public static void main(String[] args) throws IOException
+ {
+ CMSOfflineTool tool = new CMSOfflineTool(new Output(System.out, System.err));
+ CommandLine cli = new CommandLine(tool)
+ .setColorScheme(CommandLine.Help.defaultColorScheme(CommandLine.Help.Ansi.OFF))
+ .setExecutionExceptionHandler((ex, cmd, parseResult) -> {
+ cmd.getErr().println("Error: " + ex.getMessage());
+ cmd.getErr().println("-- StackTrace --");
+ cmd.getErr().println(getStackTraceAsString(ex));
+ return 2;
+ });
+ int status = cli.execute(args);
+ System.exit(status);
+ }
+
+ @Override
+ public void run()
+ {
+ CommandLine.usage(this, output.out, CommandLine.Help.Ansi.OFF);
+ }
+
+ public static abstract class ClusterMetadataToolCmd implements Runnable
+ {
+ @Option(names = { "-f", "--file" }, description = "Cluster metadata dump file path.", required = true)
+ protected String metadataDumpFile;
+
+ @Option(names = { "-sv", "--serialization-version" }, description = "Serialization version to use.")
+ private Version serializationVersion;
+
+ @CommandLine.ParentCommand
+ private CMSOfflineTool parent;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ execute(parent.output);
+ parent.output.out.flush();
+ parent.output.err.flush();
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected abstract void execute(Output output) throws IOException;
+
+ public ClusterMetadata parseClusterMetadata() throws IOException
+ {
+ File file = new File(metadataDumpFile);
+ if (!file.exists())
+ {
+ throw new IllegalArgumentException("Cluster metadata dump file " + metadataDumpFile + " does not exist.");
+ }
+
+ // Make sure the partitioner we use to manipulate the metadata is the same one used to generate it
+ IPartitioner partitioner;
+ try (FileInputStreamPlus fisp = new FileInputStreamPlus(metadataDumpFile))
+ {
+ int x = fisp.readUnsignedVInt32();
+ Version version = Version.fromInt(x);
+ partitioner = ClusterMetadata.Serializer.getPartitioner(fisp, version);
+ }
+ DatabaseDescriptor.toolInitialization();
+ DatabaseDescriptor.setPartitionerUnsafe(partitioner);
+ ClusterMetadataService.initializeForTools(false);
+
+ return ClusterMetadataService.deserializeClusterMetadata(metadataDumpFile);
+ }
+
+ public void writeMetadata(Output output, ClusterMetadata metadata, String outputFilePath) throws IOException
+ {
+ Path p = outputFilePath != null ?
+ Files.createFile(Path.of(outputFilePath)) :
+ Files.createTempFile("clustermetadata", "dump");
Review Comment:
I think so. I was following: https://github.com/apache/cassandra/blob/trunk/src/java/org/apache/cassandra/tcm/ClusterMetadataService.java#L526. Can change it in the ClusterMetadataService as well if you are okay with it.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: pr-unsubscribe@cassandra.apache.org
For additional commands, e-mail: pr-help@cassandra.apache.org