Skip to content

Commit b988cdf

Browse files
committed
MB-66604 Add conflict logging to xdcr-replicate
Testing ------- 1. Turn on conflict logging without specifying a default errors 2. Turn on/off conflict 3. Mapping rule in the wrong format errors 4. All rules tested on create and edit: --conflict-logging-rule-map override=default.override.default --conflict-logging-rule-default override.bucket_default --conflict-logging-rule-disable override.disable Change-Id: I6a6a636cdeca6dde1e65377178856677b152f3ee Reviewed-on: https://review.couchbase.org/c/couchbase-cli/+/227959 Tested-by: Build Bot <build@couchbase.com> Reviewed-by: Safian Ali <safian.ali@couchbase.com>
1 parent c778e52 commit b988cdf

4 files changed

Lines changed: 238 additions & 5 deletions

File tree

cbmgr.py

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5006,6 +5006,7 @@ def __init__(self):
50065006
group.add_argument('--filter-binary', choices=['1', '0'], metavar='<1|0>', default=None, dest='filter_binary',
50075007
help='When set to true binary documents are not replicated. When false binary documents may '
50085008
'be replicated')
5009+
group.add_argument('--force', action='store_true', help='Skips any confirmation prompts')
50095010

50105011
collection_group = self.parser.add_argument_group("Collection options")
50115012
collection_group.add_argument('--collection-explicit-mappings', choices=['1', '0'], metavar='<1|0>',
@@ -5018,6 +5019,22 @@ def __init__(self):
50185019
help='The mapping rules specified as a JSON formatted string. '
50195020
'(Enterprise Edition Only)')
50205021

5022+
conflict_logging_group = self.parser.add_argument_group("Conflict logging options")
5023+
conflict_logging_group.add_argument('--conflict-logging', choices=['1', '0'], metavar='<1|0>',
5024+
default=None, help='Whether conflict logging should be enabled')
5025+
conflict_logging_group.add_argument(
5026+
'--conflict-logging-default', type=str, metavar='<collection-string>',
5027+
help='A collection string specifying the default location for conflict logs')
5028+
conflict_logging_group.add_argument('--conflict-logging-rule-map', type=str, metavar='<mapping>',
5029+
action='append', help='A mapping from a scope/collection on the source to '
5030+
'a collection string on the destination')
5031+
conflict_logging_group.add_argument('--conflict-logging-rule-default', type=str, metavar='<collection>',
5032+
action='append', help='Use the replication default for the given '
5033+
'scope/collection')
5034+
conflict_logging_group.add_argument('--conflict-logging-rule-disable', type=str, metavar='<collection>',
5035+
action='append', help='Disable conflict logging for specified '
5036+
'scope/collection')
5037+
50215038
@rest_initialiser(cluster_init_check=True, version_check=True, enterprise_check=False)
50225039
def execute(self, opts):
50235040
if not self.enterprise and opts.compression:
@@ -5059,19 +5076,104 @@ def _get(self, opts):
50595076
_exit_if_errors(errors)
50605077
print(json.dumps(settings, indent=4, sort_keys=True))
50615078

