First commit
This commit is contained in:
commit
f056eebbd4
2 changed files with 255 additions and 0 deletions
38
README.md
Normal file
38
README.md
Normal file
|
@ -0,0 +1,38 @@
|
|||
# pg maintenance
|
||||
|
||||
Minimalist periodic maintenance script for PostgreSQL.
|
||||
|
||||
|
||||
## Disclaimer and licensing
|
||||
|
||||
This software has been created for personal purposes and may therefore not suit your needs.
|
||||
However, you may use it under the terms of the [MIT License][mit_license].
|
||||
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3
|
||||
- psql / pg_dump
|
||||
- rsync
|
||||
|
||||
|
||||
## Actions
|
||||
|
||||
This runs [VACUUM][doc_vacuum] and [ANALYZE][doc_analyze] on a specified database.
|
||||
If at least one recipient is specified, it also builds a reports and email it to said recipients.
|
||||
|
||||
To know more about routine vacuuming, please read [PostgreSQL's documentation][doc_why_vacuum].
|
||||
|
||||
|
||||
## Basic usage
|
||||
|
||||
```
|
||||
pg_maintenance.py --help
|
||||
pg_maintenance.py "db_name" --destination "user@remote:/your/backup/path" --email-recipient "admin@exemple.org"
|
||||
```
|
||||
|
||||
|
||||
[mit_license]: https://opensource.org/license/mit
|
||||
[doc_why_vacuum]: https://www.postgresql.org/docs/current/routine-vacuuming.html
|
||||
[doc_vacuum]: https://www.postgresql.org/docs/current/sql-vacuum.html
|
||||
[doc_analyze]: https://www.postgresql.org/docs/current/sql-analyze.html
|
217
pg_maintenance.py
Executable file
217
pg_maintenance.py
Executable file
|
@ -0,0 +1,217 @@
|
|||
#!/usr/bin/env python3
|
||||
#
|
||||
# Copyright 2024 Rodolphe Bréard <rodolphe@what.tf>
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in all
|
||||
# copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import smtplib
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
from email.mime.application import MIMEApplication
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import COMMASPACE, formatdate
|
||||
|
||||
DEFAULT_EMAIL_HOST = "localhost"
|
||||
DEFAULT_EMAIL_PORT = 25
|
||||
DEFAULT_EMAIL_REPORT_SENDER = "PostgreSQL maintenance script <noreply@example.org>"
|
||||
DEFAULT_PG_ADMIN_USER = "postgres"
|
||||
|
||||
|
||||
def init_report(args):
|
||||
if not args.email_recipient:
|
||||
return None
|
||||
report = MIMEMultipart("alternative")
|
||||
report["Subject"] = f"Database maintenance for `{args.db_name}`"
|
||||
report["From"] = args.email_sender
|
||||
report["To"] = COMMASPACE.join(args.email_recipient)
|
||||
report["Date"] = formatdate(localtime=True)
|
||||
report["User-Agent"] = "PostgreSQL maintenance script"
|
||||
report["Content-Language"] = "en-US"
|
||||
return report
|
||||
|
||||
|
||||
def send_report(args, vacuum, analyze, backup_create, backup_upload):
|
||||
report = init_report(args)
|
||||
if report is None:
|
||||
return
|
||||
vacuum_report = get_cmd_report("Vacuum", vacuum)
|
||||
analyze_report = get_cmd_report("Analyze", analyze)
|
||||
backup_report = get_backup_report(backup_create, backup_upload)
|
||||
report_txt = f"""Database maintenance report for `{args.db_name}`.
|
||||
|
||||
{vacuum_report}
|
||||
{analyze_report}
|
||||
{backup_report}
|
||||
"""
|
||||
report.attach(MIMEText(report_txt))
|
||||
if vacuum.stdout:
|
||||
attach_file(report, "vacuum.log", vacuum.stdout)
|
||||
if vacuum.stderr:
|
||||
attach_file(report, "vacuum.err.log", vacuum.stderr)
|
||||
if analyze.stdout:
|
||||
attach_file(report, "analyze.log", analyze.stdout)
|
||||
if analyze.stderr:
|
||||
attach_file(report, "analyze.err.log", analyze.stderr)
|
||||
with smtplib.SMTP(host=args.email_host, port=args.email_port) as smtp:
|
||||
smtp.send_message(report)
|
||||
|
||||
|
||||
def attach_file(report, file_name, file_content):
|
||||
part = MIMEApplication(file_content)
|
||||
part["Content-Disposition"] = f'attachment; filename="{file_name}"'
|
||||
report.attach(part)
|
||||
|
||||
|
||||
def run_sql_cmd(args, cmd):
|
||||
cmd = [
|
||||
"psql",
|
||||
"--quiet",
|
||||
"--echo-queries",
|
||||
"--username",
|
||||
args.db_admin_user,
|
||||
"--dbname",
|
||||
args.db_name,
|
||||
"--command",
|
||||
cmd,
|
||||
]
|
||||
start = time.time()
|
||||
ret = subprocess.run(cmd, capture_output=True)
|
||||
end = time.time()
|
||||
ret.elapsed_time = round(end - start, 3)
|
||||
return ret
|
||||
|
||||
|
||||
def create_backup(args):
|
||||
if not args.destination:
|
||||
return None
|
||||
output_file = tempfile.NamedTemporaryFile()
|
||||
cmd = [
|
||||
"pg_dump",
|
||||
"--create",
|
||||
"--clean",
|
||||
"--no-unlogged-table-data",
|
||||
"--compress",
|
||||
"zstd",
|
||||
"--file",
|
||||
output_file.name,
|
||||
"--username",
|
||||
args.db_admin_user,
|
||||
"--dbname",
|
||||
args.db_name,
|
||||
]
|
||||
start = time.time()
|
||||
ret = subprocess.run(cmd, capture_output=True)
|
||||
end = time.time()
|
||||
ret.elapsed_time = round(end - start, 3)
|
||||
ret.file_size = os.path.getsize(output_file.name)
|
||||
ret.file = output_file
|
||||
return ret
|
||||
|
||||
|
||||
def upload_backup(args, backup_create):
|
||||
if not args.destination:
|
||||
return None
|
||||
timestamp = datetime.today().strftime("%Y-%m-%d")
|
||||
file_name = f"{timestamp}_{args.db_name}.sql.zst"
|
||||
cmd = ["rsync", backup_create.file.name, os.path.join(args.destination, file_name)]
|
||||
start = time.time()
|
||||
ret = subprocess.run(cmd, capture_output=True)
|
||||
end = time.time()
|
||||
ret.elapsed_time = round(end - start, 3)
|
||||
return ret
|
||||
|
||||
|
||||
def get_cmd_report(name, cmd_ret):
|
||||
if cmd_ret.returncode == 0:
|
||||
return f"{name} completed in {cmd_ret.elapsed_time} s."
|
||||
else:
|
||||
return f"""{name} failed:
|
||||
- error code: {cmd_ret.returncode}
|
||||
- elapsed time: {cmd_ret.elapsed_time} s"""
|
||||
|
||||
|
||||
def get_backup_report(backup_create, backup_upload):
|
||||
if not args.destination:
|
||||
return "No backup upload destination found."
|
||||
if backup_create.returncode != 0:
|
||||
return f"""Backup creation failed:
|
||||
- error code: {backup_create.returncode}
|
||||
- elapsed time: {backup_create.elapsed_time}s"""
|
||||
ret = f"""Backup creation completed:
|
||||
- file size: {backup_create.file_size} B
|
||||
- elapsed time: {backup_create.elapsed_time} s
|
||||
"""
|
||||
if backup_upload.returncode != 0:
|
||||
ret += """Backup upload failed:
|
||||
- error code: {backup_upload.returncode}
|
||||
- message: {backup_upload.stderr}"""
|
||||
else:
|
||||
ret += f"Backup uploaded in {backup_upload.elapsed_time} s."
|
||||
return ret
|
||||
|
||||
|
||||
def daily_db_maintenance(args):
|
||||
vacuum = run_sql_cmd(args, "VACUUM VERBOSE")
|
||||
analyze = run_sql_cmd(args, "ANALYZE VERBOSE")
|
||||
backup_create = create_backup(args)
|
||||
backup_upload = upload_backup(args, backup_create)
|
||||
send_report(args, vacuum, analyze, backup_create, backup_upload)
|
||||
|
||||
|
||||
def get_args():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("db_name", help="name of the database to run maintenance on")
|
||||
parser.add_argument(
|
||||
"-u",
|
||||
"--db-admin-user",
|
||||
default=DEFAULT_PG_ADMIN_USER,
|
||||
help="name of the PostgreSQL superuser",
|
||||
)
|
||||
parser.add_argument("-d", "--destination", help="backup upload destination")
|
||||
parser.add_argument(
|
||||
"--email-host", default=DEFAULT_EMAIL_HOST, help="hostname of the email server"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--email-port",
|
||||
type=int,
|
||||
default=DEFAULT_EMAIL_PORT,
|
||||
help="email server submission port",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--email-sender",
|
||||
default=DEFAULT_EMAIL_REPORT_SENDER,
|
||||
help="sender of the email report",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--email-recipient",
|
||||
nargs="*",
|
||||
help="recipient of the email report (may be specified multi times)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = get_args()
|
||||
daily_db_maintenance(args)
|
Loading…
Reference in a new issue