Skip to content

Commit be41e95

Browse files
committed
Add CE restriction tests
Implement automated tests for Community Edition 5-node limit enforcement: - test_ce_5_node_limit_restart_persistence: Validates node limit, persistence across restart, and recovery path - test_ce_service_topology_restrictions: Validates CE service restrictions Change-Id: I4a7318364ca945c2f9c0ccc00bbaa669c9e33998 Reviewed-on: https://review.couchbase.org/c/TAF/+/244683 Reviewed-by: <pulkit.matta@couchbase.com> Tested-by: Build Bot <build@couchbase.com>
1 parent 98dab49 commit be41e95

3 files changed

Lines changed: 370 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# CE Restriction Tests
2+
# Jenkins install param: edition=community
3+
ns_server.ce_restrictions.CommunityEditionRestrictions:
4+
test_ce_5_node_limit_restart_persistence,nodes_init=5,services_init=kv-kv-kv-kv-kv,default_bucket=True,bucket_size=256,replicas=0,bucket_storage=couchstore,GROUP=P0
5+
test_ce_reject_ee_only_services,nodes_init=1,services_init=kv,default_bucket=True,bucket_size=256,replicas=0,bucket_storage=couchstore,GROUP=P0
6+
test_ce_allow_valid_service_combinations,nodes_init=1,services_init=kv,default_bucket=True,bucket_size=256,replicas=0,bucket_storage=couchstore,kv_quota_percent=25,index_quota_percent=25,fts_quota_percent=25,GROUP=P0
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""
2+
Community Edition Restrictions Test Module
3+
4+
Validates ns_server enforcement of Community Edition limitations:
5+
- 5-node cluster limit
6+
- EE-only service restrictions (analytics, eventing, backup)
7+
- CE topology restrictions (no query-only nodes)
8+
"""
9+
10+
from basetestcase import ClusterSetup
11+
from ns_server.ce_restrictions_util import CommunityEditionRestrictionsUtil
12+
13+
14+
class CommunityEditionRestrictions(ClusterSetup):
15+
"""Test class for Community Edition restriction enforcement."""
16+
17+
def setUp(self):
18+
super(CommunityEditionRestrictions, self).setUp()
19+
self.ce_util = CommunityEditionRestrictionsUtil(
20+
self.cluster, self.cluster_util, self.log, self.sleep)
21+
22+
if self.cluster_util.is_enterprise_edition(self.cluster):
23+
self.fail("Test requires Community Edition cluster. "
24+
"Install with edition=community parameter.")
25+
26+
self.log.info("Cluster edition: %s", self.cluster.edition)
27+
28+
def tearDown(self):
29+
self.ce_util.cleanup_pending_nodes()
30+
super(CommunityEditionRestrictions, self).tearDown()
31+
32+
def test_ce_5_node_limit_restart_persistence(self):
33+
"""
34+
Validates:
35+
1. CE cluster cannot rebalance with >5 nodes
36+
2. Restriction persists across master restart
37+
3. Recovery path (scale-down) works
38+
"""
39+
if len(self.cluster.servers) < 6:
40+
self.fail("Requires 6 servers (5 cluster + 1 test node)")
41+
42+
self.ce_util.verify_ce_edition_via_diag_eval()
43+
44+
# Verify cluster has 5 active nodes
45+
active_nodes = self.ce_util.get_active_node_count()
46+
self.assertEqual(active_nodes, self.ce_util.CE_NODE_LIMIT,
47+
"Expected %d nodes, found %d"
48+
% (self.ce_util.CE_NODE_LIMIT, active_nodes))
49+
50+
node_to_add = self.cluster.servers[self.nodes_init]
51+
self.log.info("Adding 6th node: %s", node_to_add.ip)
52+
self.ce_util.add_node_without_rebalance(node_to_add, services=["kv"])
53+
self.ce_util.verify_node_in_pending_state(node_to_add.ip)
54+
55+
# Rebalance should fail
56+
self.log.info("Verifying rebalance fails with >5 nodes")
57+
self.ce_util.attempt_rebalance_expect_failure()
58+
59+
# Restart master and verify persistence
60+
self.log.info("Restarting master to verify persistence")
61+
self.ce_util.restart_couchbase_server(self.cluster.master)
62+
self.ce_util.verify_node_in_pending_state(node_to_add.ip)
63+
64+
self.log.info("Verifying restriction persists after restart")
65+
self.ce_util.attempt_rebalance_expect_failure()
66+
67+
self.log.info("Removing extra node to restore compliance")
68+
self.ce_util.rebalance_out_node(node_to_add)
69+
70+
final_count = self.ce_util.get_active_node_count()
71+
self.assertEqual(final_count, self.ce_util.CE_NODE_LIMIT,
72+
"Expected %d nodes after recovery, found %d"
73+
% (self.ce_util.CE_NODE_LIMIT, final_count))
74+
75+
self.log.info("CE 5-node limit enforcement validated")
76+
77+
def test_ce_reject_ee_only_services(self):
78+
"""
79+
Validates CE rejects EE-only services via CLI:
80+
- analytics: EE only
81+
- eventing: EE only
82+
- backup: EE only
83+
- query-only: Invalid CE topology
84+
"""
85+
test_node = self._get_available_node()
86+
87+
# EE-only services with expected error messages
88+
ee_services = [
89+
("analytics", "analytics service is only available on Enterprise Edition"),
90+
("eventing", "eventing service is only available on Enterprise Edition"),
91+
("backup", "backup service is only available on Enterprise Edition"),
92+
("data,analytics", "analytics service is only available on Enterprise Edition"),
93+
]
94+
95+
for services, expected_error in ee_services:
96+
success, error_msg = self.ce_util.add_node_via_cli(
97+
test_node, services, expect_success=False)
98+
self.assertTrue(success, "Services '%s' should be rejected" % services)
99+
self.assertIn(expected_error, error_msg,
100+
"Expected '%s' in: %s" % (expected_error, error_msg))
101+
self.log.info("Rejected '%s': %s", services, error_msg)
102+
self.sleep(8, "Wait after ejection")
103+
104+
# Query-only topology restriction
105+
success, error_msg = self.ce_util.add_node_via_cli(
106+
test_node, "query", expect_success=False)
107+
self.assertTrue(success, "Query-only should be rejected")
108+
self.assertIn("Community Edition only supports", error_msg)
109+
self.log.info("Rejected 'query-only': %s", error_msg)
110+
self.sleep(8, "Wait after ejection")
111+
112+
def test_ce_allow_valid_service_combinations(self):
113+
"""
114+
Validates CE allows valid service combinations:
115+
- data (kv)
116+
- data,query,index (kv,n1ql,index)
117+
- data,query,index,fts (kv,n1ql,index,fts)
118+
"""
119+
test_node = self._get_available_node()
120+
services = "kv,index,n1ql,fts"
121+
122+
self.log.info("Adding node %s with services: %s", test_node.ip, services)
123+
self.ce_util.add_node_via_rest_and_rebalance_in(test_node, services)
124+
self.log.info("Rebalance-in succeeded")
125+
126+
# Cleanup
127+
self.ce_util.rebalance_out_node(test_node)
128+
self.log.info("Cleanup completed")
129+
130+
def _get_available_node(self):
131+
"""Get a node not currently in the cluster."""
132+
available = [s for s in self.cluster.servers
133+
if s not in self.cluster.nodes_in_cluster]
134+
if not available:
135+
self.fail("No available nodes. Requires at least 2 nodes in node.ini")
136+
return available[0]
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
"""
2+
Community Edition Restrictions Utility Module
3+
4+
Helper methods for CE restriction testing:
5+
- Node management (add, eject, rebalance)
6+
- CE edition verification
7+
- Service restriction validation
8+
"""
9+
10+
from cb_server_rest_util.cluster_nodes.cluster_nodes_api import ClusterRestAPI
11+
from cb_tools.cb_cli import CbCli
12+
from couchbase_utils.cluster_utils.cluster_ready_functions import ClusterUtils
13+
from couchbase_utils.rebalance_utils.rebalance_util import RebalanceUtil
14+
from custom_exceptions.exception import RebalanceFailedException
15+
from shell_util.remote_connection import RemoteMachineShellConnection
16+
17+
18+
class CommunityEditionRestrictionsUtil:
19+
"""Utility class for CE restriction test operations."""
20+
21+
CE_NODE_LIMIT = 5
22+
CE_REBALANCE_ERROR_MSG = "Cannot rebalance with more than 5"
23+
24+
def __init__(self, cluster, cluster_util, log, sleep_func):
25+
self.cluster = cluster
26+
self.cluster_util = cluster_util
27+
self.log = log
28+
self.sleep = sleep_func
29+
self.rest = ClusterRestAPI(cluster.master)
30+
31+
def verify_ce_edition_via_diag_eval(self):
32+
"""Verify cluster is running CE via diag/eval."""
33+
shell = RemoteMachineShellConnection(self.cluster.master)
34+
shell.enable_diag_eval_on_non_local_hosts()
35+
shell.disconnect()
36+
37+
status, content = self.rest.diag_eval(
38+
"cluster_compat_mode:is_enterprise().")
39+
if not status:
40+
raise AssertionError("Failed to execute diag/eval")
41+
42+
is_ce = content is False if isinstance(content, bool) \
43+
else str(content).strip().lower() == "false"
44+
45+
if not is_ce:
46+
raise AssertionError("Expected CE edition. Got: %s" % content)
47+
48+
self.log.info("Verified CE edition via diag/eval")
49+
return True
50+
51+
def add_node_without_rebalance(self, node, services=None):
52+
"""Add a node without triggering rebalance."""
53+
services = services or ["kv"]
54+
self.log.info("Adding node %s with services %s", node.ip, services)
55+
try:
56+
return self.cluster_util.add_node(
57+
self.cluster, node, services=services, rebalance=False)
58+
except Exception as e:
59+
raise AssertionError("Failed to add node %s: %s" % (node.ip, e))
60+
61+
def verify_node_in_pending_state(self, node_ip):
62+
"""Verify node is in inactiveAdded (pending) state."""
63+
nodes = ClusterUtils.get_nodes(self.cluster.master, inactive_added=True)
64+
for node in nodes:
65+
if node.ip == node_ip:
66+
if node.clusterMembership != "inactiveAdded":
67+
raise AssertionError(
68+
"Node %s expected 'inactiveAdded', got '%s'"
69+
% (node_ip, node.clusterMembership))
70+
self.log.info("Node %s is in pending state", node_ip)
71+
return True
72+
raise AssertionError("Node %s not found in cluster" % node_ip)
73+
74+
def attempt_rebalance_expect_failure(self, expected_error=None):
75+
"""Attempt rebalance expecting CE restriction failure."""
76+
expected_error = expected_error or self.CE_REBALANCE_ERROR_MSG
77+
nodes = ClusterUtils.get_nodes(self.cluster.master, inactive_added=True)
78+
known_nodes = [n.id for n in nodes]
79+
80+
self.log.info("Attempting rebalance with %d nodes (expecting failure)",
81+
len(nodes))
82+
83+
status, content = self.rest.rebalance(known_nodes=known_nodes,
84+
eject_nodes=[])
85+
if not status:
86+
error_msg = content.decode('utf-8') if isinstance(content, bytes) \
87+
else str(content)
88+
if expected_error not in error_msg:
89+
raise AssertionError("Expected CE error. Got: %s" % error_msg)
90+
self.log.info("Rebalance rejected: %s", error_msg)
91+
return error_msg
92+
93+
# Monitor if rebalance started
94+
try:
95+
RebalanceUtil(self.cluster).monitor_rebalance()
96+
raise AssertionError("Rebalance should have failed but succeeded")
97+
except RebalanceFailedException as e:
98+
error_msg = str(e)
99+
if expected_error not in error_msg:
100+
raise AssertionError("Expected CE error. Got: %s" % error_msg)
101+
self.log.info("Rebalance failed: %s", error_msg)
102+
return error_msg
103+
104+
def restart_couchbase_server(self, server):
105+
"""Restart Couchbase Server and wait for ready."""
106+
self.log.info("Restarting server %s", server.ip)
107+
shell = RemoteMachineShellConnection(server)
108+
shell.restart_couchbase()
109+
shell.disconnect()
110+
self.cluster_util.wait_for_ns_servers_or_assert([server], wait_time=120)
111+
self.log.info("Server %s ready", server.ip)
112+
113+
def rebalance_out_node(self, node_to_remove):
114+
"""Rebalance out a node from the cluster."""
115+
nodes = ClusterUtils.get_nodes(self.cluster.master, inactive_added=True)
116+
otp_node = next((n.id for n in nodes if n.ip == node_to_remove.ip), None)
117+
118+
if not otp_node:
119+
raise AssertionError("Node %s not found" % node_to_remove.ip)
120+
121+
self.log.info("Rebalancing out %s", node_to_remove.ip)
122+
result = ClusterUtils.rebalance(
123+
self.cluster, wait_for_completion=True, ejected_nodes=[otp_node])
124+
125+
if not result:
126+
raise AssertionError("Rebalance-out failed")
127+
128+
self.cluster_util.update_cluster_nodes_service_list(self.cluster)
129+
self.log.info("Removed node %s", node_to_remove.ip)
130+
131+
def get_active_node_count(self):
132+
"""Get count of active nodes in cluster."""
133+
return len(ClusterUtils.get_nodes(self.cluster.master))
134+
135+
def cleanup_pending_nodes(self):
136+
"""Eject all pending (inactiveAdded) nodes."""
137+
cleaned = 0
138+
try:
139+
nodes = ClusterUtils.get_nodes(self.cluster.master,
140+
inactive_added=True)
141+
pending = [n for n in nodes if n.clusterMembership == "inactiveAdded"]
142+
for node in pending:
143+
try:
144+
self.rest.eject_node(node.id)
145+
cleaned += 1
146+
except Exception as e:
147+
self.log.warning("Failed to eject %s: %s", node.id, e)
148+
if cleaned:
149+
self.log.info("Cleaned up %d pending nodes", cleaned)
150+
except Exception as e:
151+
self.log.warning("Cleanup error: %s", e)
152+
return cleaned
153+
154+
def add_node_via_cli(self, node, services, expect_success=True):
155+
"""
156+
Add node via couchbase-cli server-add.
157+
Returns (success, error_msg) tuple.
158+
"""
159+
self._eject_node_by_ip(node.ip)
160+
self.sleep(8, "Wait after ejection")
161+
162+
shell = RemoteMachineShellConnection(self.cluster.master)
163+
cb_cli = CbCli(shell, username=self.cluster.master.rest_username,
164+
password=self.cluster.master.rest_password)
165+
166+
try:
167+
output = cb_cli.add_node(node, services)
168+
output_str = "\n".join(output) if isinstance(output, list) \
169+
else str(output)
170+
shell.disconnect()
171+
172+
if "ERROR:" in output_str:
173+
raise Exception(output_str)
174+
175+
# Success - cleanup
176+
self._eject_node_by_ip(node.ip)
177+
self.sleep(10, "Wait after ejection")
178+
return (True, "") if expect_success else (False, "Should be rejected")
179+
180+
except Exception as e:
181+
shell.disconnect()
182+
error_msg = str(e)
183+
return (True, error_msg) if not expect_success else (False, error_msg)
184+
185+
def _eject_node_by_ip(self, node_ip):
186+
"""Eject a pending node by IP."""
187+
try:
188+
nodes = ClusterUtils.get_nodes(self.cluster.master,
189+
inactive_added=True)
190+
for n in nodes:
191+
if n.ip == node_ip:
192+
self.rest.eject_node(n.id)
193+
return True
194+
except Exception as e:
195+
self.log.warning("Failed to eject %s: %s", node_ip, e)
196+
return False
197+
198+
def add_node_via_rest_and_rebalance_in(self, node, services):
199+
"""Add node via REST and rebalance in."""
200+
self._eject_node_by_ip(node.ip)
201+
self.sleep(5, "Wait after ejection")
202+
203+
self.log.info("Adding %s with services=%s", node.ip, services)
204+
status, content = self.rest.add_node(
205+
host_name=node.ip,
206+
username=node.rest_username,
207+
password=node.rest_password,
208+
services=services)
209+
210+
if not status:
211+
raise AssertionError("Failed to add node: %s" % content)
212+
213+
self.cluster_util.update_cluster_nodes_service_list(
214+
self.cluster, inactive_added=True)
215+
self.verify_node_in_pending_state(node.ip)
216+
217+
nodes = ClusterUtils.get_nodes(self.cluster.master, inactive_added=True)
218+
known_nodes = [n.id for n in nodes]
219+
220+
status, content = self.rest.rebalance(known_nodes=known_nodes,
221+
eject_nodes=[])
222+
if not status:
223+
raise AssertionError("Failed to start rebalance: %s" % content)
224+
225+
if not RebalanceUtil(self.cluster).monitor_rebalance():
226+
raise AssertionError("Rebalance-in failed")
227+
228+
self.cluster_util.update_cluster_nodes_service_list(self.cluster)

0 commit comments

Comments
 (0)