5079+
def _parse_conflict_logging_args(self, opts):
5080+
if opts.conflict_logging is None:
5081+
return None
5082+
5083+
if opts.conflict_logging == "1" and not opts.conflict_logging_default:
5084+
_exit_if_errors(["if conflict-logging is enabled --conflict-logging-default is needed"])
5085+
if opts.conflict_logging != "1" and opts.conflict_logging_default:
5086+
_exit_if_errors(["if conflict-logging is disabled --conflict-logging-default cannot be passed"])
5087+
5088+
data = {}
5089+
if opts.conflict_logging == "0":
5090+
data["disabled"] = True
5091+
return data
5092+
5093+
if not opts.force:
5094+
choice = prompt_for_confirmation(
5095+
"Enabling conflict logging requires the source and destination clusters to "
5096+
"be on 8.0 or later, and have cross cluster versioning enabled.")
5097+
if not choice:
5098+
sys.exit(0)
5099+
5100+
data["disabled"] = False
5101+
5102+
default_cs, errors = CollectionStringParser(opts.conflict_logging_default).parse()
5103+
_exit_if_errors([f"error parsing {opts.conflict_logging_default}: {e}" for e in errors])
5104+
5105+
if default_cs.collection is None:
5106+
_exit_if_errors(["conflict logging default destination must be to a collection"])
5107+
5108+
data["bucket"] = default_cs.bucket
5109+
data["collection"] = f"{default_cs.scope}.{default_cs.collection}"
5110+
5111+
rules = {}
5112+
if opts.conflict_logging_rule_map:
5113+
for rule in opts.conflict_logging_rule_map:
5114+
# This is safe because bucket, scope and collection names cannot contain an equals
5115+
split = rule.split('=', 1)
5116+
if len(split) != 2:
5117+
_exit_if_errors([f"no '=' in log rule {rule}"])
5118+
5119+
src, errors = CollectionStringParser(split[0]).parse(start_at="scope")
5120+
_exit_if_errors([f"error parsing {split[0]}: {e}" for e in errors])
5121+
5122+
if split[1] == "":
5123+
_exit_if_errors([f"error parsing {rule}: no destination specified"])
5124+
5125+
dst, errors = CollectionStringParser(split[1]).parse()
5126+
_exit_if_errors([f"error parsing {split[1]}: {e}" for e in errors])
5127+
5128+
if dst.levels() != 3:
5129+
_exit_if_errors(["the destination for a rule must be a collection"])
5130+
5131+
rules[src.scope_collection_string()] = {
5132+
"bucket": dst.bucket,
5133+
"collection": dst.scope_collection_string()
5134+
}
5135+
5136+
if opts.conflict_logging_rule_default:
5137+
for collection in opts.conflict_logging_rule_default:
5138+
src, errors = CollectionStringParser(collection).parse(start_at="scope")
5139+
_exit_if_errors([f"error parsing {split[0]}: {e}" for e in errors])
5140+
5141+
rules[src.scope_collection_string()] = {}
5142+
5143+
if opts.conflict_logging_rule_disable:
5144+
for collection in opts.conflict_logging_rule_disable:
5145+
src, errors = CollectionStringParser(collection).parse(start_at="scope")
5146+
_exit_if_errors([f"error parsing {split[0]}: {e}" for e in errors])
5147+
5148+
rules[src.scope_collection_string()] = None
5149+
5150+
data["loggingRules"] = rules
5151+
5152+
return data
5153+
50625154
def _create(self, opts):
50635155
if opts.collection_migration == '1' and opts.collection_explicit_mappings == '1':
50645156
_exit_if_errors(['cannot enable both collection migration and explicit mappings'])
5157+
50655158
if opts.filter_skip and opts.filter is None:
50665159
_exit_if_errors(["--filter-expression is needed with the --filter-skip-restream option"])
5160+
5161+
if opts.conflict_logging == '1' and not opts.conflict_logging_default:
5162+
_exit_if_errors(["if conflict-logging is enabled --conflict-logging-default is needed"])
5163+
if opts.conflict_logging != '1' and opts.conflict_logging_default:
5164+
_exit_if_errors(["if conflict-logging is disabled --conflict-logging-default cannot be passed"])
5165+
5166+
conflict_logging = self._parse_conflict_logging_args(opts)
5167+
50675168
_, errors = self.rest.create_xdcr_replication(opts.cluster_name, opts.to_bucket, opts.from_bucket, opts.chk_int,
50685169
opts.worker_batch_size, opts.doc_batch_size, opts.fail_interval,
50695170
opts.rep_thresh, opts.src_nozzles, opts.dst_nozzles,
50705171
opts.usage_limit, opts.compression, opts.log_level,
50715172
opts.stats_interval, opts.filter, opts.priority,
50725173
opts.reset_expiry, opts.filter_del, opts.filter_exp,
50735174
opts.filter_binary, opts.collection_explicit_mappings,
5074-
opts.collection_migration, opts.collection_mapping_rules)
5175+
opts.collection_migration, opts.collection_mapping_rules,
5176+
conflict_logging)
50755177
_exit_if_errors(errors)
50765178

