|
| 1 | +import sys |
| 2 | +import datetime |
| 3 | +import paramiko |
| 4 | +import time, os |
| 5 | +import argparse |
| 6 | +import logging |
| 7 | +import schedule |
| 8 | +import subprocess |
| 9 | +logger = logging.getLogger(__name__) |
| 10 | + |
| 11 | +class PostgresDumper: |
| 12 | + """ |
| 13 | + Runs every day to create a gzip compressed dump of the postgres DB and write it to the destination (currently, only sftp) |
| 14 | +
|
| 15 | + Warning: |
| 16 | + `postgresql-client` has to be installed before the execution of this script |
| 17 | +
|
| 18 | + To restore: |
| 19 | + pg_restore --clean -U postgres -h 172.21.0.3 -p 5432 -d postgres Friday |
| 20 | + """ |
| 21 | + def __init__(self, pg_host: str, pg_port: str, pg_db_name: str, pg_user_name: str, pg_password, |
| 22 | + execution_time: str, |
| 23 | + sftp_host: int, sftp_port: str, sftp_user_name: str, sftp_password: str, sftp_folder_path: str |
| 24 | + ): |
| 25 | + |
| 26 | + self.sftp_folder_path = sftp_folder_path |
| 27 | + # remove last char if this is a slash |
| 28 | + if self.sftp_folder_path[-1:] == '/': |
| 29 | + self.sftp_folder_path = self.sftp_folder_path[:-1] |
| 30 | + self.sftp_password = sftp_password |
| 31 | + self.sftp_user_name = sftp_user_name |
| 32 | + self.sftp_port = sftp_port |
| 33 | + self.sftp_host = sftp_host |
| 34 | + self.execution_time = execution_time |
| 35 | + self.pg_password = pg_password |
| 36 | + self.pg_user_name = pg_user_name |
| 37 | + self.pg_db_name = pg_db_name |
| 38 | + self.pg_host = pg_host |
| 39 | + self.pg_port = pg_port |
| 40 | + |
| 41 | + def stream_pg_dump_to_sftp(self): |
| 42 | + try: |
| 43 | + # Establish SFTP connection |
| 44 | + transport = paramiko.Transport((self.sftp_host, self.sftp_port)) |
| 45 | + transport.connect(username=self.sftp_user_name, password=self.sftp_password) |
| 46 | + sftp = paramiko.SFTPClient.from_transport(transport) |
| 47 | + |
| 48 | + # Build full file path: we use the name fo the day, so that only 7 files are kept and there is no need |
| 49 | + # to clean up ourselves (files are overwritten the next week) |
| 50 | + sftp_file_path = f"{self.sftp_folder_path}/{datetime.date.today().strftime('%A')}.gzip" |
| 51 | + |
| 52 | + # Open a remote file for writing |
| 53 | + # TODO: given that the 'sftp.open()' works the same way as a regular file, we could make this script working for both cases |
| 54 | + with sftp.open(sftp_file_path, "wb") as remote_file: |
| 55 | + # Run pg_dump and capture output |
| 56 | + # Let's be honest, ChatGPT helped a lot on this ;-) |
| 57 | + process = subprocess.Popen( |
| 58 | + ["pg_dump", "-U", self.pg_user_name, "-h", self.pg_host, "-p", self.pg_port, "-Fc", self.pg_db_name], |
| 59 | + stdout=subprocess.PIPE, |
| 60 | + stderr=subprocess.PIPE, |
| 61 | + env={"PGPASSWORD": self.pg_password} |
| 62 | + ) |
| 63 | + |
| 64 | + # Compress the dump (the 'Fc' parameter of the pg_dump command is not very efficient) |
| 65 | + gzip_process = subprocess.Popen(["gzip"], stdin=process.stdout, stdout=subprocess.PIPE) |
| 66 | + |
| 67 | + # Stream gzip output directly to SFTP |
| 68 | + for chunk in iter(lambda: gzip_process.stdout.read(4096), b""): |
| 69 | + remote_file.write(chunk) |
| 70 | + |
| 71 | + # Ensure the process completes |
| 72 | + process.stdout.close() |
| 73 | + process.wait() |
| 74 | + |
| 75 | + # Check for errors |
| 76 | + if process.returncode != 0: |
| 77 | + error_message = process.stderr.read().decode() |
| 78 | + logger.error(f"pg_dump failed: {error_message}") |
| 79 | + |
| 80 | + logger.info(f"Backup successfully uploaded to {self.sftp_folder_path}") |
| 81 | + |
| 82 | + except Exception as e: |
| 83 | + logger.error(f"Error: {e}") |
| 84 | + sys.exit(-1) |
| 85 | + |
| 86 | + finally: |
| 87 | + sftp.close() |
| 88 | + transport.close() |
| 89 | + |
| 90 | + |
| 91 | + def execute(self): |
| 92 | + logger.info("----- Initializing Postgres Dumper...") |
| 93 | + |
| 94 | + # Check if postgresql-client is installed |
| 95 | + try: |
| 96 | + result = subprocess.run(["pg_dump", "--version"], capture_output=True, text=True, check=True) |
| 97 | + logger.info(f"pg_dump version: {result.stdout.strip()}") |
| 98 | + except FileNotFoundError: |
| 99 | + logger.error("it seems that pg_dump is NOT installed, plase install it before running this script (apt install postgresql-client)") |
| 100 | + sys.exit(-1) |
| 101 | + |
| 102 | + if self.execution_time is None: |
| 103 | + # unit test case |
| 104 | + self.stream_pg_dump_to_sftp() |
| 105 | + else: |
| 106 | + # regular (prod) case |
| 107 | + schedule.every().day.at(self.execution_time).do(self.stream_pg_dump_to_sftp) |
| 108 | + while True: |
| 109 | + schedule.run_pending() |
| 110 | + time.sleep(1) |
| 111 | + |
| 112 | + |
| 113 | +if __name__ == '__main__': |
| 114 | + level = logging.INFO |
| 115 | + if os.environ.get('VERBOSE_ENABLED'): |
| 116 | + level = logging.DEBUG |
| 117 | + logging.basicConfig(level=level, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') |
| 118 | + |
| 119 | + parser = argparse.ArgumentParser(description='Periodically dumps Postgres DB to an SFTP server.') |
| 120 | + parser.add_argument('--pg_host', type=str, default='http://orthanc-db', help='Postgres hostname') |
| 121 | + parser.add_argument('--pg_port', type=str, default='5432', help='Postgres port number') |
| 122 | + parser.add_argument('--pg_db_name', type=str, default='postgres', help='Postgres database name') |
| 123 | + parser.add_argument('--pg_user_name', type=str, default='postgres', help='Postgres username') |
| 124 | + parser.add_argument('--pg_password', type=str, default='', help='Postgres password') |
| 125 | + parser.add_argument('--execution_time', type=str, default='01:30', help='Time for script execution (format: 23:30 or 23:30:14)') |
| 126 | + parser.add_argument('--sftp_host', type=str, default=None, help='sFTP server hostname') |
| 127 | + parser.add_argument('--sftp_port', type=str, default='22', help='sFTP server port number') |
| 128 | + parser.add_argument('--sftp_user_name', type=str, default=None, help='sFTP server user name') |
| 129 | + parser.add_argument('--sftp_password', type=str, default=None, help='sFTP server password') |
| 130 | + parser.add_argument('--sftp_folder_path', type=str, default=None, help='sFTP server folder path') |
| 131 | + args = parser.parse_args() |
| 132 | + |
| 133 | + pg_host = os.environ.get("PG_HOST", args.pg_host) |
| 134 | + pg_port = os.environ.get("PG_PORT", args.pg_port) |
| 135 | + pg_db_name = os.environ.get("PG_DB_NAME", args.pg_db_name) |
| 136 | + pg_user_name = os.environ.get("PG_USER_NAME", args.pg_user_name) |
| 137 | + pg_password = os.environ.get("PG_PASSWORD", args.pg_password) |
| 138 | + execution_time = os.environ.get("EXECUTION_TIME", args.execution_time) |
| 139 | + if execution_time is '': |
| 140 | + execution_time = None |
| 141 | + sftp_host = os.environ.get("SFTP_HOST", args.sftp_host) |
| 142 | + sftp_port = int(os.environ.get("SFTP_PORT", args.sftp_port)) |
| 143 | + sftp_user_name = os.environ.get("SFTP_USER_NAME", args.sftp_user_name) |
| 144 | + sftp_password = os.environ.get("SFTP_PASSWORD", args.sftp_password) |
| 145 | + sftp_folder_path = os.environ.get("SFTP_FOLDER_PATH", args.sftp_folder_path) |
| 146 | + |
| 147 | + dumper = PostgresDumper(pg_host, pg_port, pg_db_name, pg_user_name, pg_password, execution_time, |
| 148 | + sftp_host, sftp_port, sftp_user_name, sftp_password, sftp_folder_path) |
| 149 | + |
| 150 | + dumper.execute() |
0 commit comments