This is an automated email from the ASF dual-hosted git repository.

sarankk pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/cassandra-sidecar.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 37735805c CASSSIDECAR-503: Fix IPV6 regex to recognize bracketed 
addresses in gossip info host headers (#388)
37735805c is described below

commit 37735805cf4250f31309864ae69a92cbdfc84883
Author: Saranya Krishnakumar <[email protected]>
AuthorDate: Thu Oct 1 16:04:42 2026 -0700

    CASSSIDECAR-503: Fix IPV6 regex to recognize bracketed addresses in gossip 
info host headers (#388)
    
    Patch by Saranya Krishnakumar; Reviewed by Francisco Guerrero, Yifan Cai 
for CASSSIDECAR-503
---
 CHANGES.txt                                        |   1 +
 .../common/server/utils/GossipInfoParser.java      |  13 +-
 .../common/server/utils/GossipInfoParserTest.java  |  53 +++++
 .../ClusterLeaseElectorateIPv6Test.java            | 225 +++++++++++++++++++++
 4 files changed, 289 insertions(+), 3 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index f3e2203f5..255a597d7 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,6 @@
 0.5.0
 -----
+ * Fix IPV6 regex to recognize bracketed addresses in gossip info host headers 
(CASSSIDECAR-503)
  * CDC batch-write mixing a CDC-enabled and CDC-disabled table drops the CDC 
table's mutation (CASSSIDECAR-499)
  * Disable Netty wire logging on the HTTP server (CASSSIDECAR-496)
  * Tighten path containment in Live Migration file route (CASSSIDECAR-479)
diff --git 
a/server-common/src/main/java/org/apache/cassandra/sidecar/common/server/utils/GossipInfoParser.java
 
b/server-common/src/main/java/org/apache/cassandra/sidecar/common/server/utils/GossipInfoParser.java
index a89b40be5..b26a906fb 100644
--- 
a/server-common/src/main/java/org/apache/cassandra/sidecar/common/server/utils/GossipInfoParser.java
+++ 
b/server-common/src/main/java/org/apache/cassandra/sidecar/common/server/utils/GossipInfoParser.java
@@ -36,10 +36,15 @@ public class GossipInfoParser
     {
     } // util, do not instantiate
 
-    private static final String IPV4_PATTERN = "(?:[0-9]{1,3}.){3}[0-9]{1,3}";
+    private static final String IPV4_PATTERN = 
"(?:[0-9]{1,3}\\.){3}[0-9]{1,3}";
     private static final String IPV6_PATTERN = 
"(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}";
+    // Cassandra wraps an IPv6 literal in square brackets before appending the 
port, so that the address and
+    // the port can be told apart, e.g. '/[2001:db8:0:0:0:0:0:1]:7000'. The 
brackets are absent when the
+    // gossip info is retrieved without ports. See 
org.apache.cassandra.locator.InetAddressAndPort#toString(InetAddress, int)
+    private static final String BRACKETED_IPV6_PATTERN = 
String.format("\\[%s\\]", IPV6_PATTERN);
     // Pattern text matching both IPv4 and IPv6
-    private static final String IP_PATTERN = String.format("((%s)|(%s))", 
IPV4_PATTERN, IPV6_PATTERN);
+    private static final String IP_PATTERN = String.format("((%s)|(%s)|(%s))",
+                                                           IPV4_PATTERN, 
BRACKETED_IPV6_PATTERN, IPV6_PATTERN);
     // Pattern text matching IP with port optionally present.
     // Technically, [0-9]{1,5} represents a larger range for valid ports. It 
is fine for parsing the gossip info output.
     private static final String IP_WITH_PORT_PATTERN = 
String.format("%s(:[0-9]{1,5})?", IP_PATTERN);
@@ -78,7 +83,9 @@ public class GossipInfoParser
             }
             else
             {
-                assert gossipInfo != null; // the host line appears before the 
rest. gossipInfo map must be initialized
+                Preconditions.checkState(gossipInfo != null,
+                                         "Expected a gossip host header before 
the gossip fields. " +
+                                         "Unrecognized line: %s", line);
                 final List<String> splitLine = 
GOSSIP_INFO_FIELD_SPLITTER.splitToList(line);
                 Preconditions.checkState(splitLine.size() == 2 || 
splitLine.size() == 3,
                                          "A gossip field should be split into 
two or three parts. %s",
diff --git 
a/server-common/src/test/java/org/apache/cassandra/sidecar/common/server/utils/GossipInfoParserTest.java
 
b/server-common/src/test/java/org/apache/cassandra/sidecar/common/server/utils/GossipInfoParserTest.java
index b9c53e6f5..c2a3a08e8 100644
--- 
a/server-common/src/test/java/org/apache/cassandra/sidecar/common/server/utils/GossipInfoParserTest.java
+++ 
b/server-common/src/test/java/org/apache/cassandra/sidecar/common/server/utils/GossipInfoParserTest.java
@@ -62,6 +62,36 @@ class GossipInfoParserTest
             .isFalse();
     }
 
+    @Test
+    void testIpv6GossipInfoHostHeaderCheck()
+    {
+        
assertThat(GossipInfoParser.isGossipInfoHostHeader("/[0:0:0:0:0:0:0:1]:7000"))
+        .as("should parse format /[IPV6]:PORT")
+        .isTrue();
+        
assertThat(GossipInfoParser.isGossipInfoHostHeader("optional_hostname/[fe80:0:0:0:0:0:0:1]:7000"))
+        .as("should parse format HOSTNAME/[IPV6]:PORT")
+        .isTrue();
+        assertThat(GossipInfoParser.isGossipInfoHostHeader("/0:0:0:0:0:0:0:1"))
+        .as("should parse format /IPV6")
+        .isTrue();
+    }
+
+    @Test
+    void testParseIpv6GossipInfo()
+    {
+        GossipInfoResponse result = 
GossipInfoParser.parse(SAMPLE_IPV6_GOSSIP_INFO);
+        assertThat(result).containsKey("/[0:0:0:0:0:0:0:1]:7000")
+                          .containsKey("localhost2/[0:0:0:0:0:0:0:2]:7000")
+                          .containsKey("/[fe80:0:0:0:0:0:0:1]:7000");
+        GossipInfoResponse.GossipInfo gossipInfo = 
result.get("[0:0:0:0:0:0:0:1]:7000");
+        assertThat(gossipInfo).isNotNull();
+        assertThat(gossipInfo.generation()).isEqualTo("1668100877");
+        assertThat(gossipInfo.heartbeat()).isEqualTo("242");
+        
assertThat(gossipInfo.statusWithPort()).isEqualTo("NORMAL,-9223372036854775808");
+        assertThat(result.get("[fe80:0:0:0:0:0:0:1]:7000").statusWithPort())
+        .isEqualTo("BOOT_REPLACE,-3074457345618258603");
+    }
+
     private static final String SAMPLE_GOSSIP_INFO =
         "/127.0.0.3\n" +
         "  generation:1668100877\n" +
@@ -85,4 +115,27 @@ class GossipInfoParserTest
         "  SSTABLE_VERSIONS:6:big-nb\n" +
         "  TOKENS:18:<hidden>\n" +
         "  INDEX_STATUS:2198:{\"ks.tbl_idx\":\"BUILD_SUCCEEDED\"}";
+
+    private static final String SAMPLE_IPV6_GOSSIP_INFO =
+        "/[fe80:0:0:0:0:0:0:1]:7000\n" +
+        "  generation:1668100877\n" +
+        "  heartbeat:248\n" +
+        "  LOAD:217:88883.0\n" +
+        "  STATUS_WITH_PORT:71:BOOT_REPLACE,-3074457345618258603\n" +
+        "  SSTABLE_VERSIONS:6:big-nb\n" +
+        "  TOKENS:70:<hidden>\n" +
+        "localhost2/[0:0:0:0:0:0:0:2]:7000\n" +
+        "  generation:1668100877\n" +
+        "  heartbeat:243\n" +
+        "  LOAD:211:83702.0\n" +
+        "  STATUS_WITH_PORT:19:NORMAL,-3074457345618258603\n" +
+        "  SSTABLE_VERSIONS:6:big-nb\n" +
+        "  TOKENS:18:<hidden>\n" +
+        "/[0:0:0:0:0:0:0:1]:7000\n" +
+        "  generation:1668100877\n" +
+        "  heartbeat:242\n" +
+        "  LOAD:211:88971.0\n" +
+        "  STATUS_WITH_PORT:19:NORMAL,-9223372036854775808\n" +
+        "  SSTABLE_VERSIONS:6:big-nb\n" +
+        "  TOKENS:18:<hidden>";
 }
diff --git 
a/server/src/test/java/org/apache/cassandra/sidecar/coordination/ClusterLeaseElectorateIPv6Test.java
 
b/server/src/test/java/org/apache/cassandra/sidecar/coordination/ClusterLeaseElectorateIPv6Test.java
new file mode 100644
index 000000000..ad1c6101f
--- /dev/null
+++ 
b/server/src/test/java/org/apache/cassandra/sidecar/coordination/ClusterLeaseElectorateIPv6Test.java
@@ -0,0 +1,225 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.coordination;
+
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.UnknownHostException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.apache.cassandra.sidecar.adapters.base.CassandraStorageOperations;
+import 
org.apache.cassandra.sidecar.adapters.base.jmx.ClusterMembershipJmxOperations;
+import 
org.apache.cassandra.sidecar.adapters.base.jmx.EndpointSnitchJmxOperations;
+import org.apache.cassandra.sidecar.adapters.base.jmx.StorageJmxOperations;
+import org.apache.cassandra.sidecar.cluster.CassandraAdapterDelegate;
+import org.apache.cassandra.sidecar.cluster.InstancesMetadata;
+import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata;
+import org.apache.cassandra.sidecar.common.response.NodeSettings;
+import org.apache.cassandra.sidecar.common.server.JmxClient;
+import org.apache.cassandra.sidecar.common.server.StorageOperations;
+import org.apache.cassandra.sidecar.common.server.dns.DnsResolvers;
+import org.apache.cassandra.sidecar.common.server.utils.GossipInfoParser;
+import org.apache.cassandra.sidecar.config.SidecarConfiguration;
+import org.apache.cassandra.sidecar.config.yaml.SidecarConfigurationImpl;
+import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher;
+
+import static 
org.apache.cassandra.sidecar.adapters.base.jmx.ClusterMembershipJmxOperations.FAILURE_DETECTOR_OBJ_NAME;
+import static 
org.apache.cassandra.sidecar.adapters.base.jmx.EndpointSnitchJmxOperations.ENDPOINT_SNITCH_INFO_OBJ_NAME;
+import static 
org.apache.cassandra.sidecar.adapters.base.jmx.StorageJmxOperations.STORAGE_SERVICE_OBJ_NAME;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for the cluster lease electorate for nodes with IPV6 address.
+ */
+class ClusterLeaseElectorateIPv6Test
+{
+    private static final String DATACENTER = "datacenter1";
+    private static final String PARTITIONER = 
"org.apache.cassandra.dht.Murmur3Partitioner";
+    private static final List<String> TOKEN_RANGE_OWNING_TOKEN_ZERO = 
Arrays.asList("-100", "100");
+
+    static Stream<Arguments> clusters() throws UnknownHostException
+    {
+        return Stream.of(Arguments.of(new Fixture("IPv4",
+                                                  new 
InetSocketAddress(InetAddress.getByName("127.0.0.1"), 7000),
+                                                  
Arrays.asList("127.0.0.1:7000",
+                                                                
"127.0.0.2:7000",
+                                                                
"127.0.0.3:7000"))),
+                         Arguments.of(new Fixture("IPv6",
+                                                  new 
InetSocketAddress(InetAddress.getByName("2001:db8::1"), 7000),
+                                                  
Arrays.asList("[2001:db8:0:0:0:0:0:1]:7000",
+                                                                
"[2001:db8:0:0:0:0:0:2]:7000",
+                                                                
"[2001:db8:0:0:0:0:0:3]:7000"))));
+    }
+
+    @ParameterizedTest(name = "{index} => {0}")
+    @MethodSource("clusters")
+    void testGossipHostHeaderIsRecognized(Fixture fixture)
+    {
+        String header = "/" + fixture.endpoints.get(0);
+        assertThat(GossipInfoParser.isGossipInfoHostHeader(header))
+        .as("'%s' is a host header emitted by 
InetAddressAndPort#toString(true)", header)
+        .isTrue();
+    }
+
+    @ParameterizedTest(name = "{index} => {0}")
+    @MethodSource("clusters")
+    void testIsMemberOfElectorateWhenOwningTokenZero(Fixture fixture)
+    {
+        SidecarConfiguration sidecarConfiguration = new 
SidecarConfigurationImpl();
+        ElectorateMembership membership = electorateMembership(fixture, 
sidecarConfiguration);
+
+        assertThat(membership.isMember())
+        .as("The local instance owns token zero of %s, so it is part of the 
electorate",
+            
sidecarConfiguration.serviceConfiguration().schemaKeyspaceConfiguration().keyspace())
+        .isTrue();
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+        "/[2001:db8::1]:7000",
+        "/2001:db8::1",
+        "/[2001:db8:0:0:0:0:0:1:7000",
+        "/[2001:db8:0:0:0:1]:7000",
+        "/[gggg:db8:0:0:0:0:0:1]:7000",
+    })
+    void testUnrecognizedIpv6HostHeaderIsRejected(String header)
+    {
+        assertThat(GossipInfoParser.isGossipInfoHostHeader(header))
+        .as("'%s' is not a host header Cassandra emits", header)
+        .isFalse();
+
+        String newLine = System.lineSeparator();
+        String rawGossipInfo = header + newLine + "  generation:1683666630" + 
newLine + "  heartbeat:275" + newLine;
+        assertThatThrownBy(() -> GossipInfoParser.parse(rawGossipInfo))
+        .as("Parsing must fail loudly instead of misreading the fields of an 
unrecognized host")
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessageContaining(header);
+    }
+
+    private ElectorateMembership electorateMembership(Fixture fixture, 
SidecarConfiguration sidecarConfiguration)
+    {
+        String keyspace = 
sidecarConfiguration.serviceConfiguration().schemaKeyspaceConfiguration().keyspace();
+
+        NodeSettings nodeSettings = mock(NodeSettings.class);
+        when(nodeSettings.partitioner()).thenReturn(PARTITIONER);
+
+        CassandraAdapterDelegate delegate = 
mock(CassandraAdapterDelegate.class);
+        
when(delegate.localStorageBroadcastAddress()).thenReturn(fixture.localBroadcastAddress);
+        when(delegate.nodeSettings()).thenReturn(nodeSettings);
+        StorageOperations storageOperations = new 
CassandraStorageOperations(jmxClient(fixture.endpoints, keyspace),
+                                                                            
DnsResolvers.RESOLVE_TO_IP);
+        when(delegate.storageOperations()).thenReturn(storageOperations);
+
+        InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
+        when(instanceMetadata.delegate()).thenReturn(delegate);
+        InstancesMetadata instancesMetadata = mock(InstancesMetadata.class);
+        
when(instancesMetadata.instances()).thenReturn(Collections.singletonList(instanceMetadata));
+
+        return new SidecarInternalTokenZeroElectorateMembership(new 
InstanceMetadataFetcher(instancesMetadata),
+                                                                
sidecarConfiguration);
+    }
+
+    private static JmxClient jmxClient(List<String> endpoints, String keyspace)
+    {
+        Map<List<String>, List<String>> rangeToEndpoints = new HashMap<>();
+        rangeToEndpoints.put(TOKEN_RANGE_OWNING_TOKEN_ZERO, endpoints);
+
+        StorageJmxOperations storage = mock(StorageJmxOperations.class);
+        when(storage.isGossipRunning()).thenReturn(true);
+        
when(storage.getRangeToEndpointWithPortMap(keyspace)).thenReturn(rangeToEndpoints);
+        
when(storage.getPendingRangeToEndpointWithPortMap(keyspace)).thenReturn(Collections.emptyMap());
+        when(storage.getLiveNodesWithPort()).thenReturn(endpoints);
+        
when(storage.getUnreachableNodesWithPort()).thenReturn(Collections.emptyList());
+        
when(storage.getJoiningNodesWithPort()).thenReturn(Collections.emptyList());
+        
when(storage.getLeavingNodesWithPort()).thenReturn(Collections.emptyList());
+        
when(storage.getMovingNodesWithPort()).thenReturn(Collections.emptyList());
+
+        EndpointSnitchJmxOperations endpointSnitch = 
mock(EndpointSnitchJmxOperations.class);
+        try
+        {
+            
when(endpointSnitch.getDatacenter(anyString())).thenReturn(DATACENTER);
+        }
+        catch (UnknownHostException e)
+        {
+            throw new AssertionError(e); // stubbing only, cannot be thrown
+        }
+
+        ClusterMembershipJmxOperations clusterMembership = 
mock(ClusterMembershipJmxOperations.class);
+        
when(clusterMembership.getAllEndpointStatesWithPort()).thenReturn(rawGossipInfo(endpoints));
+
+        JmxClient jmxClient = mock(JmxClient.class);
+        when(jmxClient.proxy(StorageJmxOperations.class, 
STORAGE_SERVICE_OBJ_NAME)).thenReturn(storage);
+        when(jmxClient.proxy(EndpointSnitchJmxOperations.class, 
ENDPOINT_SNITCH_INFO_OBJ_NAME)).thenReturn(endpointSnitch);
+        when(jmxClient.proxy(ClusterMembershipJmxOperations.class, 
FAILURE_DETECTOR_OBJ_NAME)).thenReturn(clusterMembership);
+        return jmxClient;
+    }
+
+    private static String rawGossipInfo(List<String> endpoints)
+    {
+        String newLine = System.lineSeparator();
+        StringBuilder sb = new StringBuilder();
+        for (String endpoint : endpoints)
+        {
+            sb.append('/').append(endpoint).append(newLine)
+              .append("  generation:1683666630").append(newLine)
+              .append("  heartbeat:275").append(newLine)
+              .append("  LOAD:210:88971.0").append(newLine)
+              .append("  DC:8:").append(DATACENTER).append(newLine)
+              .append("  RACK:10:rack1").append(newLine)
+              .append("  STATUS_WITH_PORT:16:NORMAL,-1").append(newLine)
+              .append("  SSTABLE_VERSIONS:6:big-nb").append(newLine)
+              .append("  TOKENS:15:<hidden>").append(newLine);
+        }
+        return sb.toString();
+    }
+
+    static class Fixture
+    {
+        final String name;
+        final InetSocketAddress localBroadcastAddress;
+        final List<String> endpoints;
+
+        Fixture(String name, InetSocketAddress localBroadcastAddress, 
List<String> endpoints)
+        {
+            this.name = name;
+            this.localBroadcastAddress = localBroadcastAddress;
+            this.endpoints = endpoints;
+        }
+
+        @Override
+        public String toString()
+        {
+            return name;
+        }
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to