50775179
_success("XDCR replication created")
@@ -5127,14 +5229,17 @@ def _settings(self, opts):
51275229
_exit_if_errors(["--filter-expression is needed with the --filter-skip-restream option"])
51285230
if opts.collection_migration == '1' and opts.collection_explicit_mappings == '1':
51295231
_exit_if_errors(['cannot enable both collection migration and explicit mappings'])
5232+
5233+
conflict_logging = self._parse_conflict_logging_args(opts)
5234+
51305235
_, errors = self.rest.xdcr_replicator_settings(opts.chk_int, opts.worker_batch_size, opts.doc_batch_size,
51315236
opts.fail_interval, opts.rep_thresh, opts.src_nozzles,
51325237
opts.dst_nozzles, opts.usage_limit, opts.compression,
51335238
opts.log_level, opts.stats_interval, opts.replicator_id,
51345239
opts.filter, opts.filter_skip, opts.priority, opts.reset_expiry,
51355240
opts.filter_del, opts.filter_exp, opts.filter_binary,
51365241
opts.collection_explicit_mappings, opts.collection_migration,
5137-
opts.collection_mapping_rules)
5242+
opts.collection_mapping_rules, conflict_logging)
51385243
_exit_if_errors(errors)
51395244

51405245
_success("XDCR replicator settings updated")

cluster_manager.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2155,7 +2155,8 @@ def xdcr_replicator_settings(
21552155
filter_binary,
21562156
col_explicit_mappings,
21572157
col_migration_mode,
2158-
col_mapping_rule):
2158+
col_mapping_rule,
2159+
conflict_logging=None):
21592160

