From cc522916336f1754f5f7ca61debf2effb2c34e07 Mon Sep 17 00:00:00 2001
From: Benjamin Renard <brenard@easter-eggs.com>
Date: Mon, 28 Mar 2022 15:30:39 +0200
Subject: [PATCH] Add --max-changes and --force parameters

---
 src/ldaptools/ldapsync/cmd.py | 66 ++++++++++++++++++++++++++++++++++-
 1 file changed, 65 insertions(+), 1 deletion(-)

diff --git a/src/ldaptools/ldapsync/cmd.py b/src/ldaptools/ldapsync/cmd.py
index c26b834..2b9d53e 100644
--- a/src/ldaptools/ldapsync/cmd.py
+++ b/src/ldaptools/ldapsync/cmd.py
@@ -6,7 +6,15 @@ import sys
 import ldap.sasl
 
 from ldaptools import ldif_utils, paged, ldap_source
-from ldaptools.synchronize import Synchronize
+from ldaptools.synchronize import Synchronize, Create, Rename, Update, Delete
+
+
+operation_types = {
+    'create': Create,
+    'rename': Rename,
+    'update': Update,
+    'delete': Delete,
+}
 
 
 def source_uri(value):
@@ -35,6 +43,29 @@ def object_class_pivot(value):
     return value
 
 
+def max_changes_threshold(value):
+    if ':' in value:
+        try:
+            operation_type, threshold = value.strip().split(':')
+        except ValueError as exc:
+            raise argparse.ArgumentTypeError(
+                'Invalid operation threshold %s' % value) from exc
+        if operation_type not in operation_types:
+            raise argparse.ArgumentTypeError('Unknown %s operation type' % operation_type)
+        operation_type = operation_types[operation_type]
+    else:
+        operation_type = None
+        threshold = value
+    try:
+        threshold = int(threshold)
+        if threshold < 1:
+            raise ValueError
+    except ValueError as exc:
+        raise argparse.ArgumentTypeError(
+            'Invalid threshold %s, must be a positive integer' % threshold) from exc
+    return (operation_type, threshold)
+
+
 def main(args=None):
     parser = argparse.ArgumentParser(description='''\
 Synchronize an LDIF file or a source LDAP directory to another directory
@@ -84,6 +115,19 @@ Base DN of the source is remapped to another DN in the target directory''')
     parser.add_argument('--fake',
                         action='store_true',
                         help='compute synchronization actions but do not apply')
+    parser.add_argument(
+        '--max-changes', type=max_changes_threshold, action='append',
+        help=(
+            'maximum changes count: permit to inhibit synchronization '
+            'if too much changes are detected. You could specify a '
+            'threshold by operation type (%s), for instance, '
+            '"delete:10". To specify a global threshold, just specify '
+            'the change count. You could specify multiple threshold '
+            'by repeating this parameter.' % ', '.join(operation_types)
+        ))
+    parser.add_argument(
+        '--force', action='store_true',
+        help='Force synchronization, even if a threshold is reached.')
     parser.add_argument('--verbose',
                         action='store_true',
                         help='print all actions to stdout')
@@ -147,6 +191,26 @@ Base DN of the source is remapped to another DN in the target directory''')
             print(' -', action)
         if not synchronize.actions:
             print('Nothing to do.')
+
+    if options.max_changes:
+        threshold_reached = False
+        for operation_type, threshold in options.max_changes:
+            count = len([
+                action for action in synchronize.actions
+                if not operation_type or isinstance(action, operation_type)
+            ])
+            if count > threshold:
+                print(
+                    'Max %soperation threshold reached (%d > %d)' % (
+                        "%s " % operation_type.__name__.lower() if operation_type else '',
+                        count, threshold))
+                threshold_reached = True
+        if threshold_reached:
+            if not options.force:
+                print('Block this synchronization. Run again with --force parameter to force.')
+                sys.exit(2)
+            print('Force mode, continue')
+
     if not options.fake:
         synchronize.apply_actions()
     failed_actions = [action for action in synchronize.actions if action.errors]
-- 
2.30.2

