From 375022e739009ba89abfd4772d00ff239cd604bc Mon Sep 17 00:00:00 2001 From: byrsapty Date: Sat, 5 Sep 2026 22:57:59 +0300 Subject: [PATCH] Add backup_remote.py and wire up the rest of the rclone rework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continuation of the previous commit (gdrive_backup.py removal landed separately by accident) — adds the new module itself, the admin routes/template using it, generalized Setting keys, updated docs/ provisioning notes, and tests. Co-Authored-By: Claude Sonnet 5 --- README.md | 19 +- bober_bbq/admin/system.py | 87 ++++--- bober_bbq/models.py | 24 +- bober_bbq/static/admin/css/admin.css | 1 + bober_bbq/templates/admin/backups.html | 150 ++++++++--- bober_bbq/utils/backup_remote.py | 306 ++++++++++++++++++++++ docs/DEPLOYMENT.md | 37 ++- docs/TESTING.md | 7 +- provision_client.sh | 9 +- requirements.txt | 5 +- run_web.py | 6 +- tests/test_backup_remote.py | 342 +++++++++++++++++++++++++ 12 files changed, 893 insertions(+), 100 deletions(-) create mode 100644 bober_bbq/utils/backup_remote.py create mode 100644 tests/test_backup_remote.py diff --git a/README.md b/README.md index 559ca0a..23e424a 100644 --- a/README.md +++ b/README.md @@ -102,9 +102,10 @@ градієнтів) підбирається автоматично. - **Розсилка** повідомлення всім клієнтам бота. - **Резервні копії** БД: створення, відновлення, видалення з адмінки, - плюс опційні **автоматичні бекапи в Google Drive** через сервісний - акаунт (без інтерактивного логіну) з ротацією — старі копії - видаляються, коли їх стає більше за налаштовану кількість. + плюс опційні **автоматичні бекапи через rclone** (Google Drive, S3-сумісне + сховище або будь-який інший rclone-backend, налаштовується прямо у формі) + з ротацією — старі копії видаляються, коли їх стає більше за налаштовану + кількість. - **Адміністратори**: ролі admin/manager (RBAC) — менеджер не бачить Налаштування, Адміністраторів, Резервні копії, Статус системи й Журнал дій. @@ -133,7 +134,7 @@ bober-bbq-bot/ │ │ ├── inventory.py # склад: інгредієнти, рецептура, заявки на поповнення │ │ ├── customers.py # клієнти й статистика по них │ │ ├── statistics.py # звіти за період + графік виручки -│ │ ├── system.py # статус системи, бекапи (локальні + Google Drive), очищення БД +│ │ ├── system.py # статус системи, бекапи (локальні + rclone), очищення БД │ │ ├── telegram_chats.py # перевірка підключення чатів доставки/самовивозу │ │ ├── broadcast.py # розсилка повідомлення всім клієнтам │ │ ├── admins.py # керування адміністраторами/менеджерами @@ -142,7 +143,7 @@ bober-bbq-bot/ │ ├── utils/ │ │ ├── reconciliation.py # фонові job'и: страховка оплати + health-check токена │ │ ├── inventory.py # автосписання/повернення складу, автозаявки на поповнення -│ │ ├── gdrive_backup.py # автобекапи БД у Google Drive з ротацією +│ │ ├── backup_remote.py # автобекапи БД через rclone (Google Drive/S3/інше) з ротацією │ │ ├── notify_customer.py # авто-сповіщення клієнта про статус + запит оцінки │ │ ├── notify_manager.py # сповіщення менеджера (нове/скасоване замовлення), staff_chat_id() по типу замовлення │ │ ├── telegram_notify.py # низькорівневий sendMessage/sendDocument (HTTP, поза aiogram) @@ -380,7 +381,7 @@ Better Uptime тощо), щоб дізнатись про падіння сай |---|---|---| | `reconcile_payments` | кожні 10 хв | звіряє неоплачені card-замовлення за останні 6 год напряму з monobank — страховка на випадок пропущеного вебхука | | `monobank_token_health` | щогодини | пінгує токен monobank; якщо робочий токен раптом перестав діяти — пише власнику (`OWNER_IDS`) в Telegram | -| `gdrive_backup` | щогодини (перевіряє) | якщо увімкнено й минув налаштований інтервал — вивантажує свіжий бекап у Google Drive і чистить застарілі копії; при збої пише власнику в Telegram | +| `backup_remote` | щогодини (перевіряє) | якщо увімкнено й минув налаштований інтервал — вивантажує свіжий бекап через rclone (Google Drive/S3/інше) і чистить застарілі копії; при збої пише власнику в Telegram | ## Налаштування в адмінці (без доступу до сервера) @@ -411,9 +412,9 @@ Better Uptime тощо), щоб дізнатись про падіння сай розподіл по зірках, фільтр і пагінація. - Розсилка повідомлення всім клієнтам бота. - Резервні копії БД: створення, відновлення, видалення — прямо з адмінки, - плюс автоматичні бекапи в Google Drive з ротацією (Адмінка → - Резервні копії → секція «Google Drive», з покроковою інструкцією - налаштування сервісного акаунта прямо на сторінці). + плюс автоматичні бекапи через rclone з ротацією (Адмінка → + Резервні копії → тип сховища: Google Drive / S3-сумісне / інше, + з покроковою інструкцією налаштування прямо на сторінці для кожного). - Облік сировини: перемикач «Увімкнути облік сировини», нижче в тому ж розділі — при увімкненні в меню з'являється «Склад». diff --git a/bober_bbq/admin/system.py b/bober_bbq/admin/system.py index d58e134..8963aff 100644 --- a/bober_bbq/admin/system.py +++ b/bober_bbq/admin/system.py @@ -13,7 +13,7 @@ from bober_bbq.config import BASE_DIR, config from bober_bbq.extensions import db from bober_bbq.models import AuditLog, CartItem, Order, OrderItem, Product, Setting, TelegramUser from bober_bbq.utils.audit import log_action -from bober_bbq.utils.gdrive_backup import backup_now, gdrive_enabled, test_connection +from bober_bbq.utils.backup_remote import backup_now, backup_remote_enabled, test_connection APP_VERSION = "1.0.0" BACKUP_DIR = BASE_DIR / "backups" @@ -93,61 +93,84 @@ def backups_list(): ] sqlite_path = _sqlite_path() - gdrive_last_at_raw = Setting.get("gdrive_last_backup_at", "") - gdrive_last_at = None - if gdrive_last_at_raw: + remote_last_at_raw = Setting.get("backup_remote_last_backup_at", "") + remote_last_at = None + if remote_last_at_raw: try: - gdrive_last_at = datetime.fromisoformat(gdrive_last_at_raw).replace(tzinfo=timezone.utc).astimezone(_tz) + remote_last_at = datetime.fromisoformat(remote_last_at_raw).replace(tzinfo=timezone.utc).astimezone(_tz) except ValueError: - gdrive_last_at = None - gdrive_last_status = Setting.get("gdrive_last_backup_status", "") + remote_last_at = None + remote_last_status = Setting.get("backup_remote_last_backup_status", "") + remote_keys = [ + "backup_remote_enabled", + "backup_remote_type", + "backup_remote_interval_hours", + "backup_remote_retention_count", + "backup_gdrive_service_account_json", + "backup_gdrive_folder_id", + "backup_s3_provider", + "backup_s3_access_key_id", + "backup_s3_secret_access_key", + "backup_s3_endpoint", + "backup_s3_region", + "backup_s3_bucket", + "backup_s3_prefix", + "backup_custom_type", + "backup_custom_config", + "backup_custom_path", + ] return render_template( "admin/backups.html", backups=backups, is_sqlite=bool(sqlite_path), - gdrive_values={ - "gdrive_backup_enabled": Setting.get("gdrive_backup_enabled", "0"), - "gdrive_service_account_json": Setting.get("gdrive_service_account_json", ""), - "gdrive_folder_id": Setting.get("gdrive_folder_id", ""), - "gdrive_backup_interval_hours": Setting.get("gdrive_backup_interval_hours", "24"), - "gdrive_retention_count": Setting.get("gdrive_retention_count", "14"), - }, - gdrive_last_at=gdrive_last_at, - gdrive_last_status=gdrive_last_status, + remote_values={key: Setting.get(key, "") for key in remote_keys}, + remote_last_at=remote_last_at, + remote_last_status=remote_last_status, ) -@admin_bp.route("/backups/gdrive-settings", methods=["POST"]) +@admin_bp.route("/backups/remote-settings", methods=["POST"]) @admin_required -def backups_gdrive_settings(): - Setting.set("gdrive_backup_enabled", "1" if request.form.get("gdrive_backup_enabled") else "0") - Setting.set("gdrive_service_account_json", request.form.get("gdrive_service_account_json", "").strip()) - Setting.set("gdrive_folder_id", request.form.get("gdrive_folder_id", "").strip()) - Setting.set("gdrive_backup_interval_hours", request.form.get("gdrive_backup_interval_hours", "24") or "24") - Setting.set("gdrive_retention_count", request.form.get("gdrive_retention_count", "14") or "14") +def backups_remote_settings(): + Setting.set("backup_remote_enabled", "1" if request.form.get("backup_remote_enabled") else "0") + Setting.set("backup_remote_type", request.form.get("backup_remote_type", "").strip()) + Setting.set("backup_remote_interval_hours", request.form.get("backup_remote_interval_hours", "24") or "24") + Setting.set("backup_remote_retention_count", request.form.get("backup_remote_retention_count", "14") or "14") + Setting.set("backup_gdrive_service_account_json", request.form.get("backup_gdrive_service_account_json", "").strip()) + Setting.set("backup_gdrive_folder_id", request.form.get("backup_gdrive_folder_id", "").strip()) + Setting.set("backup_s3_provider", request.form.get("backup_s3_provider", "AWS").strip() or "AWS") + Setting.set("backup_s3_access_key_id", request.form.get("backup_s3_access_key_id", "").strip()) + Setting.set("backup_s3_secret_access_key", request.form.get("backup_s3_secret_access_key", "").strip()) + Setting.set("backup_s3_endpoint", request.form.get("backup_s3_endpoint", "").strip()) + Setting.set("backup_s3_region", request.form.get("backup_s3_region", "").strip()) + Setting.set("backup_s3_bucket", request.form.get("backup_s3_bucket", "").strip()) + Setting.set("backup_s3_prefix", request.form.get("backup_s3_prefix", "").strip()) + Setting.set("backup_custom_type", request.form.get("backup_custom_type", "").strip()) + Setting.set("backup_custom_config", request.form.get("backup_custom_config", "").strip()) + Setting.set("backup_custom_path", request.form.get("backup_custom_path", "").strip()) db.session.commit() - log_action("gdrive_settings_update", "Оновлено налаштування резервного копіювання в Google Drive") - flash("Налаштування Google Drive збережено", "success") + log_action("backup_remote_settings_update", "Оновлено налаштування резервного копіювання") + flash("Налаштування резервного копіювання збережено", "success") return redirect(url_for("admin.backups_list")) -@admin_bp.route("/backups/gdrive-test", methods=["POST"]) +@admin_bp.route("/backups/remote-test", methods=["POST"]) @admin_required -def backups_gdrive_test(): +def backups_remote_test(): ok, message = test_connection() flash(message, "success" if ok else "error") return redirect(url_for("admin.backups_list")) -@admin_bp.route("/backups/gdrive-backup-now", methods=["POST"]) +@admin_bp.route("/backups/remote-backup-now", methods=["POST"]) @admin_required -def backups_gdrive_backup_now(): - if not gdrive_enabled(): - flash("Спершу увімкніть і збережіть налаштування Google Drive.", "error") +def backups_remote_backup_now(): + if not backup_remote_enabled(): + flash("Спершу увімкніть і збережіть налаштування резервного копіювання.", "error") return redirect(url_for("admin.backups_list")) ok, message = backup_now() if ok: - log_action("gdrive_backup_manual", "Створено позачергову резервну копію в Google Drive") + log_action("backup_remote_manual", "Створено позачергову резервну копію") flash(message, "success" if ok else "error") return redirect(url_for("admin.backups_list")) diff --git a/bober_bbq/models.py b/bober_bbq/models.py index 95b9c56..032680f 100644 --- a/bober_bbq/models.py +++ b/bober_bbq/models.py @@ -626,11 +626,25 @@ DEFAULT_SETTINGS = { "liqpay_private_key": "", "liqpay_sandbox_mode": "0", "payment_destination_template": "Оплата замовлення {number}", - "gdrive_backup_enabled": "0", - "gdrive_service_account_json": "", - "gdrive_folder_id": "", - "gdrive_backup_interval_hours": "24", - "gdrive_retention_count": "14", + # Off-site backups via rclone — see bober_bbq/utils/backup_remote.py. + # backup_remote_type selects which of the field groups below is + # actually used: "" (not configured) | "gdrive" | "s3" | "custom". + "backup_remote_enabled": "0", + "backup_remote_type": "", + "backup_remote_interval_hours": "24", + "backup_remote_retention_count": "14", + "backup_gdrive_service_account_json": "", + "backup_gdrive_folder_id": "", + "backup_s3_provider": "AWS", + "backup_s3_access_key_id": "", + "backup_s3_secret_access_key": "", + "backup_s3_endpoint": "", + "backup_s3_region": "", + "backup_s3_bucket": "", + "backup_s3_prefix": "", + "backup_custom_type": "", + "backup_custom_config": "", + "backup_custom_path": "", "admin_theme_accent": "#ff7a3d", "webapp_theme_accent": "#ff7a3d", "sidebar_collapsible_menu": "1", diff --git a/bober_bbq/static/admin/css/admin.css b/bober_bbq/static/admin/css/admin.css index d1108ca..53cff9d 100644 --- a/bober_bbq/static/admin/css/admin.css +++ b/bober_bbq/static/admin/css/admin.css @@ -617,6 +617,7 @@ td { vertical-align: middle; } .recipe-row input { width: 130px; flex-shrink: 0; margin: 0; } .order-type-fields { display: none; } +.backup-remote-fields { display: none; } /* ---------- responsive / mobile ---------- */ diff --git a/bober_bbq/templates/admin/backups.html b/bober_bbq/templates/admin/backups.html index ea6650f..2a456fe 100644 --- a/bober_bbq/templates/admin/backups.html +++ b/bober_bbq/templates/admin/backups.html @@ -42,70 +42,160 @@ -