21602161
url = f'{self.hostname}/settings/replications/{urllib.parse.quote_plus(replicator_id)}'
21612162
params = self._get_xdcr_params(chk_interval, worker_batch_size, doc_batch_size,
@@ -2184,6 +2185,8 @@ def xdcr_replicator_settings(
21842185
params['collectionsMigrationMode'] = one_zero_boolean_to_string(col_migration_mode)
21852186
if col_mapping_rule is not None:
21862187
params['colMappingRules'] = col_mapping_rule
2188+
if conflict_logging is not None:
2189+
params['conflictLogging'] = json.dumps(conflict_logging, indent=None, separators=(',', ':'))
21872190

21882191
return self._post_form_encoded(url, params)
21892192

@@ -2231,7 +2234,8 @@ def _get_xdcr_params(chk_interval, worker_batch_size, doc_batch_size,
22312234
def create_xdcr_replication(self, name, to_bucket, from_bucket, chk_interval, worker_batch_size, doc_batch_size,
22322235
fail_interval, replication_thresh, src_nozzles, dst_nozzles, usage_limit, compression,
22332236
log_level, stats_interval, filter_expression, priority, reset_expiry, filter_del,
2234-
filter_exp, filter_binary, col_explicit_mappings, col_migration_mode, col_mapping_rule):
2237+
filter_exp, filter_binary, col_explicit_mappings, col_migration_mode, col_mapping_rule,
2238+
conflict_logging):
22352239
url = f'{self.hostname}/controller/createReplication'
22362240
params = self._get_xdcr_params(chk_interval, worker_batch_size, doc_batch_size,
22372241
fail_interval, replication_thresh, src_nozzles,
@@ -2263,6 +2267,8 @@ def create_xdcr_replication(self, name, to_bucket, from_bucket, chk_interval, wo
22632267
params['collectionsMigrationMode'] = one_zero_boolean_to_string(col_migration_mode)
22642268
if col_mapping_rule is not None:
22652269
params['colMappingRules'] = col_mapping_rule
2270+
if conflict_logging is not None:
2271+
params['conflictLogging'] = json.dumps(conflict_logging, indent=None, separators=(',', ':'))
22662272

22672273
return self._post_form_encoded(url, params)
22682274

docs/modules/cli/pages/cbcli/couchbase-cli-xdcr-replicate.adoc

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ _couchbase-cli xdcr-replicate_ [--cluster <url>] [--username <user>] [--password
2626
[--log-level <level>] [--priority <High|Medium|Low>] [--reset-expiry <1|0>]
2727
[--filter-deletion <1|0>] [--filter-expiration <1|0>] [--filter-binary <1|0>]
2828
[--collection-explicit-mappings <1|0>] [--collection-migration <1|0>]
29-
[--collection-mapping-rules <mappings>]
29+
[--collection-mapping-rules <mappings>] [--conflict-logging <1|0>]
30+
[--conflict-logging-default <collection-string>]
31+
[--conflict-logging-rule-map <mapping>]
32+
[--conflict-logging-rule-default <collection-string>]
33+
[--conflict-logging-rule-disable <collection-string>]
3034

3135
== DESCRIPTION
3236

@@ -147,6 +151,28 @@ include::{partialsdir}/cbcli/part-common-options.adoc[]
147151
on the filter expression. See
148152
xref:rest-api:rest-xdcr-create-replication.adoc[Creating a Replication].
149153

154+
== CONFLICT LOGGING OPTIONS
155+
156+
--conflict-logging <1|0>::
157+
When set to true conflicts will be logged. `--conflict-logging-default`
158+
must also be passed.
159+
160+
--conflict-logging-default <collection-string>::
161+
The default location to put conflict logs. Should be of the form
162+
`<bucket>.<collection>.<scope>`.
163+
164+
--conflict-logging-rule-map <mapping>::
165+
Defines a mapping rule for conflict logs. Parameter should be of the form
166+
`<src>=<dst>` where the source is a scope/collection string and the
167+
destination is a string specifying a bucket, scope and collection.
168+
169+
--conflict-logging-rule-default <collection-string>::
170+
Makes the given collection use the replication's default conflict logging
171+
location.
172+
173+
--conflict-logging-rule-disable <collection-string>::
174+
Turns off conflict logging for the given collection.
175+
150176
== COLLECTION OPTIONS
151177

152178
--collection-explicit-mappings <1|0>::
@@ -179,6 +205,20 @@ $ couchbase-cli xdcr-replicate -c 192.168.1.5 -u Administrator \
179205
-p password --create --xdcr-cluster-name east --xdcr-from-bucket apps \
180206
--xdcr-to-bucket apps
181207
----
208+
To do the above but also turn on conflict logging with the default location of
209+
`apps.cl.default`, source scope `hotels` logged to `apps.cl.hotels`, collection
210+
`hotels.uk` to `apps.cl.uk_hotels`, collection `hotels.other` to the bucket
211+
default (`apps.cl.default`) and disable conflict logging for `secret` do:
212+
....
213+
$ couchbase-cli xdcr-replicate -c 192.168.1.5 -u Administrator \
214+
-p password --create --xdcr-cluster-name east --xdcr-from-bucket apps \
215+
--xdcr-to-bucket apps --conflict-logging 1 \
216+
--conflict-logging-default apps.cl.default \
217+
--conflict-logging-rule-map hotels=apps.cl.hotels \
218+
--conflict-logging-rule-map hotels.uk=apps.cl.uk_hotels \
219+
--conflict-logging-rule-default hotels.other \
220+
--conflict-logging-rule-disable secret
221+
....
182222
To list all of the current XDCR replication you can run the following command.
183223
----
184224
$ couchbase-cli xdcr-replicate -c 192.168.1.5 -u Administrator \

test/test_cli.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,14 @@ def rest_parameter_match(self, expected_params, length_match=True):
270270
for parameter in expected_params:
271271
self.assertIn(parameter, self.server.rest_params)
272272

273+
def find_rest_param(self, key):
274+
for param in self.server.rest_params:
275+
split = param.split('=')
276+
if split[0] != key:
277+
continue
278+
279+
return urllib.parse.unquote(split[1])
280+
273281
def deprecated_output(self):
274282
self.assertIn('DEPRECATED:', self.str_output)
275283

@@ -2882,6 +2890,80 @@ def test_create_CE_with_EE(self):
28822890
self.server_args)
28832891
self.assertIn('can only be configured on enterprise edition', self.str_output)
28842892

2893+
def test_conflict_logging_disable(self):
2894+
args = [
2895+
'--create', '--xdcr-cluster-name', 'cluster1', '--xdcr-to-bucket', 'bucket2',
2896+
'--xdcr-from-bucket', 'bucket1', '--conflict-logging', '0',
2897+
]
2898+
2899+
self.no_error_run(self.command + args, self.server_args)
2900+
data = json.loads(self.find_rest_param('conflictLogging'))
2901+
self.assertEqual({'disabled': True}, data)
2902+
2903+
def test_conflict_logging_enabled_no_default(self):
2904+
args = [
2905+
'--create', '--xdcr-cluster-name', 'cluster1', '--xdcr-to-bucket', 'bucket2',
2906+
'--xdcr-from-bucket', 'bucket1', '--conflict-logging', '1',
2907+
]
2908+
2909+
self.system_exit_run(self.command + args, self.server_args)
2910+
self.assertIn('--conflict-logging-default is needed', self.str_output)
2911+
2912+
def test_conflict_logging_default(self):
2913+
args = [
2914+
'--create', '--xdcr-cluster-name', 'cluster1', '--xdcr-to-bucket', 'bucket2', '--force',
2915+
'--xdcr-from-bucket', 'bucket1', '--conflict-logging', '1',
2916+
'--conflict-logging-default', 'default.cl.default',
2917+
]
2918+
2919+
self.no_error_run(self.command + args, self.server_args)
2920+
data = json.loads(self.find_rest_param('conflictLogging'))
2921+
self.assertEqual({'disabled': False, 'bucket': 'default', 'collection': 'cl.default', 'loggingRules': {}}, data)
2922+
2923+
def test_conflict_logging_rule_map_invalid_collection_string(self):
2924+
args = [
2925+
'--create', '--xdcr-cluster-name', 'cluster1', '--xdcr-to-bucket', 'bucket2', '--force',
2926+
'--xdcr-from-bucket', 'bucket1', '--conflict-logging', '1',
2927+
'--conflict-logging-default', 'default.cl.default', '--conflict-logging-rule-map', 'foo=',
2928+
]
2929+
2930+
self.system_exit_run(self.command + args, self.server_args)
2931+
self.assertIn('no destination specified', self.str_output)
2932+
2933+
def test_conflict_logging_rule_map_dest_scope(self):
2934+
args = [
2935+
'--create', '--xdcr-cluster-name', 'cluster1', '--xdcr-to-bucket', 'bucket2', '--force',
2936+
'--xdcr-from-bucket', 'bucket1', '--conflict-logging', '1',
2937+
'--conflict-logging-default', 'default.cl.default', '--conflict-logging-rule-map', 'foo=bar',
2938+
]
2939+
2940+
self.system_exit_run(self.command + args, self.server_args)
2941+
self.assertIn('the destination for a rule must be a collection', self.str_output)
2942+
2943+
def test_conflict_logging_rules(self):
2944+
args = [
2945+
'--create', '--xdcr-cluster-name', 'cluster1', '--xdcr-to-bucket', 'bucket2', '--force',
2946+
'--xdcr-from-bucket', 'bucket1', '--conflict-logging', '1',
2947+
'--conflict-logging-default', 'default.cl.bucket_default',
2948+
'--conflict-logging-rule-map', 'override=default.cl.scope_default',
2949+
'--conflict-logging-rule-disable', 'override.disabled',
2950+
'--conflict-logging-rule-default', 'override.bucket_default',
2951+
]
2952+
2953+
self.no_error_run(self.command + args, self.server_args)
2954+
data = json.loads(self.find_rest_param('conflictLogging'))
2955+
expected = {
2956+
'disabled': False,
2957+
'bucket': 'default',
2958+
'collection': 'cl.bucket_default',
2959+
'loggingRules': {
2960+
'override': {'bucket': 'default', 'collection': 'cl.scope_default'},
2961+
'override.disabled': None,
2962+
'override.bucket_default': {},
2963+
}
2964+
}
2965+
self.assertEqual(expected, data)
2966+
28852967
def test_delete_replicate(self):
28862968
self.no_error_run(self.command + ['--delete', '--xdcr-replicator', '1'], self.server_args)
28872969
self.assertIn('DELETE:/controller/cancelXDCR/1', self.server.trace)

0 commit comments

Comments
 (0)