Browse Source

implements the mysql user management and backward compat mysql 5/8

Olivier Massot 5 years ago
parent
commit
c2921b417e

+ 102 - 9
clonedb.py

@@ -54,9 +54,14 @@ LOG_MYSQL_QUERIES = True
 
 
 MAX_ALLOWED_PACKET = 1073741824
 MAX_ALLOWED_PACKET = 1073741824
 
 
+CHARSET_TO_ENCODING = {
+    'utf8': 'utf-8',
+    'utf8mb4': 'utf-8',
+    'latin1': 'latin'
+}
 
 
-# Utilities
 
 
+# Utilities
 def load_settings():
 def load_settings():
     """ Load the settings from the 'settings.yml' file
     """ Load the settings from the 'settings.yml' file
     If there is no such file, the base settings.yml file is created
     If there is no such file, the base settings.yml file is created
@@ -217,6 +222,13 @@ class MySqlServer:
         row = cursor.fetchone()
         row = cursor.fetchone()
         return row is not None
         return row is not None
 
 
+    def get_db_charset(self, dbname):
+        """ return the charset (encoding) of the mysql database """
+        cursor = self.exec_query(f"""SELECT default_character_set_name 
+                                     FROM information_schema.SCHEMATA S 
+                                     WHERE schema_name = '{dbname}';""")
+        return cursor.fetchone()[0]
+
     def list_tables(self, dbname=""):
     def list_tables(self, dbname=""):
         """ Return a list of tables (but not views!)
         """ Return a list of tables (but not views!)
         for either the currently selected database,
         for either the currently selected database,
@@ -236,7 +248,7 @@ class MySqlServer:
     def get_view_definition(self, view_name, set_definer=""):
     def get_view_definition(self, view_name, set_definer=""):
         """ Return the SQL create statement for the view
         """ Return the SQL create statement for the view
         If 'set_definer' is not empty, the username in the 'SET DEFINER' part
         If 'set_definer' is not empty, the username in the 'SET DEFINER' part
-        of the create statement is replace by the one given
+        of the create statement is replaced by the one given
         """
         """
         cursor = self.exec_query(f"show create view {view_name}")
         cursor = self.exec_query(f"show create view {view_name}")
         definition = cursor.fetchone()[1]
         definition = cursor.fetchone()[1]
@@ -248,6 +260,13 @@ class MySqlServer:
         return definition
         return definition
 
 
 
 
+class MysqlUser:
+    def __init__(self, username, pwd, host='localhost'):
+        self.username = username
+        self.pwd = pwd
+        self.host = host
+
+
 # Operation status
 # Operation status
 UNKNOWN = 0
 UNKNOWN = 0
 SUCCESS = 1
 SUCCESS = 1
@@ -263,12 +282,14 @@ class CloningOperation:
     """ A database cloning operation between two Mysql servers
     """ A database cloning operation between two Mysql servers
     """
     """
 
 
-    def __init__(self, name, dbname, from_server, to_server, is_default=True, ignore_tables=None, structure_only=None,
+    def __init__(self, name, dbname, from_server, to_server, grant=None,
+                 is_default=True, ignore_tables=None, structure_only=None,
                  filter_tables=None, ignore_views=None, compress=True):
                  filter_tables=None, ignore_views=None, compress=True):
         self.name = name
         self.name = name
         self.dbname = dbname
         self.dbname = dbname
         self.from_server = from_server
         self.from_server = from_server
         self.to_server = to_server
         self.to_server = to_server
+        self.grant = grant if grant is not None else []
 
 
         self.is_default = is_default
         self.is_default = is_default
         self.compress = compress
         self.compress = compress
@@ -298,6 +319,7 @@ class CloningOperation:
                     "--skip-add-drop-table",
                     "--skip-add-drop-table",
                     "--skip-add-locks",
                     "--skip-add-locks",
                     "--skip-comments",
                     "--skip-comments",
+                    "--column-statistics=0"
                     ]
                     ]
 
 
         if self.compress:
         if self.compress:
@@ -344,7 +366,22 @@ class CloningOperation:
         return cmd
         return cmd
 
 
     @staticmethod
     @staticmethod
-    def _run_piped_processes(dump_cmd, restore_cmd, tbl_count):
+    def _clean_sql(bin_cmd, encoding):
+        """ clean some old sql declaration from mysql 5 in order to preserve
+            a compatibility between servers"""
+        cmd = bin_cmd.decode('latin')
+
+        # To ensure compatibility between mysql5 and 8+
+        cmd = re.sub(",?NO_AUTO_CREATE_USER", "", cmd)
+
+        return cmd.encode('latin')
+
+    @staticmethod
+    def _run_piped_processes(
+            dump_cmd,
+            restore_cmd,
+            tbl_count,
+            encoding):
         """ Run the dump and the restore commands by piping them
         """ Run the dump and the restore commands by piping them
         The output of the mysqldump process is piped into the input of the mysql one
         The output of the mysqldump process is piped into the input of the mysql one
         """
         """