☁️ Автоматичні резервні копії в Google Drive

+

☁️ Автоматичні резервні копії (rclone)

- {% if gdrive_last_at %} + {% if remote_last_at %}

- Останній бекап: {{ gdrive_last_at.strftime('%d.%m.%Y %H:%M') }} — - {% if gdrive_last_status == 'ok' %}успішно ✅{% else %}помилка: {{ gdrive_last_status }}{% endif %} + Останній бекап: {{ remote_last_at.strftime('%d.%m.%Y %H:%M') }} — + {% if remote_last_status == 'ok' %}успішно ✅{% else %}помилка: {{ remote_last_status }}{% endif %}

{% endif %} -
+ - - - - - + +
- +
- +
+
+ + + + + + +
+ Як налаштувати (крок за кроком) +
    +
  1. Відкрийте Google Cloud Console, створіть проєкт (або оберіть існуючий).
  2. +
  3. Увімкніть Google Drive API для цього проєкту (APIs & Services → Library).
  4. +
  5. Створіть сервісний акаунт: IAM & Admin → Service Accounts → Create Service Account.
  6. +
  7. Відкрийте створений акаунт → Keys → Add Key → Create new key → JSON. Завантажиться файл.
  8. +
  9. Відкрийте цей файл будь-яким текстовим редактором і вставте весь вміст у поле «Ключ сервісного акаунта» вище.
  10. +
  11. У Google Drive створіть папку для бекапів, поділіться нею (Share) з email сервісного акаунта (поле client_email у JSON-файлі), давши права «Редактор».
  12. +
  13. Скопіюйте ID папки з адресного рядка (частина після /folders/) у поле «ID папки» вище.
  14. +
  15. Оберіть «Google Drive» вище, збережіть, натисніть «Перевірити підключення» — якщо все гаразд, спробуйте «Backup зараз».
  16. +
