|
@@ -6,12 +6,11 @@ serveur de production vers le serveur local
|
|
|
> Configuration: settings.yml
|
|
> Configuration: settings.yml
|
|
|
|
|
|
|
|
Usage:
|
|
Usage:
|
|
|
- clonedb.py [-v] [-y] [dbname]
|
|
|
|
|
|
|
+ clonedb.py [-v] [-y] [<dbname>]
|
|
|
clonedb.py (-h | --help)
|
|
clonedb.py (-h | --help)
|
|
|
clonedb.py --version
|
|
clonedb.py --version
|
|
|
|
|
|
|
|
Options:
|
|
Options:
|
|
|
- -v, --verbose Displays more informations
|
|
|
|
|
-y, --yes Do not ask for confirmation
|
|
-y, --yes Do not ask for confirmation
|
|
|
-h --help Show this screen.
|
|
-h --help Show this screen.
|
|
|
--version Show version.
|
|
--version Show version.
|
|
@@ -19,138 +18,160 @@ Options:
|
|
|
@author: olivier.massot, 05-2020
|
|
@author: olivier.massot, 05-2020
|
|
|
"""
|
|
"""
|
|
|
import logging
|
|
import logging
|
|
|
|
|
+import subprocess
|
|
|
import sys
|
|
import sys
|
|
|
|
|
|
|
|
-import mysql.connector
|
|
|
|
|
import yaml
|
|
import yaml
|
|
|
from docopt import docopt
|
|
from docopt import docopt
|
|
|
-
|
|
|
|
|
from path import Path
|
|
from path import Path
|
|
|
|
|
|
|
|
import logging_
|
|
import logging_
|
|
|
|
|
+from locker import Lockfile, AlreadyRunning
|
|
|
|
|
+from mysql import MySqlServer
|
|
|
|
|
|
|
|
__VERSION__ = "0.1"
|
|
__VERSION__ = "0.1"
|
|
|
|
|
|
|
|
HERE = Path(__file__).parent
|
|
HERE = Path(__file__).parent
|
|
|
-LOCKFILE = HERE / '.clonedb.lock'
|
|
|
|
|
|
|
|
|
|
|
|
+# Start logger
|
|
|
|
|
+logger = logging.getLogger('clonedb')
|
|
|
|
|
+logging_.start("clonedb", replace=True)
|
|
|
|
|
+
|
|
|
|
|
+# Load settings
|
|
|
with open(HERE / 'settings.yml', 'r') as f:
|
|
with open(HERE / 'settings.yml', 'r') as f:
|
|
|
SETTINGS = yaml.load(f, Loader=yaml.FullLoader)
|
|
SETTINGS = yaml.load(f, Loader=yaml.FullLoader)
|
|
|
|
|
|
|
|
|
|
+# FIX the default ascii encoding on some linux dockers...
|
|
|
|
|
+sys.stdout = open(sys.stdout.fileno(), mode='w', encoding='utf8', buffering=1)
|
|
|
|
|
+
|
|
|
|
|
+# Utilities
|
|
|
|
|
+def _format_cmd(cmd):
|
|
|
|
|
+ if type(cmd) is list:
|
|
|
|
|
+ return " ".join(cmd)
|
|
|
|
|
+ return cmd
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _run_process(cmds):
|
|
|
|
|
+ logger.debug("Run: %s", " ".join(map(str, cmds)))
|
|
|
|
|
+ logpipe = logging_.LogPipe('clonedb')
|
|
|
|
|
+ try:
|
|
|
|
|
+ # noinspection PyTypeChecker
|
|
|
|
|
+ p = subprocess.run(cmds, shell=False, stdin=sys.stdin, stdout=logpipe, stderr=logpipe)
|
|
|
|
|
+ if p.returncode != 0:
|
|
|
|
|
+ logger.error("Process was terminated by signal %s", p.returncode)
|
|
|
|
|
+ raise RuntimeError()
|
|
|
|
|
+
|
|
|
|
|
+ except (OSError, RuntimeError) as e:
|
|
|
|
|
+ logger.error("Execution failed: %s", e)
|
|
|
|
|
+ raise RuntimeError(f"An error happened at runtime: {e}")
|
|
|
|
|
+
|
|
|
|
|
+ finally:
|
|
|
|
|
+ logpipe.close()
|
|
|
|
|
+
|
|
|
|
|
|
|
|
-class MySqlServer:
|
|
|
|
|
- def __init__(self, host, port, username, password, description=""):
|
|
|
|
|
|
|
+class SshTunnel:
|
|
|
|
|
+ def __init__(self, host, port=22, user="root", key_file="~/.ssh/id_rsa"):
|
|
|
self.host = host
|
|
self.host = host
|
|
|
self.port = port
|
|
self.port = port
|
|
|
- self.username = username
|
|
|
|
|
- self.password = password
|
|
|
|
|
- self.description = description or "no description"
|
|
|
|
|
-
|
|
|
|
|
- self.cnn = None
|
|
|
|
|
-
|
|
|
|
|
- def __repr__(self):
|
|
|
|
|
- return f"{self.username}@{self.host}:{self.port} ({self.description})"
|
|
|
|
|
-
|
|
|
|
|
- def connect(self):
|
|
|
|
|
- logger.debug(f'Try to connect to {self}')
|
|
|
|
|
- self.cnn = mysql.connector.connect(
|
|
|
|
|
- host=self.host,
|
|
|
|
|
- port=self.port,
|
|
|
|
|
- user=self.username,
|
|
|
|
|
- passwd=self.password
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- def db_exists(self, dbname):
|
|
|
|
|
- cursor = self.cnn.cursor()
|
|
|
|
|
- cursor.execute(f"""SELECT SCHEMA_NAME
|
|
|
|
|
- FROM INFORMATION_SCHEMA.SCHEMATA
|
|
|
|
|
- WHERE SCHEMA_NAME = '{dbname}'""")
|
|
|
|
|
- row = cursor.fetchone()
|
|
|
|
|
- return row is not None
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-def clonedb(from_server, to_server, dbname):
|
|
|
|
|
- logger.info(f"*** Cloning {dbname} ***")
|
|
|
|
|
- logger.debug(f"From {from_server}")
|
|
|
|
|
- logger.debug(f"To {to_server}")
|
|
|
|
|
|
|
+ self.user = user
|
|
|
|
|
+ self.key_file = key_file
|
|
|
|
|
|
|
|
- to_server.cnn.cmd_query('DROP DATABASE {dbname};')
|
|
|
|
|
- to_server.cnn.cmd_query('CREATE DATABASE {dbname};')
|
|
|
|
|
|
|
+ def call(self, cmd):
|
|
|
|
|
+ ssh_call = ["ssh",
|
|
|
|
|
+ "-i", self.key_file,
|
|
|
|
|
+ "-p", str(self.port),
|
|
|
|
|
+ "-o", "StrictHostKeyChecking=no",
|
|
|
|
|
+ f"{self.user}@{self.host}",
|
|
|
|
|
+ ]
|
|
|
|
|
|
|
|
- dump_cmd = "mysqldump --single-transaction -u ${USERDBROOTREMOTE} --password=${PASSDBROOTREMOTE} $1"
|
|
|
|
|
- ssh_dump_cmd = f"ssh -i {SSHEXPLOITATIONKEY} -p {PORT} -C exploitation@${IPPROD} {dump_cmd}"
|
|
|
|
|
|
|
+ ssh_cmd = ssh_call + cmd
|
|
|
|
|
+ _run_process(ssh_cmd)
|
|
|
|
|
|
|
|
- restore_cmd = "mysql -h ${3} -P ${4} -u ${USERDBROOT} --password=${PASSDBROOT} -D $2"
|
|
|
|
|
|
|
|
|
|
- cmd = f"{ssh_dump_cmd} | {restore_cmd}"
|
|
|
|
|
|
|
+def clonedb(ssh_tunnel, remote_mysql, local_mysql, dbname, options):
|
|
|
|
|
+ logger.info(f"*** Cloning {dbname} ***")
|
|
|
|
|
+ logger.debug(f"From {remote_mysql}")
|
|
|
|
|
+ logger.debug(f"To {local_mysql}")
|
|
|
|
|
+
|
|
|
|
|
+ dump_cmd = ["mysqldump",
|
|
|
|
|
+ "--single-transaction",
|
|
|
|
|
+ "-u", remote_mysql.username,
|
|
|
|
|
+ f"--password={remote_mysql.password}",
|
|
|
|
|
+ "--add-drop-database",
|
|
|
|
|
+ "--compress",
|
|
|
|
|
+ dbname]
|
|
|
|
|
+
|
|
|
|
|
+ restore_cmd = ["mysql",
|
|
|
|
|
+ "-h", local_mysql.host,
|
|
|
|
|
+ "-P", str(local_mysql.port),
|
|
|
|
|
+ "-u", local_mysql.username,
|
|
|
|
|
+ f"--password={local_mysql.password}",
|
|
|
|
|
+ "-D", dbname]
|
|
|
|
|
+
|
|
|
|
|
+ cloning_cmd = dump_cmd + ['|'] + restore_cmd
|
|
|
|
|
|
|
|
|
|
+ try:
|
|
|
|
|
+ ssh_tunnel.call(cloning_cmd)
|
|
|
|
|
+ logger.info("> the database was successfully cloned")
|
|
|
|
|
+ except RuntimeError:
|
|
|
|
|
+ logger.error(f"<!> An error happened while cloning the '{dbname}' database")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def ask_confirmation(msg):
|
|
|
|
|
+ logger.debug('Ask for confirmation...')
|
|
|
|
|
+ msg += "\nWould you like to continue? (yes/no)"
|
|
|
|
|
+ while 1:
|
|
|
|
|
+ answer = input(msg)
|
|
|
|
|
+ if answer in ('oui', 'yes', 'y', 'o'):
|
|
|
|
|
+ logger.debug(f"> user confirmed by answering '{answer}'")
|
|
|
|
|
+ return True
|
|
|
|
|
+ elif answer in ('non', 'no', 'n'):
|
|
|
|
|
+ logger.debug(f"> user cancelled by answering '{answer}'")
|
|
|
|
|
+ return False
|
|
|
|
|
+ else:
|
|
|
|
|
+ msg = "The answer could'nt be understood. Continue? (yes/no)"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def main(dbnames, prompt=True):
|
|
|
|
|
+ ssh_tunnel = SshTunnel(**SETTINGS['ssh'])
|
|
|
|
|
+ remote_mysql = MySqlServer(**SETTINGS['remote'])
|
|
|
|
|
+ local_mysql = MySqlServer(**SETTINGS['local'])
|
|
|
|
|
+
|
|
|
|
|
+ if prompt:
|
|
|
|
|
+ # Ask for confirmation
|
|
|
|
|
+ msg = f"""The followind databases will be cloned
|
|
|
|
|
+from '{remote_mysql}' to '{local_mysql}':
|
|
|
|
|
+> {', '.join(dbnames)}
|
|
|
|
|
+"WARNING: the existing local databases will be replaced"""
|
|
|
|
|
+
|
|
|
|
|
+ if not ask_confirmation(msg):
|
|
|
|
|
+ logger.info("-- Operation cancelled by user --")
|
|
|
|
|
+ return
|
|
|
|
|
|
|
|
- cmd = "ssh -i ${SSHEXPLOITATIONKEY} -p ${PORT} -C exploitation@${IPPROD} mysqldump --single-transaction -u ${USERDBROOTREMOTE} --password=${PASSDBROOTREMOTE} $1 | mysql -h ${3} -P ${4} -u ${USERDBROOT} --password=${PASSDBROOT} -D $2"
|
|
|
|
|
|
|
+ # start to clone
|
|
|
|
|
+ for dbname in dbnames:
|
|
|
|
|
+ options = SETTINGS['databases'].get(dbname, None) or {}
|
|
|
|
|
+ clonedb(ssh_tunnel, remote_mysql, local_mysql, dbname, options)
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
if __name__ == '__main__':
|
|
|
- arguments = docopt(__doc__, help=__doc__, version=__VERSION__)
|
|
|
|
|
- verbose = '--verbose' in arguments
|
|
|
|
|
|
|
|
|
|
- logger = logging.getLogger('clonedb')
|
|
|
|
|
- logging_.start("clonedb", logging.DEBUG if verbose else logging.INFO, replace=True)
|
|
|
|
|
-
|
|
|
|
|
- if LOCKFILE.exists():
|
|
|
|
|
- logger.critical("Une opération de clonage est déjà en cours. "
|
|
|
|
|
- "veuillez patienter ou annuler le traitement existant")
|
|
|
|
|
- sys.exit(1)
|
|
|
|
|
|
|
+ # parse CLI arguments
|
|
|
|
|
+ arguments = docopt(__doc__, help=__doc__, version=__VERSION__)
|
|
|
|
|
+ prompt = "--yes" in arguments
|
|
|
|
|
+ dbname = arguments.get('dbname', None)
|
|
|
|
|
|
|
|
- logger.debug(f"Arguments given: {arguments}")
|
|
|
|
|
|
|
+ print('\n')
|
|
|
|
|
+ logger.info("Start db cloning utility...")
|
|
|
|
|
+ logger.debug(f"Arguments: {arguments}")
|
|
|
logger.debug(f"Settings: {SETTINGS}")
|
|
logger.debug(f"Settings: {SETTINGS}")
|
|
|
- if verbose:
|
|
|
|
|
- logger.debug("Mode: Verbose")
|
|
|
|
|
-
|
|
|
|
|
- remote_server = MySqlServer(**SETTINGS['remote'])
|
|
|
|
|
- remote_server.connect()
|
|
|
|
|
|
|
|
|
|
- local_server = MySqlServer(**SETTINGS['local'])
|
|
|
|
|
- local_server.connect()
|
|
|
|
|
-
|
|
|
|
|
- if arguments['dbname']:
|
|
|
|
|
- dbnames = [arguments['dbname']]
|
|
|
|
|
|
|
+ # database to clone
|
|
|
|
|
+ if arguments.get('<dbname>', None):
|
|
|
|
|
+ dbnames = [arguments['<dbname>']]
|
|
|
else:
|
|
else:
|
|
|
- dbnames = [db for db in SETTINGS['databases']]
|
|
|
|
|
-
|
|
|
|
|
- # Demande confirmation
|
|
|
|
|
- if not '--yes' in arguments:
|
|
|
|
|
- logger.debug('Ask for confirmation...')
|
|
|
|
|
- answer = ""
|
|
|
|
|
- msg = f"Les bases de données suivantes vont être clonées depuis " \
|
|
|
|
|
- f"'{remote_server} vers '{local_server}':\n' " \
|
|
|
|
|
- f"> {', '.join(dbnames)} \n" \
|
|
|
|
|
- "ATTENTION: Les bases existantes seront remplacées.\n" \
|
|
|
|
|
- "Voulez vous continuer? (oui/non)"
|
|
|
|
|
- while 1:
|
|
|
|
|
- answer = input(msg)
|
|
|
|
|
- if answer in ('oui', 'yes', 'y', 'o'):
|
|
|
|
|
- logger.debug(f"> user confirmed by answering '{answer}'")
|
|
|
|
|
- break
|
|
|
|
|
- elif answer in ('non', 'no', 'n'):
|
|
|
|
|
- logger.info("-- Opération annulée par l'utilisateur --")
|
|
|
|
|
- sys.exit(1)
|
|
|
|
|
- else:
|
|
|
|
|
- msg = "La réponse n'a pas été comprise. Voulez vous continuer? (oui/non)"
|
|
|
|
|
-
|
|
|
|
|
- # check for databases existence
|
|
|
|
|
- missing_db = [dbname for dbname in dbnames if not remote_server.db_exists(dbname)]
|
|
|
|
|
- if missing_db:
|
|
|
|
|
- for missing in missing_db:
|
|
|
|
|
- logger.critical(
|
|
|
|
|
- f"<!> Aucune base de donnée nommée '{missing}' trouvée sur {remote_server}")
|
|
|
|
|
- logger.critical("-- Opération annulée --")
|
|
|
|
|
- sys.exit(1)
|
|
|
|
|
|
|
+ dbnames = list(SETTINGS['databases'])
|
|
|
|
|
|
|
|
- # start to clone
|
|
|
|
|
- try:
|
|
|
|
|
- LOCKFILE.touch()
|
|
|
|
|
- for dbname in dbnames:
|
|
|
|
|
- clonedb(remote_server, local_server, dbname)
|
|
|
|
|
-
|
|
|
|
|
- logger.info("-- Clonage des bases de données terminé --")
|
|
|
|
|
- finally:
|
|
|
|
|
- LOCKFILE.remove()
|
|
|
|
|
|
|
+ with Lockfile(path=HERE / '.clonedb.lock',
|
|
|
|
|
+ on_error=lambda: logger.critical("A cloning process is already running, please wait...")):
|
|
|
|
|
+ main(dbnames, prompt)
|