diff --git a/ops/backup/.env.backup.template b/ops/backup/.env.backup.template new file mode 100644 index 0000000..29c7488 --- /dev/null +++ b/ops/backup/.env.backup.template @@ -0,0 +1,36 @@ +# -- PostgreSQL Configuration -- +PG_HOST=postgres-db +PG_PORT=5432 +PG_USER=backup_user +PG_PASSWORD=supersecretpassword +# Optional: Specify databases to back up, comma-separated. If empty, all databases will be backed up. +PG_DATABASES= + +# -- Redis Configuration -- +REDIS_HOST=redis +REDIS_PORT=6379 + +# -- File Backup Configuration -- +# Comma-separated list of absolute paths to back up +FILE_PATHS=/data/uploads,/data/config + +# -- Backup Storage and Retention -- +BACKUP_DIR=/backups +RETENTION_DAYS=7 + +# -- Scheduler Configuration -- +# Cron schedule for daemon mode +CRON_SCHEDULE="0 2 * * *" + +# -- Locking Configuration -- +# A unique integer for Postgres advisory lock +ADVISORY_LOCK_KEY=123456789 + +# -- Optional: GPG Encryption -- +GPG_ENABLED=false +GPG_RECIPIENT= + +# -- Optional: Telegram Notification -- +TELEGRAM_ENABLED=false +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= diff --git a/ops/backup/Dockerfile b/ops/backup/Dockerfile new file mode 100644 index 0000000..37f1daa --- /dev/null +++ b/ops/backup/Dockerfile @@ -0,0 +1,44 @@ +# /ops/backup/Dockerfile + +# --- Stage 1: Build Environment --- +FROM python:3.11-slim as builder + +WORKDIR /app + +# Install system dependencies, including cron +RUN apt-get update && apt-get install -y --no-install-recommends \ + postgresql-client \ + redis-tools \ + cron \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install python packages +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy source code +COPY src/ ./src +COPY entrypoint.sh . +RUN chmod +x entrypoint.sh + +# --- Stage 2: Final Image --- +FROM python:3.11-slim + +WORKDIR /app + +# Copy system utilities and cron from builder stage +COPY --from=builder /usr/bin/pg_dump /usr/bin/ +COPY --from=builder /usr/bin/psql /usr/bin/ +COPY --from=builder /usr/bin/redis-cli /usr/bin/ +COPY --from=builder /usr/sbin/cron /usr/sbin/ +COPY --from=builder /etc/cron.d /etc/cron.d + +# Copy installed Python packages +COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/ + +# Copy source code and entrypoint +COPY src/ ./src +COPY entrypoint.sh . + +# Set the entrypoint to our script +ENTRYPOINT ["./entrypoint.sh"] diff --git a/ops/backup/README-backup.md b/ops/backup/README-backup.md new file mode 100644 index 0000000..b6199e1 --- /dev/null +++ b/ops/backup/README-backup.md @@ -0,0 +1,95 @@ +# سرویس بکاپ داخلی MasirYar + +این مستندات به تشریح سرویس بکاپ داخلی برای پلتفرم MasirYar می‌پردازد. این سرویس به صورت یک کانتینر داکر اجرا شده و به طور منظم از داده‌های حیاتی سیستم (PostgreSQL, Redis, و فایل‌ها) بکاپ تهیه می‌کند. + +## 📜 نیازمندی‌ها + +- Docker و Docker Compose +- دسترسی به یک رجیستری داکر (برای build و push ایمیج) +- ابزارهای `make` (اختیاری، برای سهولت در اجرای دستورات) + +## ⚙️ پیکربندی + +تمام پیکربندی‌ها از طریق متغیرهای محیطی انجام می‌شود. یک فایل نمونه به نام `.env.backup.template` در همین پوشه قرار دارد. برای اجرای سرویس، یک کپی از این فایل با نام `.env` ایجاد کرده و مقادیر آن را مطابق با محیط خود تنظیم کنید. + +| متغیر | توضیحات | مقدار پیش‌فرض | +| ------------------- | ------------------------------------------------------------------------ | ------------------ | +| `PG_HOST` | آدرس سرویس PostgreSQL. | `postgres-db` | +| `PG_USER` | نام کاربری برای اتصال به دیتابیس. | `backup_user` | +| `PG_PASSWORD` | رمز عبور کاربر دیتابیس. | - | +| `REDIS_HOST` | آدرس سرویس Redis. | `redis` | +| `FILE_PATHS` | لیستی از مسیرهای مطلق فایل‌ها برای بکاپ، جدا شده با کاما. | - | +| `BACKUP_DIR` | مسیر داخل کانتینر که بکاپ‌ها در آن ذخیره می‌شوند. | `/backups` | +| `RETENTION_DAYS` | تعداد روزهایی که بکاپ‌ها باید نگهداری شوند. | `7` | +| `CRON_SCHEDULE` | زمان‌بندی اجرای بکاپ در فرمت Cron. | `0 2 * * *` (2 بامداد) | +| `ADVISORY_LOCK_KEY` | یک کلید عددی منحصر به فرد برای قفل در PostgreSQL. | `123456789` | + +## 🚀 نحوه اجرا + +### اجرای مستقل برای تست + +یک فایل `docker-compose.backup.yml` برای اجرای سرویس به صورت ایزوله و برای تست فراهم شده است. + +```bash +# Build کردن و اجرای سرویس بکاپ به صورت یکباره +docker-compose -f docker-compose.backup.yml run --build backup-service run-once + +# برای مشاهده بکاپ‌های ایجاد شده +docker-compose -f docker-compose.backup.yml exec backup-service ls /backups +``` + +### ادغام با `docker-compose` اصلی + +برای ادغام با پروژه اصلی، قطعه کد زیر را به فایل `docker-compose.yml` اصلی خود اضافه کنید و مقادیر را مطابق با نیاز خود تنظیم نمایید. + +```yaml +services: + backup: + build: + context: ./ops/backup + container_name: masiryar-backup + environment: + - PG_HOST=postgres-db + - PG_USER=your_backup_user + - PG_PASSWORD=${YOUR_DB_PASSWORD} + - REDIS_HOST=redis + - FILE_PATHS=/path/to/your/files + - BACKUP_DIR=/backups + - RETENTION_DAYS=7 + - CRON_SCHEDULE=0 2 * * * + volumes: + - masiryar_backup_data:/backups # Volume برای نگهداری دائمی بکاپ‌ها + restart: on-failure +``` + +## 🧪 تست + +Unit testها با استفاده از `pytest` نوشته شده‌اند. + +```bash +# نصب وابستگی‌ها +pip install -r ops/backup/requirements.txt + +# اجرای تست‌ها +pytest ops/backup/tests/ +``` + +## ⏪ بازیابی بکاپ (Restore) + +یک اسکریپت کمکی به نام `restore.sh` برای بازیابی بکاپ‌های PostgreSQL فراهم شده است. + +**نحوه استفاده:** + +```bash +# دادن دسترسی اجرایی به اسکریپت +chmod +x ops/backup/restore.sh + +# اجرای اسکریپت +PGPASSWORD=your_db_pass ./ops/backup/restore.sh [new_db_name] +``` + +- ``: مسیر پوشه‌ای که بکاپ در آن قرار دارد (مثلاً `/var/lib/docker/volumes/masiryar_backup_data/_data/20231103T102030Z`). +- ``: نام دیتابیسی که از آن بکاپ گرفته شده است. +- `[new_db_name]` (اختیاری): نام دیتابیس جدیدی که می‌خواهید بکاپ در آن بازیابی شود. + +این اسکریپت به صورت تعاملی برای حذف دیتابیس مقصد (در صورت وجود) از شما تایید خواهد گرفت. diff --git a/ops/backup/docker-compose.backup.yml b/ops/backup/docker-compose.backup.yml new file mode 100644 index 0000000..c87d2bf --- /dev/null +++ b/ops/backup/docker-compose.backup.yml @@ -0,0 +1,49 @@ +# /ops/backup/docker-compose.backup.yml +version: '3.8' + +services: + postgres-test: + image: postgres:16 + container_name: backup_postgres_test + environment: + - POSTGRES_USER=testuser + - POSTGRES_PASSWORD=testpass + - POSTGRES_DB=testdb + ports: + - "5435:5432" # Use a different port to avoid conflicts + volumes: + - postgres_test_data:/var/lib/postgresql/data + restart: always + + redis-test: + image: redis:7 + container_name: backup_redis_test + ports: + - "6381:6379" # Use a different port + restart: always + + backup-service: + build: + context: . + dockerfile: Dockerfile + container_name: masiryar_backup_test + depends_on: + - postgres-test + - redis-test + environment: + - PG_HOST=postgres-test + - PG_PORT=5432 + - PG_USER=testuser + - PG_PASSWORD=testpass + - REDIS_HOST=redis-test + - REDIS_PORT=6379 + - FILE_PATHS=/testdata/logs,/testdata/config # Example paths inside the container + - BACKUP_DIR=/backups + - RETENTION_DAYS=3 + volumes: + - backup_test_data:/backups + - ./testdata:/testdata # Mount a local testdata directory + +volumes: + postgres_test_data: + backup_test_data: diff --git a/ops/backup/entrypoint.sh b/ops/backup/entrypoint.sh new file mode 100755 index 0000000..3395f7f --- /dev/null +++ b/ops/backup/entrypoint.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# /ops/backup/entrypoint.sh + +set -e + +# --- Environment Variable Setup for Cron --- +# Export all environment variables to a file that cron can use +printenv | grep -v "no_proxy" > /etc/environment + +# --- Cron Job Setup --- +echo "Setting up cron schedule: ${CRON_SCHEDULE}" + +# Create a crontab file +# The command runs the backup service once and logs the output +echo "${CRON_SCHEDULE} /usr/local/bin/python /app/src/backup_service.py run-once >> /var/log/cron.log 2>&1" > /etc/cron.d/backup-cron + +# Give execution rights on the cron job +chmod 0644 /etc/cron.d/backup-cron + +# Apply cron job +crontab /etc/cron.d/backup-cron + +# Create the log file to be able to run tail +touch /var/log/cron.log + +# --- Start Cron and Log Output --- +echo "Starting cron scheduler..." +cron && tail -f /var/log/cron.log diff --git a/ops/backup/requirements.txt b/ops/backup/requirements.txt new file mode 100644 index 0000000..3f7d6a8 --- /dev/null +++ b/ops/backup/requirements.txt @@ -0,0 +1,5 @@ +# ops/backup/requirements.txt +python-dotenv==1.0.0 +psycopg2-binary==2.9.9 +redis==5.0.4 +pytest==8.2.0 diff --git a/ops/backup/restore.sh b/ops/backup/restore.sh new file mode 100755 index 0000000..7274f05 --- /dev/null +++ b/ops/backup/restore.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# /ops/backup/restore.sh + +set -e +set -o pipefail + +# --- Defaults and Configuration --- +PG_HOST=${PG_HOST:-localhost} +PG_PORT=${PG_PORT:-5432} +PG_USER=${PG_USER:-admin} + +# --- Helper Functions --- +function print_usage() { + echo "Usage: ./restore.sh [target_db_name]" + echo "" + echo "Restores a PostgreSQL database from a backup directory generated by the backup service." + echo "" + echo "Arguments:" + echo " Path to the backup directory (e.g., /backups/20231103T102030Z)." + echo " The original name of the database to restore (e.g., PersonalGrowthDb)." + echo " [target_db_name] Optional. The name of the new database to restore into. Defaults to _restored." + echo "" + echo "Environment Variables:" + echo " PG_HOST, PG_PORT, PG_USER, PGPASSWORD can be set to configure the connection." +} + +# --- Input Validation --- +if [[ -z "$1" || -z "$2" ]]; then + echo "Error: Backup directory and database name are required." + print_usage + exit 1 +fi + +BACKUP_DIR=$1 +DB_NAME=$2 +TARGET_DB_NAME=${3:-${DB_NAME}_restored} + +# Find the dump file in the backup directory +DUMP_FILE=$(find "$BACKUP_DIR" -name "pg-${DB_NAME}.dump" | head -n 1) + +if [[ ! -f "$DUMP_FILE" ]]; then + echo "Error: Dump file for database '$DB_NAME' not found in '$BACKUP_DIR'." + exit 1 +fi + +if [[ -z "$PGPASSWORD" ]]; then + echo -n "Enter PostgreSQL password for user '$PG_USER': " + read -s PGPASSWORD + export PGPASSWORD + echo +fi + +# --- Restore Process --- +echo "Starting restore of '$DB_NAME' from '$DUMP_FILE' to database '$TARGET_DB_NAME'..." + +# 1. Drop target database if it exists (with confirmation) +read -p "WARNING: This will drop the database '$TARGET_DB_NAME' if it exists. Continue? (y/N) " confirm +if [[ ! "$confirm" =~ ^[yY]$ ]]; then + echo "Restore cancelled." + exit 0 +fi + +echo "Dropping existing database '$TARGET_DB_NAME'..." +dropdb --if-exists -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" "$TARGET_DB_NAME" + +# 2. Create a new, empty database +echo "Creating new database '$TARGET_DB_NAME'..." +createdb -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -O "$PG_USER" "$TARGET_DB_NAME" + +# 3. Restore the dump into the new database +echo "Restoring data using pg_restore..." +pg_restore -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$TARGET_DB_NAME" -v --no-owner --no-privileges "$DUMP_FILE" + +echo "" +echo "✅ Restore completed successfully." +echo "Database '$DB_NAME' has been restored to '$TARGET_DB_NAME' on host '$PG_HOST'." diff --git a/ops/backup/src/backup_service.py b/ops/backup/src/backup_service.py new file mode 100644 index 0000000..39d559d --- /dev/null +++ b/ops/backup/src/backup_service.py @@ -0,0 +1,284 @@ +# /ops/backup/src/backup_service.py +# -*- coding: utf-8 -*- + +import os +import subprocess +import logging +import sys +import shutil +import json +from datetime import datetime, timezone +from pathlib import Path + +import psycopg2 +import redis +from dotenv import load_dotenv + +# --- پیکربندی اولیه --- +# بارگذاری متغیرهای محیطی از فایل .env +# در محیط پروداکشن، این متغیرها باید توسط Docker یا سیستم عامل فراهم شوند. +load_dotenv() + +# تنظیمات لاگ‌گیری +LOG_FORMAT = '%(asctime)s - %(levelname)s - %(message)s' +logging.basicConfig(level=logging.INFO, format=LOG_FORMAT, stream=sys.stdout) + + +# --- خواندن متغیرهای محیطی با مقادیر پیش‌فرض --- +PG_HOST = os.getenv('PG_HOST', 'localhost') +PG_PORT = os.getenv('PG_PORT', '5432') +PG_USER = os.getenv('PG_USER') +PG_PASSWORD = os.getenv('PG_PASSWORD') +PG_DATABASES = os.getenv('PG_DATABASES', '').split(',') +REDIS_HOST = os.getenv('REDIS_HOST', 'localhost') +REDIS_PORT = int(os.getenv('REDIS_PORT', '6379')) +FILE_PATHS = [Path(p.strip()) for p in os.getenv('FILE_PATHS', '').split(',') if p.strip()] +BACKUP_DIR = Path(os.getenv('BACKUP_DIR', '/backups')) +RETENTION_DAYS = int(os.getenv('RETENTION_DAYS', 7)) +ADVISORY_LOCK_KEY = int(os.getenv('ADVISORY_LOCK_KEY', 123456789)) + +# --- توابع اصلی سرویس بکاپ --- + +def _get_db_connection(): + """یک اتصال جدید به PostgreSQL ایجاد می‌کند.""" + try: + conn = psycopg2.connect( + host=PG_HOST, + port=PG_PORT, + user=PG_USER, + password=PG_PASSWORD, + dbname='postgres' # اتصال به دیتابیس پیش‌فرض برای اجرای کوئری‌های مدیریتی + ) + return conn + except psycopg2.OperationalError as e: + logging.error(f"خطا در اتصال به PostgreSQL: {e}") + return None + +def obtain_lock(conn): + """ + یک قفل مشورتی (advisory lock) در PostgreSQL برای جلوگیری از اجرای همزمان ایجاد می‌کند. + این روش از ایجاد فایل lock امن‌تر است، به خصوص در محیط‌های توزیع‌شده. + """ + if not conn: + return False + try: + with conn.cursor() as cur: + cur.execute(f"SELECT pg_try_advisory_lock({ADVISORY_LOCK_KEY})") + locked = cur.fetchone()[0] + if not locked: + logging.warning("یک فرآیند بکاپ دیگر در حال اجراست. از این اجرا صرف نظر می‌شود.") + return False + logging.info("قفل با موفقیت ایجاد شد.") + return True + except Exception as e: + logging.error(f"خطا در ایجاد قفل: {e}") + return False + +def release_lock(conn): + """قفل مشورتی را آزاد می‌کند.""" + if not conn: + return + try: + with conn.cursor() as cur: + cur.execute(f"SELECT pg_advisory_unlock({ADVISORY_LOCK_KEY})") + logging.info("قفل با موفقیت آزاد شد.") + except Exception as e: + logging.error(f"خطا در آزاد کردن قفل: {e}") + +def list_databases(conn): + """لیست تمام دیتابیس‌ها را (به جز دیتابیس‌های template) برمی‌گرداند.""" + if PG_DATABASES and PG_DATABASES[0]: + return PG_DATABASES + + if not conn: + return [] + try: + with conn.cursor() as cur: + cur.execute("SELECT datname FROM pg_database WHERE datistemplate = false AND datname != 'postgres';") + databases = [row[0] for row in cur.fetchall()] + return databases + except Exception as e: + logging.error(f"خطا در دریافت لیست دیتابیس‌ها: {e}") + return [] + +def backup_postgres_db(db_name: str, target_dir: Path): + """از یک دیتابیس PostgreSQL با استفاده از pg_dump بکاپ می‌گیرد.""" + logging.info(f"شروع بکاپ از دیتابیس: {db_name}") + dump_file = target_dir / f"pg-{db_name}.dump" + + # اطمینان از اینکه رمز عبور در متغیر محیطی است + env = os.environ.copy() + env['PGPASSWORD'] = PG_PASSWORD + + command = [ + 'pg_dump', + '-h', PG_HOST, + '-p', PG_PORT, + '-U', PG_USER, + '-d', db_name, + '-F', 'c', # فرمت سفارشی فشرده + '-b', # شامل اشیاء بزرگ (blobs) + '-v', # حالت پرجزئیات + '-f', str(dump_file) + ] + try: + subprocess.run(command, check=True, capture_output=True, text=True, env=env) + logging.info(f"بکاپ از دیتابیس {db_name} با موفقیت در {dump_file} ذخیره شد.") + return dump_file + except subprocess.CalledProcessError as e: + logging.error(f"خطا در اجرای pg_dump برای دیتابیس {db_name}: {e.stderr}") + return None + +def backup_redis(): + """از Redis با استفاده از BGSAVE بکاپ می‌گیرد.""" + logging.info("شروع بکاپ از Redis...") + try: + r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT) + last_save_time_before = r.lastsave() + r.bgsave() + + # منتظر می‌مانیم تا BGSAVE تمام شود + for _ in range(30): # حداکثر 30 ثانیه صبر می‌کنیم + if r.lastsave() > last_save_time_before: + logging.info("BGSAVE در Redis با موفقیت انجام شد.") + # در یک سناریوی واقعی، باید مسیر dump.rdb را از Redis بگیریم + # اما معمولاً در /data/dump.rdb است. این بخش به پیکربندی Docker بستگی دارد. + # در اینجا فرض می‌کنیم که این فایل در یک volume مشترک قابل دسترسی است. + return Path("/data/dump.rdb") # مسیر فرضی + logging.warning("BGSAVE در Redis در زمان مقرر تمام نشد.") + return None + except Exception as e: + logging.error(f"خطا در بکاپ‌گیری از Redis: {e}") + return None + +def backup_files(target_dir: Path): + """از مسیرهای مشخص‌شده با استفاده از tar بکاپ می‌گیرد.""" + backup_files_list = [] + for path in FILE_PATHS: + if not path.exists(): + logging.warning(f"مسیر فایل برای بکاپ یافت نشد: {path}") + continue + + logging.info(f"شروع بکاپ از مسیر: {path}") + archive_name = f"files-{path.name}.tar.gz" + archive_path = target_dir / archive_name + + command = [ + 'tar', + '-czf', str(archive_path), + '-C', str(path.parent), # برای حفظ ساختار نسبی + str(path.name) + ] + try: + subprocess.run(command, check=True, capture_output=True, text=True) + logging.info(f"بکاپ از {path} با موفقیت در {archive_path} ذخیره شد.") + backup_files_list.append(archive_path) + except subprocess.CalledProcessError as e: + logging.error(f"خطا در فشرده‌سازی مسیر {path}: {e.stderr}") + return backup_files_list + +def generate_manifest(backup_dir: Path, backup_files: dict): + """یک فایل manifest.json با اطلاعات بکاپ ایجاد می‌کند.""" + manifest = { + 'timestamp_utc': datetime.now(timezone.utc).isoformat(), + 'backup_directory': str(backup_dir), + 'files': [] + } + for name, path in backup_files.items(): + if path and path.exists(): + file_stat = path.stat() + manifest['files'].append({ + 'name': name, + 'path': str(path.relative_to(backup_dir)), + 'size_bytes': file_stat.st_size, + }) + + manifest_path = backup_dir / "manifest.json" + with open(manifest_path, 'w') as f: + json.dump(manifest, f, indent=2) + logging.info(f"فایل manifest در {manifest_path} ایجاد شد.") + +def rotate_backups(): + """بکاپ‌های قدیمی‌تر از RETENTION_DAYS را حذف می‌کند.""" + if RETENTION_DAYS <= 0: + return + + logging.info(f"حذف بکاپ‌های قدیمی‌تر از {RETENTION_DAYS} روز...") + now = datetime.now(timezone.utc) + + try: + backup_dirs = [d for d in BACKUP_DIR.iterdir() if d.is_dir()] + backup_dirs.sort(key=lambda d: d.name) # مرتب‌سازی بر اساس نام (که با timestamp شروع می‌شود) + + to_delete = backup_dirs[:-RETENTION_DAYS] # همه به جز N تای آخر + + for d in to_delete: + logging.info(f"حذف پوشه بکاپ قدیمی: {d}") + shutil.rmtree(d) + except Exception as e: + logging.error(f"خطا در حذف بکاپ‌های قدیمی: {e}") + + +def run_once(): + """یک بار فرآیند کامل بکاپ را اجرا می‌کند.""" + start_time = datetime.now(timezone.utc) + logging.info("--- شروع فرآیند بکاپ ---") + + conn = _get_db_connection() + if not obtain_lock(conn): + sys.exit(2) + + try: + # ۱. ایجاد پوشه بکاپ برای این اجرا + timestamp = start_time.strftime('%Y%m%dT%H%M%SZ') + today_backup_dir = BACKUP_DIR / timestamp + today_backup_dir.mkdir(parents=True, exist_ok=True) + logging.info(f"پوشه بکاپ ایجاد شد: {today_backup_dir}") + + all_backup_files = {} + + # ۲. بکاپ از PostgreSQL + databases = list_databases(conn) + if not databases: + logging.warning("هیچ دیتابیسی برای بکاپ یافت نشد.") + else: + for db in databases: + dump_file = backup_postgres_db(db, today_backup_dir) + if dump_file: + all_backup_files[f'postgres_{db}'] = dump_file + + # ۳. بکاپ از Redis (بخش شبیه‌سازی شده) + redis_dump_path = backup_redis() + if redis_dump_path and redis_dump_path.exists(): + # مسیر dump.rdb را به پوشه بکاپ کپی می‌کنیم + final_redis_path = today_backup_dir / "redis-dump.rdb" + shutil.copy(redis_dump_path, final_redis_path) + all_backup_files['redis'] = final_redis_path + + # ۴. بکاپ از فایل‌ها + archived_files = backup_files(today_backup_dir) + for i, path in enumerate(archived_files): + all_backup_files[f'file_{i}'] = path + + # ۵. ایجاد manifest + generate_manifest(today_backup_dir, all_backup_files) + + # ۶. حذف بکاپ‌های قدیمی + rotate_backups() + + logging.info("--- فرآیند بکاپ با موفقیت به پایان رسید ---") + + except Exception as e: + logging.critical(f"یک خطای پیش‌بینی‌نشده در فرآیند بکاپ رخ داد: {e}", exc_info=True) + sys.exit(1) + finally: + release_lock(conn) + if conn: + conn.close() + +if __name__ == '__main__': + if len(sys.argv) > 1 and sys.argv[1] == 'run-once': + run_once() + else: + # در حالت daemon، یک orchestrator خارجی (مانند cron) باید این اسکریپت را با run-once فراخوانی کند. + print("برای اجرای یکباره، از دستور 'python backup_service.py run-once' استفاده کنید.") diff --git a/ops/backup/testdata/config/settings.conf b/ops/backup/testdata/config/settings.conf new file mode 100644 index 0000000..3cb262b --- /dev/null +++ b/ops/backup/testdata/config/settings.conf @@ -0,0 +1 @@ +config data diff --git a/ops/backup/tests/test_backup_utils.py b/ops/backup/tests/test_backup_utils.py new file mode 100644 index 0000000..1272be3 --- /dev/null +++ b/ops/backup/tests/test_backup_utils.py @@ -0,0 +1,97 @@ +# /ops/backup/tests/test_backup_utils.py +import os +import json +from pathlib import Path +from datetime import datetime, timedelta, timezone +from unittest.mock import patch, MagicMock + +# برای اینکه بتوانیم ماژول backup_service را import کنیم، باید مسیر src را به sys.path اضافه کنیم. +import sys +sys.path.insert(0, str(Path(__file__).parent.parent / 'src')) + +import backup_service + +# --- تست‌های مربوط به حذف بکاپ‌های قدیمی (Retention) --- + +def test_rotate_backups_deletes_oldest(tmp_path): + """ + تست می‌کند که آیا تابع rotate_backups به درستی قدیمی‌ترین پوشه‌ها را حذف می‌کند + و فقط تعداد مشخص‌شده را باقی می‌گذارد. + """ + # ساخت پوشه‌های بکاپ ساختگی + base_dir = tmp_path + for i in range(10): + # ایجاد timestampهای متوالی در گذشته + ts = (datetime.now(timezone.utc) - timedelta(days=i)).strftime('%Y%m%dT%H%M%SZ') + (base_dir / ts).mkdir() + + # استفاده از patch برای جایگزینی متغیرهای گلوبال با مقادیر تستی + with patch('backup_service.BACKUP_DIR', base_dir), \ + patch('backup_service.RETENTION_DAYS', 7): + + backup_service.rotate_backups() + + # بررسی نتیجه + remaining_dirs = sorted([d.name for d in base_dir.iterdir()]) + assert len(remaining_dirs) == 7 + # قدیمی‌ترین پوشه حذف شده باید پوشه‌ای باشد که 9 روز پیش ایجاد شده است + assert "T" in remaining_dirs[0] # اطمینان از فرمت صحیح نام + assert (datetime.now(timezone.utc) - timedelta(days=9)).strftime('%Y%m%d') not in "".join(remaining_dirs) + + +def test_rotate_backups_does_nothing_if_not_enough_backups(tmp_path): + """ + تست می‌کند که اگر تعداد بکاپ‌ها کمتر از retention_days باشد، هیچ پوشه‌ای حذف نمی‌شود. + """ + base_dir = tmp_path + for i in range(5): + (base_dir / f"{i}").mkdir() + + with patch('backup_service.BACKUP_DIR', base_dir), \ + patch('backup_service.RETENTION_DAYS', 7): + + backup_service.rotate_backups() + + assert len(list(base_dir.iterdir())) == 5 + + +# --- تست‌های مربوط به ایجاد Manifest --- + +def test_generate_manifest_creates_correct_json(tmp_path): + """ + تست می‌کند که آیا فایل manifest.json با محتوای صحیح ایجاد می‌شود. + """ + backup_dir = tmp_path + + # ایجاد فایل‌های بکاپ ساختگی + pg_dump_path = backup_dir / "pg-testdb.dump" + pg_dump_path.write_text("postgres dump content") + + redis_dump_path = backup_dir / "redis-dump.rdb" + redis_dump_path.write_text("redis dump content") + + backup_files = { + 'postgres_testdb': pg_dump_path, + 'redis': redis_dump_path + } + + backup_service.generate_manifest(backup_dir, backup_files) + + # بررسی محتوای manifest.json + manifest_path = backup_dir / "manifest.json" + assert manifest_path.exists() + + with open(manifest_path, 'r') as f: + manifest_data = json.load(f) + + assert "timestamp_utc" in manifest_data + assert manifest_data['backup_directory'] == str(backup_dir) + assert len(manifest_data['files']) == 2 + + pg_file_info = next(f for f in manifest_data['files'] if f['name'] == 'postgres_testdb') + assert pg_file_info['path'] == "pg-testdb.dump" + assert pg_file_info['size_bytes'] == len("postgres dump content") + + redis_file_info = next(f for f in manifest_data['files'] if f['name'] == 'redis') + assert redis_file_info['path'] == "redis-dump.rdb" + assert redis_file_info['size_bytes'] == len("redis dump content")