chore: tune backup function and related docs

This commit is contained in:
3252a8
2026-05-27 14:11:22 +03:00
parent 0250264fa0
commit 4bd547f06a
13 changed files with 56 additions and 143 deletions
-13
View File
@@ -70,16 +70,3 @@ FRONTEND_PORT=8082
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For. # Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
# Keep loopback for local proxy; add your proxy network if needed. # Keep loopback for local proxy; add your proxy network if needed.
TRUSTED_PROXIES=127.0.0.1,::1 TRUSTED_PROXIES=127.0.0.1,::1
# Periodic backups from the worker container. Disabled by default.
# The archive contains a pg_dump custom-format dump and a snapshot of the
# compose directory mounted read-only into /app/compose-source.
BACKUP_ENABLED=False
BACKUP_CHAT_ID=
BACKUP_INTERVAL_SECONDS=3600
BACKUP_LOCAL_RETENTION=100
BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True
BACKUP_ARCHIVE_SIGNATURE_SECRET=
BACKUP_COMPOSE_ENABLED=True
COMPOSE_BACKUP_SOURCE=.
COMPOSE_RESTORE_MODE=rw
-40
View File
@@ -1,53 +1,15 @@
import hashlib import hashlib
import hmac
import json import json
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from config.settings import Settings
BACKUP_APP_ID = "remnawave-minishop" BACKUP_APP_ID = "remnawave-minishop"
BACKUP_FILENAME_PREFIX = "remnawave-minishop-backup-" BACKUP_FILENAME_PREFIX = "remnawave-minishop-backup-"
BACKUP_FORMAT_VERSION = 1 BACKUP_FORMAT_VERSION = 1
BACKUP_MANIFEST_NAME = "manifest.json" BACKUP_MANIFEST_NAME = "manifest.json"
def backup_signature_secret(settings: Settings) -> str:
configured = str(getattr(settings, "BACKUP_ARCHIVE_SIGNATURE_SECRET", "") or "").strip()
return configured or settings.BOT_TOKEN
def canonical_manifest_payload(manifest: dict[str, Any]) -> bytes:
payload = json.loads(json.dumps(manifest, ensure_ascii=False))
archive = payload.get("archive")
if isinstance(archive, dict):
archive.pop("signature", None)
return json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
def sign_manifest(manifest: dict[str, Any], settings: Settings) -> str:
return hmac.new(
backup_signature_secret(settings).encode("utf-8"),
canonical_manifest_payload(manifest),
hashlib.sha256,
).hexdigest()
def verify_manifest_signature(manifest: dict[str, Any], settings: Settings) -> bool:
archive = manifest.get("archive") if isinstance(manifest.get("archive"), dict) else {}
signature = str(archive.get("signature") or "")
if not signature:
return False
expected = sign_manifest(manifest, settings)
return hmac.compare_digest(signature, expected)
def file_sha256(path: Path) -> str: def file_sha256(path: Path) -> str:
digest = hashlib.sha256() digest = hashlib.sha256()
with path.open("rb") as handle: with path.open("rb") as handle:
@@ -79,14 +41,12 @@ def attach_archive_integrity(
manifest: dict[str, Any], manifest: dict[str, Any],
*, *,
file_records: list[dict[str, Any]], file_records: list[dict[str, Any]],
settings: Settings,
) -> None: ) -> None:
manifest["app"] = BACKUP_APP_ID manifest["app"] = BACKUP_APP_ID
manifest["format_version"] = BACKUP_FORMAT_VERSION manifest["format_version"] = BACKUP_FORMAT_VERSION
manifest["archive"] = { manifest["archive"] = {
"files": file_records, "files": file_records,
} }
manifest["archive"]["signature"] = sign_manifest(manifest, settings)
def write_manifest(source_dir: Path, manifest: dict[str, Any]) -> None: def write_manifest(source_dir: Path, manifest: dict[str, Any]) -> None:
@@ -21,7 +21,6 @@ from bot.services.backup_archive import (
BACKUP_MANIFEST_NAME, BACKUP_MANIFEST_NAME,
attach_archive_integrity, attach_archive_integrity,
build_file_records, build_file_records,
verify_manifest_signature,
write_manifest, write_manifest,
write_zip_from_directory, write_zip_from_directory,
) )
@@ -153,16 +152,6 @@ class BackupRestoreService:
with zipfile.ZipFile(archive_path) as archive: with zipfile.ZipFile(archive_path) as archive:
self._validate_zip_members(archive.infolist()) self._validate_zip_members(archive.infolist())
manifest = self._read_manifest(archive) manifest = self._read_manifest(archive)
signature_valid = self._archive_signature_valid(manifest)
signature_required = getattr(
self.settings,
"BACKUP_ARCHIVE_SIGNATURE_REQUIRED",
True,
)
if signature_required and not signature_valid:
raise BackupArchiveError("Archive manifest signature is not valid")
if not signature_valid:
warnings.append("manifest signature is not valid")
has_database = self._find_database_dump_member(archive) is not None has_database = self._find_database_dump_member(archive) is not None
compose_members = self._compose_file_members(archive) compose_members = self._compose_file_members(archive)
@@ -401,7 +390,6 @@ class BackupRestoreService:
attach_archive_integrity( attach_archive_integrity(
manifest, manifest,
file_records=build_file_records(staging_dir), file_records=build_file_records(staging_dir),
settings=self.settings,
) )
write_manifest(staging_dir, manifest) write_manifest(staging_dir, manifest)
tmp_archive = archive_path.with_name(f"{archive_path.name}.tmp") tmp_archive = archive_path.with_name(f"{archive_path.name}.tmp")
@@ -551,18 +539,12 @@ class BackupRestoreService:
raise BackupArchiveError("Archive manifest format is not supported") raise BackupArchiveError("Archive manifest format is not supported")
return manifest return manifest
def _archive_signature_valid(self, manifest: dict[str, Any]) -> bool:
return verify_manifest_signature(manifest, self.settings)
def _validate_archive_for_restore(self, archive_path: Path) -> None: def _validate_archive_for_restore(self, archive_path: Path) -> None:
if not zipfile.is_zipfile(archive_path): if not zipfile.is_zipfile(archive_path):
raise BackupArchiveError("Archive is not a valid ZIP file") raise BackupArchiveError("Archive is not a valid ZIP file")
with zipfile.ZipFile(archive_path) as archive: with zipfile.ZipFile(archive_path) as archive:
self._validate_zip_members(archive.infolist()) self._validate_zip_members(archive.infolist())
manifest = self._read_manifest(archive) manifest = self._read_manifest(archive)
if getattr(self.settings, "BACKUP_ARCHIVE_SIGNATURE_REQUIRED", True):
if not self._archive_signature_valid(manifest):
raise BackupArchiveError("Archive manifest signature is not valid")
self._validate_archive_integrity(archive, manifest) self._validate_archive_integrity(archive, manifest)
def _validate_archive_integrity( def _validate_archive_integrity(
-1
View File
@@ -179,7 +179,6 @@ class BackupWorker:
attach_archive_integrity( attach_archive_integrity(
manifest, manifest,
file_records=build_file_records(staging_dir), file_records=build_file_records(staging_dir),
settings=self.settings,
) )
write_manifest(staging_dir, manifest) write_manifest(staging_dir, manifest)
-3
View File
@@ -135,8 +135,6 @@ class Settings(BaseSettings):
BACKUP_PG_DUMP_TIMEOUT_SECONDS: int = Field(default=30 * 60) BACKUP_PG_DUMP_TIMEOUT_SECONDS: int = Field(default=30 * 60)
BACKUP_PG_RESTORE_PATH: str = Field(default="pg_restore") BACKUP_PG_RESTORE_PATH: str = Field(default="pg_restore")
BACKUP_PG_RESTORE_TIMEOUT_SECONDS: int = Field(default=30 * 60) BACKUP_PG_RESTORE_TIMEOUT_SECONDS: int = Field(default=30 * 60)
BACKUP_ARCHIVE_SIGNATURE_REQUIRED: bool = Field(default=True)
BACKUP_ARCHIVE_SIGNATURE_SECRET: Optional[str] = Field(default=None)
BACKUP_COMPOSE_ENABLED: bool = Field(default=True) BACKUP_COMPOSE_ENABLED: bool = Field(default=True)
BACKUP_COMPOSE_SOURCE_DIR: Optional[str] = Field(default="/app/compose-source") BACKUP_COMPOSE_SOURCE_DIR: Optional[str] = Field(default="/app/compose-source")
BACKUP_COMPOSE_RESTORE_DIR: Optional[str] = Field(default=None) BACKUP_COMPOSE_RESTORE_DIR: Optional[str] = Field(default=None)
@@ -1005,7 +1003,6 @@ class Settings(BaseSettings):
"SMTP_FROM_EMAIL", "SMTP_FROM_EMAIL",
"SMTP_FROM_NAME", "SMTP_FROM_NAME",
"SMTP_FALLBACK_PORTS", "SMTP_FALLBACK_PORTS",
"BACKUP_ARCHIVE_SIGNATURE_SECRET",
"BACKUP_COMPOSE_SOURCE_DIR", "BACKUP_COMPOSE_SOURCE_DIR",
"BACKUP_COMPOSE_RESTORE_DIR", "BACKUP_COMPOSE_RESTORE_DIR",
mode="before", mode="before",
-11
View File
@@ -38,14 +38,3 @@ PANEL_WEBHOOK_SECRET=change_me
# Caddy and Docker network ranges that may set X-Forwarded-For. # Caddy and Docker network ranges that may set X-Forwarded-For.
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12 TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
# Periodic backups from the worker container. Disabled by default.
BACKUP_ENABLED=False
BACKUP_CHAT_ID=
BACKUP_INTERVAL_SECONDS=3600
BACKUP_LOCAL_RETENTION=100
BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True
BACKUP_ARCHIVE_SIGNATURE_SECRET=
BACKUP_COMPOSE_ENABLED=True
COMPOSE_BACKUP_SOURCE=.
COMPOSE_RESTORE_MODE=rw
-11
View File
@@ -39,14 +39,3 @@ PANEL_WEBHOOK_SECRET=change_me
# Pangolin/Newt and Docker network ranges that may set X-Forwarded-For. # Pangolin/Newt and Docker network ranges that may set X-Forwarded-For.
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12 TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
# Periodic backups from the worker container. Disabled by default.
BACKUP_ENABLED=False
BACKUP_CHAT_ID=
BACKUP_INTERVAL_SECONDS=3600
BACKUP_LOCAL_RETENTION=100
BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True
BACKUP_ARCHIVE_SIGNATURE_SECRET=
BACKUP_COMPOSE_ENABLED=True
COMPOSE_BACKUP_SOURCE=.
COMPOSE_RESTORE_MODE=rw
-11
View File
@@ -38,14 +38,3 @@ PANEL_WEBHOOK_SECRET=change_me
# Nginx and Docker network ranges that may set X-Forwarded-For. # Nginx and Docker network ranges that may set X-Forwarded-For.
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12 TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
# Periodic backups from the worker container. Disabled by default.
BACKUP_ENABLED=False
BACKUP_CHAT_ID=
BACKUP_INTERVAL_SECONDS=3600
BACKUP_LOCAL_RETENTION=100
BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True
BACKUP_ARCHIVE_SIGNATURE_SECRET=
BACKUP_COMPOSE_ENABLED=True
COMPOSE_BACKUP_SOURCE=.
COMPOSE_RESTORE_MODE=rw
-11
View File
@@ -39,14 +39,3 @@ PANEL_API_KEY=change_me
PANEL_WEBHOOK_SECRET=change_me PANEL_WEBHOOK_SECRET=change_me
TRUSTED_PROXIES=127.0.0.1,::1 TRUSTED_PROXIES=127.0.0.1,::1
# Periodic backups from the worker container. Disabled by default.
BACKUP_ENABLED=False
BACKUP_CHAT_ID=
BACKUP_INTERVAL_SECONDS=3600
BACKUP_LOCAL_RETENTION=100
BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True
BACKUP_ARCHIVE_SIGNATURE_SECRET=
BACKUP_COMPOSE_ENABLED=True
COMPOSE_BACKUP_SOURCE=.
COMPOSE_RESTORE_MODE=rw
-2
View File
@@ -84,8 +84,6 @@
| `BACKUP_PG_DUMP_TIMEOUT_SECONDS` | Таймаут выполнения `pg_dump`. | | `BACKUP_PG_DUMP_TIMEOUT_SECONDS` | Таймаут выполнения `pg_dump`. |
| `BACKUP_PG_RESTORE_PATH` | Путь к `pg_restore` внутри backend-контейнера для восстановления из админки. | | `BACKUP_PG_RESTORE_PATH` | Путь к `pg_restore` внутри backend-контейнера для восстановления из админки. |
| `BACKUP_PG_RESTORE_TIMEOUT_SECONDS` | Таймаут выполнения `pg_restore`. | | `BACKUP_PG_RESTORE_TIMEOUT_SECONDS` | Таймаут выполнения `pg_restore`. |
| `BACKUP_ARCHIVE_SIGNATURE_REQUIRED` | Требовать валидную HMAC-подпись `manifest.json` при upload/restore. По умолчанию `True`. |
| `BACKUP_ARCHIVE_SIGNATURE_SECRET` | Отдельный секрет подписи backup-архивов. Если пусто, используется `BOT_TOKEN`. |
| `BACKUP_COMPOSE_ENABLED` | Добавлять snapshot compose-каталога в архив. Если mount отсутствует, бэкап БД не падает. | | `BACKUP_COMPOSE_ENABLED` | Добавлять snapshot compose-каталога в архив. Если mount отсутствует, бэкап БД не падает. |
| `BACKUP_COMPOSE_SOURCE_DIR` | Путь внутри контейнера к compose-каталогу. В стандартном compose это `/app/compose-source`. | | `BACKUP_COMPOSE_SOURCE_DIR` | Путь внутри контейнера к compose-каталогу. В стандартном compose это `/app/compose-source`. |
| `BACKUP_COMPOSE_RESTORE_DIR` | Куда восстанавливать compose-файлы. Если пусто, используется `BACKUP_COMPOSE_SOURCE_DIR`. | | `BACKUP_COMPOSE_RESTORE_DIR` | Куда восстанавливать compose-файлы. Если пусто, используется `BACKUP_COMPOSE_SOURCE_DIR`. |
+24 -13
View File
@@ -24,23 +24,26 @@ remnawave-minishop-backup-20260527-120000+0300.zip
Основные параметры доступны в админке: **Система -> Настройки -> Бэкапы**. Основные параметры доступны в админке: **Система -> Настройки -> Бэкапы**.
Минимальный `.env`: Минимальный `.env`, если `LOG_CHAT_ID` уже задан и подходит для бэкапов:
```env ```env
BACKUP_ENABLED=True BACKUP_ENABLED=True
BACKUP_CHAT_ID=-1001234567890
BACKUP_INTERVAL_SECONDS=3600
BACKUP_LOCAL_RETENTION=100
BACKUP_COMPOSE_ENABLED=True
COMPOSE_BACKUP_SOURCE=.
COMPOSE_RESTORE_MODE=rw
``` ```
`BACKUP_INTERVAL_SECONDS=3600` запускает бэкапы ровно на границе часа: 12:00, 13:00 и т.д. Значение по умолчанию для локального хранения - 100 последних ZIP-архивов. Если бэкапы нужно отправлять в отдельный чат или topic/thread, добавьте только нужные переменные:
```env
BACKUP_CHAT_ID=-1001234567890
BACKUP_THREAD_ID=123
```
Остальные backup-переменные обычно не нужны в `.env`: `BACKUP_INTERVAL_SECONDS=3600` запускает бэкапы ровно на границе часа 12:00, 13:00 и т.д.; `BACKUP_LOCAL_RETENTION=100` хранит 100 последних ZIP-архивов; `BACKUP_COMPOSE_ENABLED=True`, `COMPOSE_BACKUP_SOURCE=.` и `COMPOSE_RESTORE_MODE=rw` уже совпадают со стандартным compose-сценарием.
`BACKUP_CHAT_ID` задает чат Telegram для отправки архивов. Если он пустой, используется `LOG_CHAT_ID`. Для topic/thread можно указать `BACKUP_THREAD_ID`; если он пустой, используется `LOG_THREAD_ID`. `BACKUP_CHAT_ID` задает чат Telegram для отправки архивов. Если он пустой, используется `LOG_CHAT_ID`. Для topic/thread можно указать `BACKUP_THREAD_ID`; если он пустой, используется `LOG_THREAD_ID`.
Каждый архив подписывается HMAC-подписью в `manifest.json` и содержит SHA-256 каждого файла. По умолчанию restore принимает только архивы с валидной подписью этого инстанса. Если нужен отдельный стабильный ключ подписи, задайте `BACKUP_ARCHIVE_SIGNATURE_SECRET`; если ключ пустой, используется `BOT_TOKEN`. Каждый архив содержит `manifest.json` с SHA-256 и размером каждого файла. Это позволяет проверить, что архив не поврежден и его содержимое не отличается от manifest.
Архив не привязан к текущему инстансу, `BOT_TOKEN` или серверу. Его можно загрузить и восстановить на другом сервере, если формат архива поддерживается и проверки целостности проходят.
## Mount compose-папки ## Mount compose-папки
@@ -91,7 +94,6 @@ Backend валидирует архив до восстановления:
- файл должен быть валидным ZIP; - файл должен быть валидным ZIP;
- `manifest.json` должен принадлежать `remnawave-minishop` и иметь поддерживаемую версию формата; - `manifest.json` должен принадлежать `remnawave-minishop` и иметь поддерживаемую версию формата;
- HMAC-подпись manifest должна быть валидной, если `BACKUP_ARCHIVE_SIGNATURE_REQUIRED=True`;
- SHA-256 и размер каждого файла должны совпадать с manifest; - SHA-256 и размер каждого файла должны совпадать с manifest;
- выбранный server-side файл должен лежать внутри `BACKUP_DIR`, путь вида `../backup.zip` отклоняется; - выбранный server-side файл должен лежать внутри `BACKUP_DIR`, путь вида `../backup.zip` отклоняется;
- пути внутри ZIP не могут быть абсолютными, содержать `..`, `\`, пустые сегменты или дубли; - пути внутри ZIP не могут быть абсолютными, содержать `..`, `\`, пустые сегменты или дубли;
@@ -101,7 +103,18 @@ Backend валидирует архив до восстановления:
- compose restore стартует только если целевая папка существует и доступна на запись; - compose restore стартует только если целевая папка существует и доступна на запись;
- backup/restore защищены одним Redis lock, чтобы две операции не выполнялись одновременно. - backup/restore защищены одним Redis lock, чтобы две операции не выполнялись одновременно.
Это защищает от случайной загрузки мусорного файла, zip-slip-архивов, поврежденных ZIP и структурно похожих архивов, которые не были созданы этим инстансом. Если вы сознательно восстанавливаете старый неподписанный архив, временно выставьте `BACKUP_ARCHIVE_SIGNATURE_REQUIRED=False`, восстановите архив и верните проверку обратно. Это защищает от случайной загрузки мусорного файла, zip-slip-архивов и поврежденных ZIP. Проверка специально не привязана к секретам инстанса, чтобы архивы можно было использовать для переноса между серверами. Это не проверка доверенного источника: не восстанавливайте архивы, происхождение которых вы не контролируете.
## Перенос на другой сервер
Для переноса БД между инстансами:
1. Создайте backup на старом сервере или возьмите ZIP из Telegram.
2. На новом сервере загрузите архив в **Система -> Бэкапы**.
3. Выберите `БД`; `compose-папку` включайте только если хотите перенести `.env`, `docker-compose.yml` и proxy-конфиги.
4. Запустите restore и после восстановления выполните миграции/healthcheck.
Если переносите compose-папку, проверьте домены, токены, `WEBHOOK_BASE_URL`, `SUBSCRIPTION_MINI_APP_URL`, bind-порты и volume/mount пути: на новом сервере они могут отличаться.
## Ручное восстановление БД ## Ручное восстановление БД
@@ -134,8 +147,6 @@ docker compose logs -f backend worker
| `BACKUP_INTERVAL_SECONDS` | Периодичность, по умолчанию `3600`. | | `BACKUP_INTERVAL_SECONDS` | Периодичность, по умолчанию `3600`. |
| `BACKUP_LOCAL_RETENTION` | Сколько последних архивов хранить на сервере. | | `BACKUP_LOCAL_RETENTION` | Сколько последних архивов хранить на сервере. |
| `BACKUP_DIR` | Каталог ZIP-архивов. | | `BACKUP_DIR` | Каталог ZIP-архивов. |
| `BACKUP_ARCHIVE_SIGNATURE_REQUIRED` | Требовать валидную HMAC-подпись manifest при upload/restore. |
| `BACKUP_ARCHIVE_SIGNATURE_SECRET` | Отдельный секрет подписи архивов; если пустой, используется `BOT_TOKEN`. |
| `BACKUP_COMPOSE_ENABLED` | Добавлять compose snapshot. | | `BACKUP_COMPOSE_ENABLED` | Добавлять compose snapshot. |
| `COMPOSE_BACKUP_SOURCE` | Host-путь compose-папки для mount в контейнеры. | | `COMPOSE_BACKUP_SOURCE` | Host-путь compose-папки для mount в контейнеры. |
| `COMPOSE_RESTORE_MODE` | `rw` для восстановления compose из админки, `ro` для запрета записи. | | `COMPOSE_RESTORE_MODE` | `rw` для восстановления compose из админки, `ro` для запрета записи. |
+32 -7
View File
@@ -34,7 +34,6 @@ def _settings(tmp_path: Path, compose_dir: Path, **overrides) -> Settings:
def _write_backup_archive( def _write_backup_archive(
settings: Settings,
path: Path, path: Path,
*, *,
include_db=True, include_db=True,
@@ -65,7 +64,6 @@ def _write_backup_archive(
attach_archive_integrity( attach_archive_integrity(
manifest, manifest,
file_records=build_file_records(staging_dir), file_records=build_file_records(staging_dir),
settings=settings,
) )
write_manifest(staging_dir, manifest) write_manifest(staging_dir, manifest)
write_zip_from_directory(staging_dir, path) write_zip_from_directory(staging_dir, path)
@@ -80,7 +78,7 @@ def test_backup_restore_service_lists_archives_with_contents(tmp_path):
compose_dir.mkdir() compose_dir.mkdir()
settings = _settings(tmp_path, compose_dir) settings = _settings(tmp_path, compose_dir)
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip" archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
_write_backup_archive(settings, archive_path) _write_backup_archive(archive_path)
archives = BackupRestoreService(settings).list_archives() archives = BackupRestoreService(settings).list_archives()
@@ -106,7 +104,7 @@ def test_backup_restore_service_restores_compose_and_snapshots_current(tmp_path)
settings = _settings(tmp_path, compose_dir) settings = _settings(tmp_path, compose_dir)
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip" archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
_write_backup_archive(settings, archive_path, include_db=False) _write_backup_archive(archive_path, include_db=False)
service = BackupRestoreService(settings) service = BackupRestoreService(settings)
result = service.restore_archive_sync( result = service.restore_archive_sync(
@@ -131,7 +129,7 @@ def test_backup_restore_service_prevents_zip_slip_in_compose_restore(tmp_path):
compose_dir.mkdir() compose_dir.mkdir()
settings = _settings(tmp_path, compose_dir) settings = _settings(tmp_path, compose_dir)
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip" archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
_write_backup_archive(settings, archive_path, include_db=False, unsafe=True) _write_backup_archive(archive_path, include_db=False, unsafe=True)
with pytest.raises(BackupArchiveError): with pytest.raises(BackupArchiveError):
BackupRestoreService(settings).restore_archive_sync( BackupRestoreService(settings).restore_archive_sync(
@@ -148,7 +146,7 @@ def test_backup_restore_service_runs_pg_restore_for_dump(tmp_path):
compose_dir.mkdir() compose_dir.mkdir()
settings = _settings(tmp_path, compose_dir) settings = _settings(tmp_path, compose_dir)
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip" archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
_write_backup_archive(settings, archive_path, include_compose=False) _write_backup_archive(archive_path, include_compose=False)
service = BackupRestoreService(settings) service = BackupRestoreService(settings)
restored_payloads = [] restored_payloads = []
@@ -169,6 +167,33 @@ def test_backup_restore_service_runs_pg_restore_for_dump(tmp_path):
assert restored_payloads == [b"fake dump"] assert restored_payloads == [b"fake dump"]
def test_backup_restore_service_accepts_archive_from_another_instance(tmp_path):
compose_dir = tmp_path / "compose"
compose_dir.mkdir()
target_settings = _settings(tmp_path, compose_dir, BOT_TOKEN="target-token")
archive_path = Path(target_settings.BACKUP_DIR) / (
f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
)
_write_backup_archive(archive_path, include_compose=False)
service = BackupRestoreService(target_settings)
restored_payloads = []
def fake_pg_restore(dump_path: Path) -> None:
restored_payloads.append(dump_path.read_bytes())
service._run_pg_restore = fake_pg_restore
result = service.restore_archive_sync(
archive_path.name,
restore_database=True,
restore_compose=False,
)
assert result.database_restored is True
assert restored_payloads == [b"fake dump"]
def test_backup_restore_service_validates_uploaded_zip(tmp_path): def test_backup_restore_service_validates_uploaded_zip(tmp_path):
compose_dir = tmp_path / "compose" compose_dir = tmp_path / "compose"
compose_dir.mkdir() compose_dir.mkdir()
@@ -185,7 +210,7 @@ def test_backup_restore_service_rejects_tampered_archive(tmp_path):
compose_dir.mkdir() compose_dir.mkdir()
settings = _settings(tmp_path, compose_dir) settings = _settings(tmp_path, compose_dir)
archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip" archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip"
_write_backup_archive(settings, archive_path, include_compose=False) _write_backup_archive(archive_path, include_compose=False)
tampered_path = archive_path.with_name("tampered.zip") tampered_path = archive_path.with_name("tampered.zip")
with zipfile.ZipFile(archive_path) as source, zipfile.ZipFile(tampered_path, "w") as target: with zipfile.ZipFile(archive_path) as source, zipfile.ZipFile(tampered_path, "w") as target:
-2
View File
@@ -209,8 +209,6 @@ class SettingsTests(unittest.TestCase):
self.assertEqual(settings.BACKUP_COMPOSE_SOURCE_DIR, "/app/compose-source") self.assertEqual(settings.BACKUP_COMPOSE_SOURCE_DIR, "/app/compose-source")
self.assertIsNone(settings.BACKUP_COMPOSE_RESTORE_DIR) self.assertIsNone(settings.BACKUP_COMPOSE_RESTORE_DIR)
self.assertEqual(settings.BACKUP_PG_RESTORE_PATH, "pg_restore") self.assertEqual(settings.BACKUP_PG_RESTORE_PATH, "pg_restore")
self.assertTrue(settings.BACKUP_ARCHIVE_SIGNATURE_REQUIRED)
self.assertIsNone(settings.BACKUP_ARCHIVE_SIGNATURE_SECRET)
def test_subscription_purchase_description_is_localized_and_toggleable(self): def test_subscription_purchase_description_is_localized_and_toggleable(self):
settings = Settings( settings = Settings(