+
+
+ +
+ + + +
+
+ + +
+
+ + +
+
+ + + + +
+
+ + +
+
+ + +
+
+ + + + +
+ Як налаштувати (крок за кроком) +
    +
  1. Створіть бакет у своєму провайдері (AWS S3, Backblaze B2, Wasabi, DigitalOcean Spaces тощо).
  2. +
  3. Створіть ключ доступу (Access Key / Application Key) з правом запису в цей бакет.
  4. +
  5. Для AWS S3 залиште поле «Endpoint» порожнім. Для інших провайдерів — вкажіть їхній S3-endpoint (є в панелі провайдера; для Backblaze B2 і подібних оберіть провайдера «Інше»).
  6. +
  7. Заповніть Access Key ID, Secret Access Key, бакет — і збережіть.
  8. +
  9. Натисніть «Перевірити підключення», потім «Backup зараз».
  10. +
+
+
+ +
+ + + + + + + + + +
+ Як налаштувати (крок за кроком) +

+ Для нетипових сховищ, не перелічених вище. Тип і назви параметрів беруться з + документації rclone для конкретного сховища + (наприклад, для SFTP: тип sftp, параметри host/user/pass). + Кожен рядок у полі «Додаткові параметри» стає одним параметром конфігурації цього сховища. +

+
+
+
-
+
-
+
- -
- Як налаштувати (крок за кроком) -
    -
  1. Відкрийте Google Cloud Console, створіть проєкт (або оберіть існуючий).
  2. -
  3. Увімкніть Google Drive API для цього проєкту (APIs & Services → Library).
  4. -
  5. Створіть сервісний акаунт: IAM & Admin → Service Accounts → Create Service Account.
  6. -
  7. Відкрийте створений акаунт → Keys → Add Key → Create new key → JSON. Завантажиться файл.
  8. -
  9. Відкрийте цей файл будь-яким текстовим редактором і вставте весь вміст у поле «Ключ сервісного акаунта» вище.
  10. -
  11. У Google Drive створіть папку для бекапів, поділіться нею (Share) з email сервісного акаунта (поле client_email у JSON-файлі), давши права «Редактор».
  12. -
  13. Скопіюйте ID папки з адресного рядка (частина після /folders/) у поле «ID папки» вище.
  14. -
  15. Увімкніть перемикач, збережіть, натисніть «Перевірити підключення» — якщо все гаразд, спробуйте «Backup зараз».
  16. -
