diff --git a/.dockerignore b/.dockerignore index 4d1f772..5ffe93c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -52,6 +52,7 @@ dist/ Dockerfile docker-compose.yml .dockerignore +.github/workflows/ # Dokumentationsverzeichnisse docs/ diff --git a/README.md b/README.md index 9a0b810..42af714 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 📦 Paperless Backup -An automated Docker-based backup system for [Paperless-ngx](https://github.com/paperless-ngx/paperless-ngx) with email notifications and intelligent retention policies. +An automated Docker-based backup system for [Paperless-ngx](https://github.com/paperless-ngx/paperless-ngx) with email notifications and retention policies. ![Docker](https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white) ![Python](https://img.shields.io/badge/Python-3.14-3776ab?style=for-the-badge&logo=python&logoColor=white) @@ -40,8 +40,10 @@ services: PAPERLESS_EXPORT_DIR: '../export' BACKUP_DIR: '/backups' EXPORT_DIR: '/export' + BACKUP_PREFIX: 'backup' BACKUP_SCHEDULE: "0 2 * * *" # Optional: Daily at 02:00 KEEP_BACKUPS: 7 + BACKUP_ON_STARTUP: False # Optional: SMTP Configuration # SMTP_SERVER: smtp.example.com @@ -68,8 +70,10 @@ services: | `PAPERLESS_CONTAINER_NAME` | `paperless` | Name of the Paperless Docker container | | `PAPERLESS_EXPORT_DIR` | `../export` | Export directory inside Paperless container | | `BACKUP_DIR` | `/backup` | Target directory for backups | +| `BACKUP_PREFIX` | `backup` | Prefix of the backup files | | `EXPORT_DIR` | `/export` | Local export directory | | `KEEP_BACKUPS` | `7` | Number of backup versions to keep | +| `BACKUP_ON_STARTUP` | `False` | Run a backup on startup | ### Optional: SMTP Configuration @@ -115,7 +119,7 @@ This project is licensed under the [MIT License](LICENSE). ## 📞 Support -- 🐛 [Issues](https://github.com/yourusername/paperless-backup/issues) +- 🐛 [Issues](https://github.com/rossberi/paperless-backup/issues) ## 📚 Resources diff --git a/app/backup.py b/app/backup.py index 28cece9..c2d61a4 100644 --- a/app/backup.py +++ b/app/backup.py @@ -5,10 +5,10 @@ from app.log import log_msg -def export_paperless(container_name: str, paperless_export_dir: str, timestamp: str): +def export_paperless(container_name: str, paperless_export_dir: str, timestamp: str, backup_prefix: str): client = docker.from_env() paperless = client.containers.get(container_name) - export_cmd = f"document_exporter -z -zn backup_{timestamp} {paperless_export_dir}" + export_cmd = f"document_exporter -z -zn {backup_prefix}_{timestamp} {paperless_export_dir}" exec_result = paperless.exec_run(export_cmd, stdout=True, stderr=True) log_output = exec_result.output.decode("utf-8") log_msg("") @@ -22,23 +22,23 @@ def export_paperless(container_name: str, paperless_export_dir: str, timestamp: if exec_result.exit_code != 0: raise Exception(f"ERROR - Failed to export documents. Exit-Code: {exec_result.exit_code}") -def copy_exported_zip(timestamp: str, paperless_export_dir: str, paperless_backup_dir: str): - exported_zip = f'{paperless_export_dir}/backup_{timestamp}.zip' +def copy_exported_zip(timestamp: str, paperless_export_dir: str, paperless_backup_dir: str, backup_prefix: str): + exported_zip = f'{paperless_export_dir}/{backup_prefix}_{timestamp}.zip' if not os.path.exists(exported_zip): raise FileNotFoundError(f"ERROR - Export failed – File not found: {exported_zip}") - shutil.move(exported_zip, f'{paperless_backup_dir}/backup_{timestamp}.zip') + shutil.move(exported_zip, f'{paperless_backup_dir}/{backup_prefix}_{timestamp}.zip') - file_size = round((os.path.getsize(f'{paperless_backup_dir}/backup_{timestamp}.zip') / 1024 / 1024 / 1024), 2) + file_size = round((os.path.getsize(f'{paperless_backup_dir}/{backup_prefix}_{timestamp}.zip') / 1024 / 1024 / 1024), 2) - log_msg(f"Created backup: backup_{timestamp}.zip | Size: {file_size} GB") + log_msg(f"Created backup: {backup_prefix}_{timestamp}.zip | Size: {file_size} GB") log_msg("") -def start_backup(container_name: str, paperless_export_dir: str,backup_dir: str): +def start_backup(container_name: str, paperless_export_dir: str,backup_dir: str, backup_prefix: str): timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") log_msg("Paperless export started ...") - export_paperless(container_name, paperless_export_dir, timestamp) + export_paperless(container_name, paperless_export_dir, timestamp, backup_prefix) log_msg("Copying exported zip ...") - copy_exported_zip(timestamp, paperless_export_dir, backup_dir) \ No newline at end of file + copy_exported_zip(timestamp, paperless_export_dir, backup_dir, backup_prefix) \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..a84e215 --- /dev/null +++ b/app/config.py @@ -0,0 +1,37 @@ +import os + +class Config: + # Paperless + PAPERLESS_CONTAINER_NAME = os.getenv("PAPERLESS_CONTAINER_NAME", "paperless") + PAPERLESS_EXPORT_DIR = os.getenv("PAPERLESS_EXPORT_DIR", "../export") + + # Backup + BACKUP_DIR: str = os.getenv("BACKUP_DIR", "/backup") + EXPORT_DIR: str = os.getenv("EXPORT_DIR", "/export") + KEEP_BACKUPS: int = int(os.getenv("KEEP_BACKUPS", "7")) + BACKUP_PREFIX: str = os.getenv("BACKUP_PREFIX", "backup") + BACKUP_ON_STARTUP: str = os.getenv("BACKUP_ON_STARTUP", "False") + + # SMTP + SMTP_SERVER: str = os.getenv("SMTP_SERVER") + SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587")) + SMTP_USERNAME: str = os.getenv("SMTP_USERNAME") + SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD") + SMTP_SENDER: str = os.getenv("SMTP_SENDER") + SMTP_RECIPIENT: str = os.getenv("SMTP_RECIPIENT") + SMTP_SUBJECT_SUCCESS: str = os.getenv("SMTP_SUBJECT_SUCCESS", "Paperless backup successful") + SMTP_SUBJECT_FAILURE: str = os.getenv("SMTP_SUBJECT_FAILURE", "Paperless backup failed") + SMTP_SECURITY: str = os.getenv("SMTP_SECURITY", "starttls") + + @staticmethod + def validate(): + """Validate required configuration""" + if not os.path.exists(Config.BACKUP_DIR): + raise FileNotFoundError(f"ERROR - Backup directory not found: {Config.BACKUP_DIR}") + + if not os.path.exists(Config.EXPORT_DIR): + raise FileNotFoundError(f"ERROR - Paperless export directory not found: {Config.EXPORT_DIR}") + + if Config.SMTP_SERVER is not None: + if not all([Config.SMTP_USERNAME, Config.SMTP_PASSWORD, Config.SMTP_SENDER, Config.SMTP_RECIPIENT]): + raise ValueError("ERROR - SMTP configuration is incomplete. Please set SMTP_USERNAME, SMTP_PASSWORD, SMTP_SENDER, and SMTP_RECIPIENT environment variables.") \ No newline at end of file diff --git a/app/mail.py b/app/mail.py index c8146f0..7323d65 100644 --- a/app/mail.py +++ b/app/mail.py @@ -1,6 +1,8 @@ import smtplib +import socket from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart +from app.log import log_msg def send_mail( @@ -14,30 +16,64 @@ def send_mail( body, security="starttls", # "ssl", "starttls", "plain" ): - msg = MIMEMultipart() - msg["From"] = sender - msg["To"] = recipient - msg["Subject"] = subject - msg.attach(MIMEText(body, "plain")) - - # SSL / TLS - if security == "ssl": - with smtplib.SMTP_SSL(smtp_server, smtp_port) as server: - server.login(username, password) - server.send_message(msg) - - elif security == "starttls": - with smtplib.SMTP(smtp_server, smtp_port) as server: - server.ehlo() - server.starttls() - server.ehlo() - server.login(username, password) - server.send_message(msg) - - elif security == "plain": - with smtplib.SMTP(smtp_server, smtp_port) as server: - server.login(username, password) - server.send_message(msg) - - else: - raise ValueError(f"Unknown security option: {security}") + + # Validate security option + if security not in ["ssl", "starttls", "plain"]: + log_msg(f"ERROR - Unknown SMTP security option: {security}") + return False + + try: + # Build email message + msg = MIMEMultipart() + msg["From"] = sender + msg["To"] = recipient + msg["Subject"] = subject + msg.attach(MIMEText(body, "plain")) + + log_msg(f"Sending email to {recipient} via {smtp_server}:{smtp_port} ({security})...") + + # Send email based on security type + if security == "ssl": + with smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=10) as server: + server.login(username, password) + server.send_message(msg) + + elif security == "starttls": + with smtplib.SMTP(smtp_server, smtp_port, timeout=10) as server: + server.ehlo() + server.starttls() + server.ehlo() + server.login(username, password) + server.send_message(msg) + + elif security == "plain": + with smtplib.SMTP(smtp_server, smtp_port, timeout=10) as server: + server.login(username, password) + server.send_message(msg) + + log_msg("Email sent successfully") + return True + + except smtplib.SMTPAuthenticationError as e: + log_msg(f"ERROR - SMTP authentication failed: {str(e)}") + return False + + except smtplib.SMTPException as e: + log_msg(f"ERROR - SMTP error occurred: {str(e)}") + return False + + except socket.timeout: + log_msg(f"ERROR - SMTP connection timeout ({smtp_server}:{smtp_port})") + return False + + except socket.gaierror: + log_msg(f"ERROR - Could not resolve SMTP server: {smtp_server}") + return False + + except ConnectionRefusedError: + log_msg(f"ERROR - SMTP server refused connection: {smtp_server}:{smtp_port}") + return False + + except Exception as e: + log_msg(f"ERROR - Unexpected error while sending email: {type(e).__name__}: {str(e)}") + return False \ No newline at end of file diff --git a/app/retentionpolicy.py b/app/retentionpolicy.py index 6ca13b5..9fd4582 100644 --- a/app/retentionpolicy.py +++ b/app/retentionpolicy.py @@ -1,9 +1,9 @@ import os from app.log import log_msg -def retention_policy(keep_backups: int, backup_dir: str): +def retention_policy(keep_backups: int, backup_dir: str, backup_prefix: str): if keep_backups > 0: - backups = [f for f in os.listdir(backup_dir) if f.startswith("backup_") and f.endswith(".zip")] + backups = [f for f in os.listdir(backup_dir) if f.startswith(f"{backup_prefix}_") and f.endswith(".zip")] backups.sort() if len(backups) > keep_backups: log_msg(f"Delete old backups > {keep_backups} versions ...") diff --git a/dockerfile b/dockerfile index 08695f6..ad15344 100644 --- a/dockerfile +++ b/dockerfile @@ -11,5 +11,11 @@ RUN apk add --no-cache dcron \ COPY . . +RUN chmod +x /code/start.sh \ + && chmod +x /code/healthcheck.py + +HEALTHCHECK --interval=60s --timeout=10s --start-period=30s --retries=3 \ + CMD python3 /code/healthcheck.py + RUN chmod +x /code/start.sh CMD ["/code/start.sh"] \ No newline at end of file diff --git a/healthcheck.py b/healthcheck.py new file mode 100644 index 0000000..059895e --- /dev/null +++ b/healthcheck.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Simple health check for Paperless Backup container.""" + +import os +import sys + +def main(): + # Check backup directory + backup_dir = os.getenv("BACKUP_DIR", "/backup") + if not os.path.exists(backup_dir) or not os.access(backup_dir, os.W_OK): + print(f"ERROR - Backup directory not accessible: {backup_dir}") + return 1 + + # Check export directory + export_dir = os.getenv("EXPORT_DIR", "/export") + if not os.path.exists(export_dir) or not os.access(export_dir, os.R_OK): + print(f"ERROR - Export directory not accessible: {export_dir}") + return 1 + + # Check Docker socket + if not os.path.exists("/var/run/docker.sock"): + print("ERROR - Docker socket not found") + return 1 + + print("OK - Health check passed") + return 0 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/main.py b/main.py index 73bbe8c..1d7a97d 100644 --- a/main.py +++ b/main.py @@ -4,61 +4,38 @@ from app.retentionpolicy import retention_policy from app.mail import send_mail from app.log import log_msg, log +from app.config import Config success = True - -#environment variables -container_name = os.getenv("PAPERLESS_CONTAINER_NAME", "paperless") -paperless_export_dir = os.getenv("PAPERLESS_EXPORT_DIR", "../export") -keep_backups = int(os.getenv("KEEP_BACKUPS", "3")) -backup_dir = os.getenv("BACKUP_DIR", "/backup") -export_dir = os.getenv("EXPORT_DIR", "/export") - -smtp_server = os.getenv("SMTP_SERVER") -smtp_port = int(os.getenv("SMTP_PORT", "587")) -username = os.getenv("SMTP_USERNAME") -password = os.getenv("SMTP_PASSWORD") -sender = os.getenv("SMTP_SENDER") -recipient = os.getenv("SMTP_RECIPIENT") -subject_success = os.getenv("SMTP_SUBJECT_SUCCESS", "Paperless backup successful") -subject_failure = os.getenv("SMTP_SUBJECT_FAILURE", "Paperless backup failed") -security = os.getenv("SMTP_SECURITY", "starttls") - - -if smtp_server is not None: - if username is None or password is None or sender is None or recipient is None: - raise ValueError("ERROR - SMTP configuration is incomplete. Please set SMTP_USERNAME, SMTP_PASSWORD, SMTP_SENDER, and SMTP_RECIPIENT environment variables.") - -if not os.path.exists(backup_dir): - raise FileNotFoundError(f"ERROR - Backup directory not found: {backup_dir}") - -if not os.path.exists(export_dir): - raise FileNotFoundError(f"ERROR - Paperless export directory not found: {export_dir}") +Config.validate() try: #start backup process log_msg(f"Paperless Backup started ...") - start_backup(container_name, paperless_export_dir, backup_dir) + start_backup(container_name=Config.PAPERLESS_CONTAINER_NAME, paperless_export_dir=Config.PAPERLESS_EXPORT_DIR, backup_dir=Config.BACKUP_DIR, backup_prefix=Config.BACKUP_PREFIX) #start retention policy - retention_policy(keep_backups=keep_backups, backup_dir=backup_dir) + retention_policy(keep_backups=Config.KEEP_BACKUPS, backup_dir=Config.BACKUP_DIR, backup_prefix=Config.BACKUP_PREFIX) except Exception as e: log_msg(str(e)) success = False -subject = subject_success if success else subject_failure +# Send mail with log +subject = Config.SMTP_SUBJECT_SUCCESS if success else Config.SMTP_SUBJECT_FAILURE mail_body = "\n".join(log) -if smtp_server is not None: +if Config.SMTP_SERVER is not None: send_mail( - smtp_server, - smtp_port, - username, - password, - sender, - recipient, + Config.SMTP_SERVER, + Config.SMTP_PORT, + Config.SMTP_USERNAME, + Config.SMTP_PASSWORD, + Config.SMTP_SENDER, + Config.SMTP_RECIPIENT, subject, mail_body, - security, - ) \ No newline at end of file + Config.SMTP_SECURITY, + ) +else: + log_msg("No SMTP_SERVER configured, skipping email notification") \ No newline at end of file diff --git a/start.sh b/start.sh index ce811ea..cee1a0d 100644 --- a/start.sh +++ b/start.sh @@ -6,8 +6,13 @@ if [ -n "$BACKUP_SCHEDULE" ]; then printf "BACKUP_SCHEDULE detected: $BACKUP_SCHEDULE\n" printf "\n" + # Check if Backup_ON_STARTUP is set to run a backup on startup + if [ "$(echo "$BACKUP_ON_STARTUP" | tr '[:upper:]' '[:lower:]')" = "true" ]; then + /usr/local/bin/python3 /code/main.py + fi + # create crontab - echo "$BACKUP_SCHEDULE /usr/local/bin/python3 /code/main.py 2>&1" > /etc/crontabs/root + echo "$BACKUP_SCHEDULE /usr/local/bin/python3 /code/main.py >> /proc/1/fd/1 2>&1" > /etc/crontabs/root crond -f -L /dev/stdout else