Skip to content

Commit 68aed4f

Browse files
committed
added postgres_dumper
1 parent 421046e commit 68aed4f

File tree

3 files changed

+155
-1
lines changed

3 files changed

+155
-1
lines changed

Diff for: orthanc_tools/postgres_dumper.py

+150
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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()

Diff for: release-notes.md

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
v 0.16.2
2+
========
3+
- added `PostgresDumper` to dump a postgres db and write the dump on an sftp server.
4+
15
v 0.16.1
26
========
37
- `OrthancFolderImporter`: allow working without saving the state in a file

Diff for: setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
# For a discussion on single-sourcing the version across setup.py and the
2929
# project code, see
3030
# https://packaging.python.org/guides/single-sourcing-package-version/
31-
version='0.16.1', # Required
31+
version='0.16.2', # Required
3232

3333
# This is a one-line description or tagline of what your project does. This
3434
# corresponds to the "Summary" metadata field:

0 commit comments

Comments
 (0)