-
+ +
diff --git a/bober_bbq/utils/backup_remote.py b/bober_bbq/utils/backup_remote.py new file mode 100644 index 0000000..a43e60f --- /dev/null +++ b/bober_bbq/utils/backup_remote.py @@ -0,0 +1,306 @@ +"""Automatic off-site backups (SQLite DB + static/uploads/) via rclone, +with rotation: once more than `backup_remote_retention_count` backups exist +in the target remote, the oldest ones are deleted. + +rclone is invoked as an external binary (`subprocess`), never via a config +file on disk — the selected remote's credentials are read from `Setting` +rows (see _remote_env_and_path()) and passed as RCLONE_CONFIG_BACKUP_* +environment variables for each call, exactly as rclone's own docs describe +for defining a remote entirely through the environment. This keeps +credentials in the same place the previous Google-Drive-only version kept +its service-account JSON (the app's own Setting table) — no worse than +before, and no rclone.conf ever touches the filesystem. + +Entirely opt-in via "backup_remote_enabled". The scheduled job in +run_web.py calls run_scheduled_backup() frequently (e.g. hourly); this +module itself decides whether the configured interval has actually +elapsed, so changing the interval in the admin panel takes effect without +restarting the process. +""" + +import json +import logging +import os +import shutil +import sqlite3 +import subprocess +from datetime import datetime, timedelta +from pathlib import Path +from zoneinfo import ZoneInfo + +from bober_bbq.config import BASE_DIR, config +from bober_bbq.extensions import db +from bober_bbq.models import Setting +from bober_bbq.utils.telegram_notify import send_message + +logger = logging.getLogger(__name__) +_tz = ZoneInfo(config.TIMEZONE) + +# Internal rclone remote name — never shown to the admin, just the prefix +# used for RCLONE_CONFIG_BACKUP_* env vars and the "backup:..." path arg. +_REMOTE = "backup" + + +class BackupError(Exception): + """Raised for any configuration or rclone problem — the message is + written straight into flash messages / owner notifications, so it's + kept human-readable rather than a raw stack trace.""" + + +def backup_remote_enabled() -> bool: + return Setting.get_bool("backup_remote_enabled", False) + + +def _remote_env_and_path() -> tuple[dict, str]: + """Builds the environment rclone needs to see the configured remote, + and the "remote:path" argument to use in CLI calls — entirely from + Setting rows, entirely via env vars (see module docstring). Raises + BackupError immediately if the selected type or its required fields + are missing, before any subprocess is ever spawned.""" + remote_type = Setting.get("backup_remote_type", "").strip() + if not remote_type: + raise BackupError("Тип сховища для резервних копій не обрано") + + prefix = f"RCLONE_CONFIG_{_REMOTE.upper()}_" + env = dict(os.environ) + # Never let rclone fall back to a host-level ~/.config/rclone/rclone.conf + # — the only configuration source must be what this function builds. + env["RCLONE_CONFIG"] = os.devnull + + if remote_type == "gdrive": + service_account_json = Setting.get("backup_gdrive_service_account_json", "").strip() + folder_id = Setting.get("backup_gdrive_folder_id", "").strip() + if not service_account_json: + raise BackupError("Не вказано ключ сервісного акаунта Google") + if not folder_id: + raise BackupError("Не вказано ID папки Google Drive") + try: + json.loads(service_account_json) + except json.JSONDecodeError as e: + raise BackupError(f"Невалідний JSON ключа сервісного акаунта: {e}") from e + env[prefix + "TYPE"] = "drive" + env[prefix + "SERVICE_ACCOUNT_CREDENTIALS"] = service_account_json + env[prefix + "ROOT_FOLDER_ID"] = folder_id + return env, f"{_REMOTE}:" + + if remote_type == "s3": + access_key_id = Setting.get("backup_s3_access_key_id", "").strip() + secret_access_key = Setting.get("backup_s3_secret_access_key", "").strip() + bucket = Setting.get("backup_s3_bucket", "").strip() + if not access_key_id or not secret_access_key: + raise BackupError("Не вказано ключі доступу до S3-сховища") + if not bucket: + raise BackupError("Не вказано назву бакета S3-сховища") + env[prefix + "TYPE"] = "s3" + env[prefix + "PROVIDER"] = Setting.get("backup_s3_provider", "AWS").strip() or "AWS" + env[prefix + "ACCESS_KEY_ID"] = access_key_id + env[prefix + "SECRET_ACCESS_KEY"] = secret_access_key + endpoint = Setting.get("backup_s3_endpoint", "").strip() + if endpoint: + env[prefix + "ENDPOINT"] = endpoint + region = Setting.get("backup_s3_region", "").strip() + if region: + env[prefix + "REGION"] = region + prefix_path = Setting.get("backup_s3_prefix", "").strip().strip("/") + remote_path = f"{_REMOTE}:{bucket}/{prefix_path}" if prefix_path else f"{_REMOTE}:{bucket}" + return env, remote_path + + if remote_type == "custom": + custom_type = Setting.get("backup_custom_type", "").strip() + custom_path = Setting.get("backup_custom_path", "").strip() + if not custom_type: + raise BackupError("Не вказано тип rclone-backend'у") + if not custom_path: + raise BackupError("Не вказано шлях призначення") + env[prefix + "TYPE"] = custom_type + for line in Setting.get("backup_custom_config", "").splitlines(): + line = line.strip() + if not line or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip().upper().replace(" ", "_") + if key: + env[prefix + key] = value.strip() + return env, f"{_REMOTE}:{custom_path}" + + raise BackupError(f"Невідомий тип сховища: {remote_type!r}") + + +def _rclone(args: list[str], env: dict, timeout: int) -> subprocess.CompletedProcess: + try: + result = subprocess.run(["rclone", *args], env=env, capture_output=True, text=True, timeout=timeout) + except FileNotFoundError as e: + raise BackupError("Утиліту rclone не знайдено на сервері (потрібно: apt install rclone)") from e + except subprocess.TimeoutExpired as e: + raise BackupError(f"Команда rclone не відповіла за {timeout}с") from e + if result.returncode != 0: + raise BackupError((result.stderr or "").strip() or f"rclone завершився з кодом {result.returncode}") + return result + + +def test_connection() -> tuple[bool, str]: + """Verifies the configured remote's credentials work and the target + path is reachable, without uploading anything.""" + try: + env, remote_path = _remote_env_and_path() + _rclone(["lsjson", remote_path], env, timeout=20) + return True, f"Підключення працює — «{remote_path}» доступний." + except BackupError as e: + return False, str(e) + except Exception as e: + return False, f"Помилка rclone: {e}" + + +def upload_backup(path: Path) -> None: + """Uploads a local backup file to the configured remote.""" + env, remote_path = _remote_env_and_path() + _rclone(["copy", str(path), remote_path], env, timeout=300) + + +def rotate_backups(name_contains: str) -> int: + """Deletes the oldest remote backups beyond backup_remote_retention_count, + among files whose name contains `name_contains` — DB snapshots and + uploads archives are rotated as separate groups (by their distinct + filename patterns below) so uploading one kind never evicts the other + out of retention. Returns how many were deleted.""" + env, remote_path = _remote_env_and_path() + retention = max(Setting.get_int("backup_remote_retention_count", 14), 1) + + result = _rclone(["lsjson", remote_path], env, timeout=30) + try: + entries = json.loads(result.stdout or "[]") + except json.JSONDecodeError as e: + raise BackupError(f"Не вдалося розібрати список файлів rclone: {e}") from e + + matching = [e for e in entries if not e.get("IsDir") and name_contains in e.get("Name", "")] + matching.sort(key=lambda e: e.get("ModTime", ""), reverse=True) + stale = matching[retention:] + for entry in stale: + _rclone(["deletefile", f"{remote_path}/{entry['Name']}"], env, timeout=30) + return len(stale) + + +def create_local_backup() -> Path | None: + """Mirrors admin/system.py's local-backup logic — kept here instead of + imported from there so this module (utils/) never depends on admin/.""" + uri = config.SQLALCHEMY_DATABASE_URI + if not uri.startswith("sqlite:///"): + return None + sqlite_path = Path(uri.replace("sqlite:///", "", 1)) + if not sqlite_path.exists(): + return None + backup_dir = BASE_DIR / "backups" + backup_dir.mkdir(exist_ok=True) + stamp = datetime.now(_tz).strftime("%Y-%m-%d_%H%M%S") + dest = backup_dir / f"bober_bbq-{stamp}-backup.db" + shutil.copy2(sqlite_path, dest) + # copy2 preserves the SOURCE db's mtime — reset it to "now" so the + # backups list shows when this snapshot was actually taken. + os.utime(dest, None) + + # A backup nobody can actually restore from is worse than no backup — + # it looks like protection while quietly being useless. Verify the copy + # itself before it's ever uploaded, not just that the copy succeeded. + # The connection must be closed before any unlink attempt below — on + # Windows an open sqlite3 connection holds a file lock that makes + # unlink() raise PermissionError even after the exception is caught. + conn = None + try: + conn = sqlite3.connect(str(dest)) + result = conn.execute("PRAGMA integrity_check").fetchone() + except sqlite3.Error as e: + if conn is not None: + conn.close() + dest.unlink(missing_ok=True) + raise BackupError(f"Резервна копія пошкоджена (не вдалось відкрити): {e}") + else: + conn.close() + if not result or result[0] != "ok": + dest.unlink(missing_ok=True) + raise BackupError(f"Резервна копія пошкоджена (integrity check: {result[0] if result else '?'})") + + return dest + + +def create_uploads_archive() -> Path | None: + """Zips bober_bbq/static/uploads/ (product photos) — these were never + part of the backup before, so a DB restore alone couldn't bring a + client's menu images back. Returns None if the folder is missing or + has nothing but the .gitkeep placeholder in it.""" + uploads_dir = config.UPLOAD_FOLDER + if not uploads_dir.is_dir(): + return None + has_real_files = any(p.is_file() and p.name != ".gitkeep" for p in uploads_dir.rglob("*")) + if not has_real_files: + return None + + backup_dir = BASE_DIR / "backups" + backup_dir.mkdir(exist_ok=True) + stamp = datetime.now(_tz).strftime("%Y-%m-%d_%H%M%S") + archive_base = backup_dir / f"bober_bbq-uploads-{stamp}" + archive_path = Path(shutil.make_archive(str(archive_base), "zip", root_dir=uploads_dir)) + return archive_path + + +def backup_now() -> tuple[bool, str]: + """Backs up immediately, bypassing the interval check — used by the + admin panel's "Backup зараз" button and by the scheduled job below.""" + try: + local_path = create_local_backup() + if local_path is None: + return False, "Резервне копіювання зараз підтримується лише для SQLite." + upload_backup(local_path) + deleted = rotate_backups("-backup.db") + + uploads_path = create_uploads_archive() + if uploads_path is not None: + upload_backup(uploads_path) + deleted += rotate_backups("bober_bbq-uploads-") + + Setting.set("backup_remote_last_backup_at", datetime.utcnow().isoformat()) + Setting.set("backup_remote_last_backup_status", "ok") + db.session.commit() + message = f"Резервну копію завантажено: {local_path.name}" + if uploads_path is not None: + message += f" + {uploads_path.name}" + if deleted: + message += f" (видалено {deleted} застарілих копій)" + logger.info(message) + return True, message + except BackupError as e: + Setting.set("backup_remote_last_backup_status", f"error: {e}") + db.session.commit() + logger.warning("Remote backup failed: %s", e) + return False, str(e) + except Exception as e: + Setting.set("backup_remote_last_backup_status", f"error: {e}") + db.session.commit() + logger.exception("Remote backup failed") + return False, f"Помилка rclone: {e}" + + +def run_scheduled_backup() -> None: + """Called periodically by APScheduler — only actually backs up once + the configured interval has elapsed since the last successful run.""" + if not backup_remote_enabled(): + return + + interval_hours = Setting.get_int("backup_remote_interval_hours", 24) + last_raw = Setting.get("backup_remote_last_backup_at", "") + if last_raw: + try: + last = datetime.fromisoformat(last_raw) + if datetime.utcnow() - last < timedelta(hours=interval_hours): + return + except ValueError: + pass + + ok, message = backup_now() + if not ok: + for owner_id in config.OWNER_IDS: + send_message( + owner_id, + f"⚠️ Не вдалося зробити резервну копію\n\n{message}\n\n" + "Перевірте підключення: Адмінка → Резервні копії.", + parse_mode="HTML", + ) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index db9aa1a..34bbc0c 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -21,13 +21,17 @@ ## 1. Підготовка сервера ```bash -sudo apt update && sudo apt install -y python3.11 python3.11-venv nginx certbot python3-certbot-nginx nodejs npm fonts-dejavu-core +sudo apt update && sudo apt install -y python3.11 python3.11-venv nginx certbot python3-certbot-nginx nodejs npm fonts-dejavu-core rclone ``` `fonts-dejavu-core` — потрібен лише для PDF-відомостей постачальників (кирилична TTF-шрифтова пара для `fpdf2`, `bober_bbq/utils/supplier_reports.py`). Excel-відомості й решта застосунку без нього працюють нормально. +`rclone` — потрібен лише для автоматичних резервних копій у хмару +(`bober_bbq/utils/backup_remote.py`, налаштовується в Адмінка → Резервні +копії). Локальні бекапи/відновлення на цій сторінці працюють і без нього. + ## 2. Код і залежності ```bash @@ -146,15 +150,26 @@ sudo systemctl enable --now ## 7. Резервне копіювання -SQLite: досить копіювати файл `instance/bober_bbq.db` за розкладом (cron): +Основний спосіб — вбудована система в самій адмінці (Адмінка → Резервні +копії): періодично (не cron, а внутрішній APScheduler-job в `run_web.py`, +щогодини перевіряє, чи настав час) знімає копію SQLite-бази і `static/ +uploads/` (фото товарів), заливає обране сховище через `rclone` і +ротує старі копії — усе налаштовується прямо у формі (Google Drive або +S3-сумісне сховище: AWS S3 / Backblaze B2 / Wasabi / MinIO / інше), без +жодного `rclone.conf` на диску сервера. Дивись +`bober_bbq/utils/backup_remote.py`. Вимагає встановленого `rclone` +(крок 1 вище). + +Резервний варіант без хмари — просте копіювання файлу за розкладом (cron): ```bash 0 3 * * * cp $INSTALL_DIR/instance/bober_bbq.db /opt/backups/bober_bbq-$(date +\%F).db ``` -Postgres: стандартний `pg_dump` за розкладом. Не забудьте також бекапити -`bober_bbq/static/uploads/` (фото товарів) — БД зберігає лише посилання -на файли. +Postgres: стандартний `pg_dump` за розкладом (вбудована система в адмінці +поки що підтримує лише SQLite — див. TODO нижче). Не забудьте також +бекапити `bober_bbq/static/uploads/` (фото товарів) — БД зберігає лише +посилання на файли. ## 8. Оновлення коду @@ -298,13 +313,13 @@ sudo systemctl start ### ⚠️ TODO перед реальним переходом: автоматичні бекапи не підтримують Postgres -Уся наявна інфраструктура резервного копіювання (планові бекапи в Google -Drive + кнопки «Backup зараз» / «Відновити» в адмінці) жорстко зав'язана +Уся наявна інфраструктура резервного копіювання (планові бекапи через +rclone + кнопки «Backup зараз» / «Відновити» в адмінці) жорстко зав'язана на SQLite і **мовчки (або з явним попередженням) вимикається**, якщо `DATABASE_URL` вказує на Postgres. Це не гіпотетична проблема — обидва місця вже сьогодні перевіряють тип БД і відмовляються працювати: -- **`bober_bbq/utils/gdrive_backup.py`, `create_local_backup()`** — +- **`bober_bbq/utils/backup_remote.py`, `create_local_backup()`** — перевіряє `config.SQLALCHEMY_DATABASE_URI.startswith("sqlite:///")` і, якщо ні, просто повертає `None` — плановий бекап (`run_scheduled_backup`, раз на годину через APScheduler у `run_web.py`) тихо нічого не робить. @@ -322,14 +337,14 @@ Drive + кнопки «Backup зараз» / «Відновити» в адмі **Що конкретно треба зробити, коли дійде до реального переходу:** -1. У `gdrive_backup.py` додати гілку для Postgres поруч із наявною +1. У `backup_remote.py` додати гілку для Postgres поруч із наявною SQLite-гілкою в `create_local_backup()`: викликати `pg_dump` у форматі custom (`pg_dump -Fc`, а не звичайний SQL-дамп — компактніше і відновлюється через `pg_restore`, без ручного `psql < file.sql`). Результат — файл на диску, той самий контракт, що й зараз - (`Path | None`), щоб решта пайплайна (завантаження в Drive, + (`Path | None`), щоб решта пайплайна (завантаження через rclone, `rotate_backups()`) запрацювала без змін. -2. Додати перевірку цілісності дампу перед завантаженням у Drive — +2. Додати перевірку цілісності дампу перед завантаженням — Postgres-аналог наявного `PRAGMA integrity_check` для SQLite. Найпростіший варіант: `pg_restore --list <файл>` на щойно створеному дампі — команда завершується з помилкою, якщо файл пошкоджений/обрізаний, не diff --git a/docs/TESTING.md b/docs/TESTING.md index c5257cf..6dc650e 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -61,9 +61,10 @@ explicitly reset between tests — see `_reset_module_level_state()` in only the *webhook handler's* payment-status logic is tested, with signature verification monkeypatched out. Testing the actual HTTP calls would need a fake Monobank server. -- **Google Drive backups** (`bober_bbq/utils/gdrive_backup.py`) — needs a - real (or mocked) Google service account and Drive API, neither of which - is set up for tests. +- **Remote backups' actual cloud calls** (`bober_bbq/utils/backup_remote.py`) + — `tests/test_backup_remote.py` covers the env/path construction per + storage type and the rotation logic with `subprocess.run` mocked, but + never shells out to a real `rclone` binary or a real cloud provider. - **The scheduled jobs' timing/APScheduler wiring itself** in `run_web.py` (only the job *functions* they call are tested directly, e.g. `reconcile_pending_payments()`). diff --git a/provision_client.sh b/provision_client.sh index 4cff315..85fd871 100644 --- a/provision_client.sh +++ b/provision_client.sh @@ -5,8 +5,8 @@ # that runbook by hand. # # Prerequisite (docs/DEPLOYMENT.md step 1, one-time OS setup, not run by -# this script): python3.11, nginx, certbot, nodejs/npm already installed -# on this server. +# this script): python3.11, nginx, certbot, nodejs/npm, rclone already +# installed on this server. # # Never touches an existing deployment: refuses to run if --install-dir # already exists, and every irreversible/system-level step (nginx reload, @@ -206,7 +206,8 @@ cat < Меню: видалити демо-меню (Шашлик/BBQ), додати асортимент клієнта. 7. Зареєструвати https://${DOMAIN}/health в UptimeRobot (чи аналог) — вручну, акаунт створює власник бізнесу, не цей скрипт. -8. Резервні копії: Адмінка -> Резервні копії -> увімкнути Google Drive backup - (або окремий cron — docs/DEPLOYMENT.md, крок 7). +8. Резервні копії: Адмінка -> Резервні копії -> налаштувати сховище (Google + Drive або S3-сумісне) і увімкнути (або окремий cron — docs/DEPLOYMENT.md, + крок 7). EOF diff --git a/requirements.txt b/requirements.txt index c259249..91fa3ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,6 @@ cryptography>=42.0 Pillow>=10.0 openpyxl>=3.1,<4 xlrd>=2.0,<3 -google-api-python-client>=2.100,<3 -google-auth>=2.23,<3 -google-auth-httplib2>=0.2,<1 fpdf2>=2.7,<3 +# Off-site backups (bober_bbq/utils/backup_remote.py) shell out to the +# `rclone` binary — install it on the OS, not here (see docs/DEPLOYMENT.md). diff --git a/run_web.py b/run_web.py index f514203..2cd209d 100644 --- a/run_web.py +++ b/run_web.py @@ -17,8 +17,8 @@ from bober_bbq.migrate import migrate from bober_bbq.models import Setting from bober_bbq.seed import seed from bober_bbq.utils import error_alerts +from bober_bbq.utils.backup_remote import run_scheduled_backup from bober_bbq.utils.checkbox_prro import maybe_close_shift -from bober_bbq.utils.gdrive_backup import run_scheduled_backup from bober_bbq.utils.reconciliation import ( check_liqpay_token_health, check_monobank_token_health, @@ -95,10 +95,10 @@ if __name__ == "__main__": id="liqpay_token_health", ) scheduler.add_job( - lambda: _run_in_app_context(run_scheduled_backup, "gdrive_backup"), + lambda: _run_in_app_context(run_scheduled_backup, "backup_remote"), "interval", hours=1, - id="gdrive_backup", + id="backup_remote", ) scheduler.add_job( lambda: _run_in_app_context(maybe_close_shift, "checkbox_auto_close_shift"), diff --git a/tests/test_backup_remote.py b/tests/test_backup_remote.py new file mode 100644 index 0000000..993d790 --- /dev/null +++ b/tests/test_backup_remote.py @@ -0,0 +1,342 @@ +"""Off-site backups via rclone (bober_bbq/utils/backup_remote.py) — env/path +construction per storage type, rotation logic, and the admin settings +routes. `subprocess.run` is mocked throughout: there's no real rclone +binary or cloud credentials in the test environment, by design (see +docs/TESTING.md). +""" + +import subprocess + +import pytest + +from bober_bbq.config import config +from bober_bbq.extensions import db +from bober_bbq.models import Setting +from bober_bbq.utils import backup_remote + + +def _admin_login(client, app): + with app.app_context(): + username, password = config.FLASK_ADMIN_USERNAME, config.FLASK_ADMIN_PASSWORD + return client.post("/admin/login", data={"username": username, "password": password}, follow_redirects=True) + + +def _completed(returncode=0, stdout="", stderr=""): + return subprocess.CompletedProcess(args=["rclone"], returncode=returncode, stdout=stdout, stderr=stderr) + + +# --------------------------------------------------------------------------- +# _remote_env_and_path() — per storage type +# --------------------------------------------------------------------------- + + +def test_gdrive_env_and_path(app): + with app.app_context(): + Setting.set("backup_remote_type", "gdrive") + Setting.set("backup_gdrive_service_account_json", '{"type": "service_account"}') + Setting.set("backup_gdrive_folder_id", "FOLDER123") + db.session.commit() + + env, path = backup_remote._remote_env_and_path() + assert env["RCLONE_CONFIG_BACKUP_TYPE"] == "drive" + assert env["RCLONE_CONFIG_BACKUP_SERVICE_ACCOUNT_CREDENTIALS"] == '{"type": "service_account"}' + assert env["RCLONE_CONFIG_BACKUP_ROOT_FOLDER_ID"] == "FOLDER123" + assert path == "backup:" + + +def test_s3_env_and_path(app): + with app.app_context(): + Setting.set("backup_remote_type", "s3") + Setting.set("backup_s3_provider", "Other") + Setting.set("backup_s3_access_key_id", "AKIA123") + Setting.set("backup_s3_secret_access_key", "secret") + Setting.set("backup_s3_endpoint", "s3.example.com") + Setting.set("backup_s3_region", "eu-central-1") + Setting.set("backup_s3_bucket", "mybucket") + Setting.set("backup_s3_prefix", "backups") + db.session.commit() + + env, path = backup_remote._remote_env_and_path() + assert env["RCLONE_CONFIG_BACKUP_TYPE"] == "s3" + assert env["RCLONE_CONFIG_BACKUP_PROVIDER"] == "Other" + assert env["RCLONE_CONFIG_BACKUP_ACCESS_KEY_ID"] == "AKIA123" + assert env["RCLONE_CONFIG_BACKUP_SECRET_ACCESS_KEY"] == "secret" + assert env["RCLONE_CONFIG_BACKUP_ENDPOINT"] == "s3.example.com" + assert env["RCLONE_CONFIG_BACKUP_REGION"] == "eu-central-1" + assert path == "backup:mybucket/backups" + + +def test_s3_blank_endpoint_omitted_for_real_aws(app): + with app.app_context(): + Setting.set("backup_remote_type", "s3") + Setting.set("backup_s3_access_key_id", "AKIA123") + Setting.set("backup_s3_secret_access_key", "secret") + Setting.set("backup_s3_bucket", "mybucket") + Setting.set("backup_s3_endpoint", "") + db.session.commit() + + env, path = backup_remote._remote_env_and_path() + assert "RCLONE_CONFIG_BACKUP_ENDPOINT" not in env + assert path == "backup:mybucket" + + +def test_custom_env_and_path(app): + with app.app_context(): + Setting.set("backup_remote_type", "custom") + Setting.set("backup_custom_type", "sftp") + Setting.set("backup_custom_config", "host = example.com\nuser = deploy\n\npass = secret") + Setting.set("backup_custom_path", "srv/backups") + db.session.commit() + + env, path = backup_remote._remote_env_and_path() + assert env["RCLONE_CONFIG_BACKUP_TYPE"] == "sftp" + assert env["RCLONE_CONFIG_BACKUP_HOST"] == "example.com" + assert env["RCLONE_CONFIG_BACKUP_USER"] == "deploy" + assert env["RCLONE_CONFIG_BACKUP_PASS"] == "secret" + assert path == "backup:srv/backups" + + +def test_empty_remote_type_raises_before_any_subprocess_call(app, monkeypatch): + called = [] + monkeypatch.setattr(backup_remote.subprocess, "run", lambda *a, **kw: called.append(1)) + + with app.app_context(): + Setting.set("backup_remote_type", "") + db.session.commit() + with pytest.raises(backup_remote.BackupError): + backup_remote._remote_env_and_path() + + assert not called, "must fail fast without ever invoking rclone" + + +# --------------------------------------------------------------------------- +# rotate_backups() +# --------------------------------------------------------------------------- + + +def test_rotate_backups_deletes_only_oldest_excess_of_matching_group(app, monkeypatch): + listing = [ + {"Name": "bober_bbq-2026-01-01_000000-backup.db", "ModTime": "2026-01-01T00:00:00Z", "IsDir": False}, + {"Name": "bober_bbq-2026-01-02_000000-backup.db", "ModTime": "2026-01-02T00:00:00Z", "IsDir": False}, + {"Name": "bober_bbq-2026-01-03_000000-backup.db", "ModTime": "2026-01-03T00:00:00Z", "IsDir": False}, + # Unrelated group — must never be touched by a "-backup.db" rotation. + {"Name": "bober_bbq-uploads-2026-01-01_000000.zip", "ModTime": "2026-01-01T00:00:00Z", "IsDir": False}, + ] + import json as json_module + + deleted = [] + + def fake_run(cmd, env, capture_output, text, timeout): + if cmd[1] == "lsjson": + return _completed(stdout=json_module.dumps(listing)) + if cmd[1] == "deletefile": + deleted.append(cmd[2]) + return _completed() + raise AssertionError(f"unexpected rclone call: {cmd}") + + monkeypatch.setattr(backup_remote.subprocess, "run", fake_run) + + with app.app_context(): + Setting.set("backup_remote_type", "s3") + Setting.set("backup_s3_access_key_id", "k") + Setting.set("backup_s3_secret_access_key", "s") + Setting.set("backup_s3_bucket", "b") + Setting.set("backup_remote_retention_count", "2") + db.session.commit() + + deleted_count = backup_remote.rotate_backups("-backup.db") + + assert deleted_count == 1 + assert deleted == ["backup:b/bober_bbq-2026-01-01_000000-backup.db"] + + +def test_upload_backup_invokes_rclone_copy(app, monkeypatch, tmp_path): + calls = [] + + def fake_run(cmd, env, capture_output, text, timeout): + calls.append(cmd) + return _completed() + + monkeypatch.setattr(backup_remote.subprocess, "run", fake_run) + + local_file = tmp_path / "snapshot.db" + local_file.write_text("x") + + with app.app_context(): + Setting.set("backup_remote_type", "s3") + Setting.set("backup_s3_access_key_id", "k") + Setting.set("backup_s3_secret_access_key", "s") + Setting.set("backup_s3_bucket", "b") + db.session.commit() + backup_remote.upload_backup(local_file) + + assert calls == [["rclone", "copy", str(local_file), "backup:b"]] + + +# --------------------------------------------------------------------------- +# test_connection() +# --------------------------------------------------------------------------- + + +def test_connection_failure_returns_stderr_message(app, monkeypatch): + monkeypatch.setattr( + backup_remote.subprocess, "run", lambda *a, **kw: _completed(returncode=1, stderr="access denied") + ) + with app.app_context(): + Setting.set("backup_remote_type", "s3") + Setting.set("backup_s3_access_key_id", "k") + Setting.set("backup_s3_secret_access_key", "s") + Setting.set("backup_s3_bucket", "b") + db.session.commit() + ok, message = backup_remote.test_connection() + assert ok is False + assert "access denied" in message + + +def test_connection_success(app, monkeypatch): + monkeypatch.setattr(backup_remote.subprocess, "run", lambda *a, **kw: _completed(returncode=0, stdout="[]")) + with app.app_context(): + Setting.set("backup_remote_type", "s3") + Setting.set("backup_s3_access_key_id", "k") + Setting.set("backup_s3_secret_access_key", "s") + Setting.set("backup_s3_bucket", "b") + db.session.commit() + ok, message = backup_remote.test_connection() + assert ok is True + + +def test_missing_rclone_binary_is_a_clean_error(app, monkeypatch): + def fake_run(*a, **kw): + raise FileNotFoundError("no such file") + + monkeypatch.setattr(backup_remote.subprocess, "run", fake_run) + with app.app_context(): + Setting.set("backup_remote_type", "s3") + Setting.set("backup_s3_access_key_id", "k") + Setting.set("backup_s3_secret_access_key", "s") + Setting.set("backup_s3_bucket", "b") + db.session.commit() + ok, message = backup_remote.test_connection() + assert ok is False + assert "apt install rclone" in message + + +def test_rclone_timeout_is_a_clean_error(app, monkeypatch): + def fake_run(*a, **kw): + raise subprocess.TimeoutExpired(cmd="rclone", timeout=20) + + monkeypatch.setattr(backup_remote.subprocess, "run", fake_run) + with app.app_context(): + Setting.set("backup_remote_type", "s3") + Setting.set("backup_s3_access_key_id", "k") + Setting.set("backup_s3_secret_access_key", "s") + Setting.set("backup_s3_bucket", "b") + db.session.commit() + ok, message = backup_remote.test_connection() + assert ok is False + + +# --------------------------------------------------------------------------- +# Admin routes +# --------------------------------------------------------------------------- + + +def test_remote_settings_route_persists_s3_fields(app): + client = app.test_client() + _admin_login(client, app) + + resp = client.post( + "/admin/backups/remote-settings", + data={ + "backup_remote_enabled": "on", + "backup_remote_type": "s3", + "backup_remote_interval_hours": "12", + "backup_remote_retention_count": "7", + "backup_s3_provider": "Wasabi", + "backup_s3_access_key_id": "AKIA1", + "backup_s3_secret_access_key": "secret1", + "backup_s3_endpoint": "s3.wasabisys.com", + "backup_s3_region": "", + "backup_s3_bucket": "mybucket", + "backup_s3_prefix": "", + }, + follow_redirects=True, + ) + assert resp.status_code == 200 + + with app.app_context(): + assert Setting.get("backup_remote_enabled") == "1" + assert Setting.get("backup_remote_type") == "s3" + assert Setting.get("backup_remote_interval_hours") == "12" + assert Setting.get("backup_s3_provider") == "Wasabi" + assert Setting.get("backup_s3_access_key_id") == "AKIA1" + assert Setting.get("backup_s3_bucket") == "mybucket" + + +def test_switching_remote_type_does_not_wipe_other_types_values(app): + client = app.test_client() + _admin_login(client, app) + + client.post( + "/admin/backups/remote-settings", + data={ + "backup_remote_type": "gdrive", + "backup_gdrive_service_account_json": '{"a": 1}', + "backup_gdrive_folder_id": "FID", + "backup_s3_access_key_id": "", + "backup_s3_secret_access_key": "", + "backup_s3_bucket": "", + }, + follow_redirects=True, + ) + client.post( + "/admin/backups/remote-settings", + data={ + "backup_remote_type": "s3", + # A real form posts every field every time (hidden inputs still + # submit) — the gdrive fields ride along unchanged here. + "backup_gdrive_service_account_json": '{"a": 1}', + "backup_gdrive_folder_id": "FID", + "backup_s3_access_key_id": "AKIA2", + "backup_s3_secret_access_key": "secret2", + "backup_s3_bucket": "otherbucket", + }, + follow_redirects=True, + ) + + with app.app_context(): + assert Setting.get("backup_remote_type") == "s3" + assert Setting.get("backup_gdrive_service_account_json") == '{"a": 1}' + assert Setting.get("backup_gdrive_folder_id") == "FID" + assert Setting.get("backup_s3_access_key_id") == "AKIA2" + + +def test_remote_test_with_no_type_configured_fails_fast(app, monkeypatch): + called = [] + monkeypatch.setattr(backup_remote.subprocess, "run", lambda *a, **kw: called.append(1)) + + client = app.test_client() + _admin_login(client, app) + with app.app_context(): + Setting.set("backup_remote_type", "") + db.session.commit() + + resp = client.post("/admin/backups/remote-test", follow_redirects=True) + assert resp.status_code == 200 + assert not called, "must not shell out to rclone when nothing is configured" + + +def test_remote_backup_now_requires_enabled_flag(app, monkeypatch): + called = [] + monkeypatch.setattr(backup_remote.subprocess, "run", lambda *a, **kw: called.append(1)) + + client = app.test_client() + _admin_login(client, app) + with app.app_context(): + Setting.set("backup_remote_enabled", "0") + db.session.commit() + + resp = client.post("/admin/backups/remote-backup-now", follow_redirects=True) + assert resp.status_code == 200 + assert "Спершу увімкніть" in resp.get_data(as_text=True) + assert not called