@@ -358,7 +395,9 @@ class CloningOperation:
             with Popen(restore_cmd, stdin=PIPE, stdout=mysql_handler, stderr=mysql_handler) as mysql:
             with Popen(restore_cmd, stdin=PIPE, stdout=mysql_handler, stderr=mysql_handler) as mysql:
                 # noinspection PyTypeChecker
                 # noinspection PyTypeChecker
                 with Popen(dump_cmd, stdout=PIPE, stderr=mysqldump_handler) as mysqldump:
                 with Popen(dump_cmd, stdout=PIPE, stderr=mysqldump_handler) as mysqldump:
-                    mysql.stdin.write(mysqldump.stdout.read())
+                    cmd = mysqldump.stdout.read()
+                    cmd = CloningOperation._clean_sql(cmd, encoding)
+                    mysql.stdin.write(cmd)
 
 
             if mysqldump.returncode:
             if mysqldump.returncode:
                 raise RuntimeError('mysqldump returned a non zero code')
                 raise RuntimeError('mysqldump returned a non zero code')
@@ -390,6 +429,18 @@ class CloningOperation:
             self.to_server.connect()
             self.to_server.connect()
             logger.debug('Connected to %s', self.to_server)
             logger.debug('Connected to %s', self.to_server)
 
 
+            # Create admin users if not exist
+            for user in self.grant:
+                exists = self.to_server.exec_query(
+                    f"SELECT count(*) FROM mysql.user WHERE User = '{user.username}' and Host='{user.host}';"
+                ).fetchone()[0] > 0
+
+                if not exists:
+                    logger.info(f'Create user %s@%s on %s', user.username, user.host, self.to_server)
+                    self.to_server.exec_query(
+                        f"CREATE USER '{user.username}'@'{user.host}' IDENTIFIED BY '{user.pwd}';")
+
+            # List tables
             tables = {}
             tables = {}
             for tname in self.from_server.list_tables():
             for tname in self.from_server.list_tables():
                 if any(rx.match(tname) for rx in self.ignore_tables):
                 if any(rx.match(tname) for rx in self.ignore_tables):
@@ -422,15 +473,39 @@ class CloningOperation:
             self.to_server.exec_query(f"CREATE SCHEMA `{self.dbname}`;")
             self.to_server.exec_query(f"CREATE SCHEMA `{self.dbname}`;")
             self.to_server.set_active_db(self.dbname)
             self.to_server.set_active_db(self.dbname)
 
 
+            # Following is to avoid conflict between mysql 5 and mysql 8+
+            # (@see https://stackoverflow.com/questions/50336378/variable-sql-mode-cant-be-set-to-the-value-of-no-auto-create-user)
+            self.to_server.exec_query(f"SET GLOBAL log_bin_trust_function_creators = 1;")
+
+            # Grant admin users if any
+            for user in self.grant:
+                self.to_server.exec_query(
+                    f"GRANT ALL ON {self.dbname}.* TO '{user.username}'@'{user.host}';"
+                )
+
+            # charsets
+            charset = self.from_server.get_db_charset(self.dbname)
+            encoding = CHARSET_TO_ENCODING[charset]
+
             # Run mysqldump
             # Run mysqldump
             try:
             try:
                 if dump_structure_for:
                 if dump_structure_for:
                     logger.info(f"Cloning structure for {len(dump_structure_for)} tables (on {len(tables)})...")
                     logger.info(f"Cloning structure for {len(dump_structure_for)} tables (on {len(tables)})...")
-                    self._run_piped_processes(dump_structure_cmd, restore_cmd, len(dump_structure_for))
+                    self._run_piped_processes(
+                        dump_structure_cmd,
+                        restore_cmd,
+                        len(dump_structure_for),
+                        encoding
+                    )
 
 
                 if dump_data_for:
                 if dump_data_for:
                     logger.info(f"Cloning data for {len(dump_data_for)} tables (on {len(tables)})...")
                     logger.info(f"Cloning data for {len(dump_data_for)} tables (on {len(tables)})...")
-                    self._run_piped_processes(dump_data_cmd, restore_cmd, len(dump_data_for))
+                    self._run_piped_processes(
+                        dump_data_cmd,
+                        restore_cmd,
+                        len(dump_data_for),
+                        encoding
+                    )
 
 
                 logger.info(f"Cloning views...")
                 logger.info(f"Cloning views...")
                 self.from_server.set_active_db(self.dbname)
                 self.from_server.set_active_db(self.dbname)
@@ -466,6 +541,8 @@ def main(settings, arguments):
 
 
     # Load the servers' configuration
     # Load the servers' configuration
     servers = {}
     servers = {}
+    if 'servers' not in settings:
+        raise RuntimeError(f'Missing section in settings.yml: {servers}')
     for server_name, server_settings in settings['servers'].items():
     for server_name, server_settings in settings['servers'].items():
         hostname = server_settings['host']
         hostname = server_settings['host']
 
 
@@ -488,15 +565,27 @@ def main(settings, arguments):
 
 
         servers[server_name] = server
         servers[server_name] = server
 
 
+    # Load the users' configuration
+    users = {}
+    for username, args in settings.get('users', {}).items():
+        host = args.get('host', 'localhost')
+        pwd = args.get('pwd', '')
+        users[username] = MysqlUser(username, pwd, host)
+
     # Load the cloning ops' configuration
     # Load the cloning ops' configuration
     ops = {}
     ops = {}
+    if 'operations' not in settings:
+        raise RuntimeError(f'Missing section in settings.yml: {servers}')
     for name, args in settings['operations'].items():
     for name, args in settings['operations'].items():
         dbname = args['dbname']
         dbname = args['dbname']
         from_server = servers[args['from_server']]
         from_server = servers[args['from_server']]
         to_server = servers[args['to_server']]
         to_server = servers[args['to_server']]
-        kwargs = {k: v for k, v in args.items() if k not in ('dbname', 'from_server', 'to_server')}
+        grant = args.get('grant', [])
+        admins = [user for username, user in users.items() if username in grant]
+        kwargs = {k: v for k, v in args.items() \
+                  if k not in ('dbname', 'from_server', 'to_server', 'grant')}
 
 
-        op = CloningOperation(name, dbname, from_server, to_server, **kwargs)
+        op = CloningOperation(name, dbname, from_server, to_server, admins, **kwargs)
         ops[name] = op
         ops[name] = op
 
 
     # Operations to launch
     # Operations to launch
@@ -526,6 +615,10 @@ def main(settings, arguments):
             return
             return
         logger.debug('> User confirmed')
         logger.debug('> User confirmed')
 
 
+    # Create the user if they do not exist
+    # CREATE USER IF NOT EXISTS 'user'@'localhost' IDENTIFIED BY 'password';
+    # GRANT ALL ON opentalent TO 'opentalent'@'localhost';
+
     # Run the cloning operations
     # Run the cloning operations
     for op in selected_ops:
     for op in selected_ops:
         op.run()
         op.run()

BIN
core/__pycache__/__init__.cpython-38.pyc


BIN
core/__pycache__/docker.cpython-38.pyc


BIN
core/__pycache__/locker.cpython-38.pyc


BIN
core/__pycache__/logging_.cpython-38.pyc


BIN
core/__pycache__/pipe_handler.cpython-36.pyc


BIN
core/__pycache__/pipe_handler.cpython-38.pyc


BIN
core/__pycache__/prompt.cpython-38.pyc


BIN
core/__pycache__/ssh.cpython-38.pyc


+ 7 - 3
core/pipe_handler.py

@@ -37,9 +37,13 @@ class PipeHandler(threading.Thread):
     def run(self):
     def run(self):
         """ Run the thread, logging everything.
         """ Run the thread, logging everything.
         """
         """
-        for line in iter(self.pipeReader.readline, ''):
-            self.process(line.strip('\n'))
-        self.pipeReader.close()
+        try:
+            for line in iter(self.pipeReader.readline, ''):
+                self.process(line.strip('\n'))
+        except UnicodeDecodeError:
+            self.process(' -- Error while decoding the incoming, unable to log --')
+        finally:
+            self.pipeReader.close()
 
 
     def close(self):
     def close(self):
         """ Close the write end of the pipe.
         """ Close the write end of the pipe.

+ 26 - 8
readme.md

@@ -27,6 +27,8 @@ Script de clonage des bases de données MySql
 
 
 ## Configuration
 ## Configuration
 
 
+### Structure générale
+
 Pour configurer les opérations de clônage, ouvrez le fichier `settings.yml`.
 Pour configurer les opérations de clônage, ouvrez le fichier `settings.yml`.
 La structure du fichier est la suivante:
 La structure du fichier est la suivante:
 
 
@@ -43,6 +45,11 @@ La structure du fichier est la suivante:
           port: 22
           port: 22
           user: user
           user: user
 
 
+    users:
+      [username]:
+        hosts: localhost
+        pwd: [password]
+
     operations:
     operations:
       [op_name]:
       [op_name]:
         dbname: my_db
         dbname: my_db
@@ -54,7 +61,10 @@ La structure du fichier est la suivante:
         structure_only: []
         structure_only: []
         compress: True
         compress: True
         filter_tables: []
         filter_tables: []
+        grant: []
+
 
 
+### Configurer les serveurs
 
 
 Ajouter une entrée dans la section `servers` pour 
 Ajouter une entrée dans la section `servers` pour 
 le serveur d'origine et le serveur cible de l'opération de clônage.
 le serveur d'origine et le serveur cible de l'opération de clônage.
@@ -72,9 +82,22 @@ une section `ssh`, dans laquelle
 pourront apparaitre les paramètres de connexion SSH:
 pourront apparaitre les paramètres de connexion SSH:
 `key_file` (chemin d'accès à la clé privée ssh), `port`, `user`
 `key_file` (chemin d'accès à la clé privée ssh), `port`, `user`
 
 
-> Les noms donnés à chaque serveur (`[server_name]`) n'ont pas d'importance 
+> Les noms donnés à chaque serveur (`[server_name]`) n'ont pas d'importance pour l'exécution de clonedb
+
+### Configurer les users (facultatif)
+
+Il est possible d'ajouter une section `users`. Les users listés dans cette section
+seront créés avant toute opération de clonage **seulement s'ils n'existent pas déjà**.
+Les users ne seront jamais remplacés.
+
+Les paramètres sont:
 
 
-Ensuite, ajouter une entrée dans la section `operations` et définir au moins les trois paramètres suivants: 
+* `host`: l'host correspondant ('localhost' par défaut)
+* `pwd`: le mot de passe
+
+### Configurer les opérations de clônage
+
+Enfin, ajouter une entrée dans la section `operations` et définir au moins les trois paramètres suivants: 
    * dbname: le nom de la base de données à cloner
    * dbname: le nom de la base de données à cloner
    * from_server: le nom du serveur source tel qu'il a été défini à la section servers
    * from_server: le nom du serveur source tel qu'il a été défini à la section servers
    * to_server: le nom du serveur cible tel qu'il a été défini à la section servers
    * to_server: le nom du serveur cible tel qu'il a été défini à la section servers
@@ -89,9 +112,4 @@ correspondantes sera clonée, pas les données contenues.
 * `ignore_views`: liste de noms de tables ou d'expressions régulières, les vues correspondantes seront ignorées
 * `ignore_views`: liste de noms de tables ou d'expressions régulières, les vues correspondantes seront ignorées
 * `filter_tables`: liste de noms de tables ou d'expressions régulières; si une liste est donnée, 
 * `filter_tables`: liste de noms de tables ou d'expressions régulières; si une liste est donnée, 
 seules les tables correspondantes seront traitées.
 seules les tables correspondantes seront traitées.
-
-
-## Workarounds
-
-* `The user specified as a definer ('username'@'%') does not exist`: add a user account 
-to the targeted mysql server named `username@%` with admin privileges
+* `grant`: une liste des users à qui seront accordés les droits d'admin sur cette base

+ 15 - 0
settings.yml.dist

@@ -44,6 +44,15 @@ servers:
       port: 22
       port: 22
       user: exploitation
       user: exploitation
 
 
+users:
+  opentalent:
+    host: localhost
+    pwd: nSDC3Bm3ws88qKjV63
+
+  openassos:
+    host: localhost
+    pwd: Vq2icge7SM3P26CaC3
+
 operations:
 operations:
   crm:
   crm:
     dbname: crm
     dbname: crm
@@ -51,6 +60,8 @@ operations:
     to_server: docker
     to_server: docker
     is_default: True
     is_default: True
     compress: True
     compress: True
+    grant:
+      - opentalent
 
 
   adminassos:
   adminassos:
     dbname: adminassos
     dbname: adminassos
@@ -67,6 +78,8 @@ operations:
     compress: True
     compress: True
     ignore_tables: ['Access', 'matomo.*', 'user_piwik.*', 'zzz_.*', 'tt_content_save', 'tt_content_v59']
     ignore_tables: ['Access', 'matomo.*', 'user_piwik.*', 'zzz_.*', 'tt_content_save', 'tt_content_v59']
     structure_only: ['cf_cache_.*', 'sys_log']
     structure_only: ['cf_cache_.*', 'sys_log']
+    grant:
+      - openassos
 
 
   opentalent:
   opentalent:
     dbname: opentalent
     dbname: opentalent
@@ -77,6 +90,8 @@ operations:
     structure_only: ['Audit_.*']
     structure_only: ['Audit_.*']
     ignore_tables: []
     ignore_tables: []
     ignore_views: []
     ignore_views: []
+    grant:
+      - opentalent
 
 
   metabase:
   metabase:
     dbname: metabase
     dbname: metabase