From ef4b493e655170cdc97e419eef3d9077ea3f69ed Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Wed, 27 May 2026 23:01:42 +0300 Subject: [PATCH] chore: change backup archive name style --- backend/bot/services/backup_archive.py | 7 ++- .../bot/services/backup_restore_service.py | 14 +++--- backend/bot/services/backup_worker.py | 3 +- docs/features/backups.md | 6 +-- frontend/src/lib/webapp/mockApi.js | 31 ++++++------ tests/test_backup_restore_service.py | 48 +++++++++++++++---- tests/test_backup_worker.py | 6 ++- 7 files changed, 79 insertions(+), 36 deletions(-) diff --git a/backend/bot/services/backup_archive.py b/backend/bot/services/backup_archive.py index 723c026..5014cc8 100644 --- a/backend/bot/services/backup_archive.py +++ b/backend/bot/services/backup_archive.py @@ -1,15 +1,20 @@ import hashlib import json import zipfile +from datetime import datetime from pathlib import Path from typing import Any BACKUP_APP_ID = "remnawave-minishop" -BACKUP_FILENAME_PREFIX = "remnawave-minishop-backup-" +BACKUP_FILENAME_PREFIX = "minishop-" BACKUP_FORMAT_VERSION = 1 BACKUP_MANIFEST_NAME = "manifest.json" +def backup_filename_timestamp() -> str: + return datetime.now().astimezone().strftime("%Y%m%d-%H-%M") + + def file_sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: diff --git a/backend/bot/services/backup_restore_service.py b/backend/bot/services/backup_restore_service.py index 3d73be7..4be5624 100644 --- a/backend/bot/services/backup_restore_service.py +++ b/backend/bot/services/backup_restore_service.py @@ -20,6 +20,7 @@ from bot.services.backup_archive import ( BACKUP_FORMAT_VERSION, BACKUP_MANIFEST_NAME, attach_archive_integrity, + backup_filename_timestamp, build_file_records, write_manifest, write_zip_from_directory, @@ -39,7 +40,7 @@ BACKUP_MAX_COMPOSE_BYTES = 1024 * 1024 * 1024 BACKUP_MAX_COMPOSE_MEMBER_BYTES = 256 * 1024 * 1024 BACKUP_MAX_COMPRESSION_RATIO = 200 BACKUP_ZIP_BOMB_MIN_BYTES = 100 * 1024 * 1024 -COMPOSE_PRE_RESTORE_PREFIX = "remnawave-minishop-compose-pre-restore-" +COMPOSE_PRE_RESTORE_PREFIX = "minishop-pre-restore-" SAFE_ARCHIVE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.@+-]{0,220}\.zip$") @@ -183,9 +184,8 @@ class BackupRestoreService: ) -> BackupArchiveInfo: self._validate_archive_for_restore(temp_path) digest = self._file_digest(temp_path) - stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S%z") - safe_original = self._safe_original_stem(original_filename) - archive_name = f"{BACKUP_FILENAME_PREFIX}uploaded-{stamp}-{digest}-{safe_original}.zip" + stamp = backup_filename_timestamp() + archive_name = f"{BACKUP_FILENAME_PREFIX}uploaded-{stamp}-{digest}.zip" target_path = self._unique_archive_path(archive_name) temp_path.replace(target_path) return self.inspect_archive(target_path) @@ -346,12 +346,12 @@ class BackupRestoreService: ) from exc def _snapshot_current_compose(self, target_dir: Path) -> Optional[Path]: - stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S%z") - archive_path = self.backup_dir() / f"{COMPOSE_PRE_RESTORE_PREFIX}{stamp}.zip" + stamp = backup_filename_timestamp() + archive_path = self._unique_archive_path(f"{COMPOSE_PRE_RESTORE_PREFIX}{stamp}.zip") excluded_dirs = self._compose_excluded_dirs() files_count = 0 with tempfile.TemporaryDirectory( - prefix=f"{COMPOSE_PRE_RESTORE_PREFIX}{stamp}-", + prefix=f"{archive_path.stem}-", dir=self.backup_dir(), ) as tmp: staging_dir = Path(tmp) diff --git a/backend/bot/services/backup_worker.py b/backend/bot/services/backup_worker.py index d231561..e94597e 100644 --- a/backend/bot/services/backup_worker.py +++ b/backend/bot/services/backup_worker.py @@ -19,6 +19,7 @@ from bot.services.backup_archive import ( BACKUP_FILENAME_PREFIX, BACKUP_FORMAT_VERSION, attach_archive_integrity, + backup_filename_timestamp, build_file_records, write_manifest, write_zip_from_directory, @@ -145,7 +146,7 @@ class BackupWorker: async def create_backup(self, *, backup_type: str = "scheduled") -> BackupResult: started_at = datetime.now(timezone.utc) - stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S%z") + stamp = backup_filename_timestamp() archive_name = f"{BACKUP_FILENAME_PREFIX}{stamp}.zip" backup_dir = Path(self.settings.BACKUP_DIR).expanduser() backup_dir.mkdir(parents=True, exist_ok=True) diff --git a/docs/features/backups.md b/docs/features/backups.md index 7175608..7d7d2da 100644 --- a/docs/features/backups.md +++ b/docs/features/backups.md @@ -9,7 +9,7 @@ Minishop умеет автоматически собирать ZIP-бэкапы Типовой файл называется так: ```text -remnawave-minishop-backup-20260527-120000+0300.zip +minishop-20260527-12-00.zip ``` Внутри: @@ -86,7 +86,7 @@ COMPOSE_RESTORE_MODE=ro Compose-файлы восстанавливаются поверх текущей папки. Перед заменой backend создает pre-restore snapshot текущего compose-каталога рядом с остальными архивами: ```text -remnawave-minishop-compose-pre-restore-YYYYMMDD-HHMMSS+ZZZZ.zip +minishop-pre-restore-YYYYMMDD-HH-MM.zip ``` После восстановления compose-папки перезапустите нужные сервисы, чтобы изменения `docker-compose.yml`, `.env`, Caddyfile/Nginx-конфигов и других файлов реально применились: @@ -131,7 +131,7 @@ Backend валидирует архив до восстановления: Если админка недоступна, можно восстановить дамп вручную: ```bash -unzip remnawave-minishop-backup-YYYYMMDD-HHMMSS+ZZZZ.zip -d restore +unzip minishop-YYYYMMDD-HH-MM.zip -d restore docker compose cp restore/database/remnawave_minishop.dump postgres:/tmp/remnawave_minishop.dump docker compose stop backend worker docker compose exec postgres sh -c 'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner --no-privileges /tmp/remnawave_minishop.dump' diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index 05e0737..a807059 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -270,9 +270,17 @@ export async function mockApi(path, options = {}, context = {}) { } return out; })(); + const compactBackupStamp = (date) => { + const pad = (value) => String(value).padStart(2, "0"); + return [ + `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`, + pad(date.getHours()), + pad(date.getMinutes()), + ].join("-"); + }; const mockBackups = [ { - name: "remnawave-minishop-backup-20260527-120000+0300.zip", + name: "minishop-20260527-12-00.zip", size_bytes: 184320, modified_at: "2026-05-27T09:00:00Z", created_at: "2026-05-27T09:00:00Z", @@ -285,7 +293,7 @@ export async function mockApi(path, options = {}, context = {}) { manifest: {}, }, { - name: "remnawave-minishop-backup-20260527-110000+0300.zip", + name: "minishop-20260527-11-00.zip", size_bytes: 153600, modified_at: "2026-05-27T08:00:00Z", created_at: "2026-05-27T08:00:00Z", @@ -477,14 +485,9 @@ export async function mockApi(path, options = {}, context = {}) { } if (path === "/admin/backups/create") { const createdAt = new Date(); - const stamp = createdAt - .toISOString() - .replace(/[-:]/g, "") - .replace("T", "-") - .replace(/\.\d{3}Z$/, "+0000"); const archive = { ...mockBackups[0], - name: `remnawave-minishop-backup-${stamp}.zip`, + name: `minishop-${compactBackupStamp(createdAt)}.zip`, modified_at: createdAt.toISOString(), created_at: createdAt.toISOString(), created_at_local: createdAt.toISOString(), @@ -505,14 +508,15 @@ export async function mockApi(path, options = {}, context = {}) { }; } if (path === "/admin/backups/upload") { + const uploadedAt = new Date(); return { ok: true, archive: { ...mockBackups[0], - name: `remnawave-minishop-backup-uploaded-${Date.now()}.zip`, - modified_at: new Date().toISOString(), - created_at: new Date().toISOString(), - created_at_local: new Date().toISOString(), + name: `minishop-uploaded-${compactBackupStamp(uploadedAt)}-0000000000000000.zip`, + modified_at: uploadedAt.toISOString(), + created_at: uploadedAt.toISOString(), + created_at_local: uploadedAt.toISOString(), }, }; } @@ -526,8 +530,7 @@ export async function mockApi(path, options = {}, context = {}) { database_restored: true, compose_files_restored: 6, compose_target_dir: "/app/compose-source", - compose_pre_restore_archive: - "data/backups/remnawave-minishop-compose-pre-restore-20260527-121500+0300.zip", + compose_pre_restore_archive: "data/backups/minishop-pre-restore-20260527-12-15.zip", warnings: [], }, }; diff --git a/tests/test_backup_restore_service.py b/tests/test_backup_restore_service.py index 6ca822c..f1afb70 100644 --- a/tests/test_backup_restore_service.py +++ b/tests/test_backup_restore_service.py @@ -1,4 +1,5 @@ import asyncio +import re import tempfile import zipfile from pathlib import Path @@ -12,6 +13,7 @@ from bot.services.backup_archive import ( write_zip_from_directory, ) from bot.services.backup_restore_service import ( + COMPOSE_PRE_RESTORE_PREFIX, BackupArchiveError, BackupRestoreService, ) @@ -77,7 +79,7 @@ def test_backup_restore_service_lists_archives_with_contents(tmp_path): compose_dir = tmp_path / "compose" compose_dir.mkdir() 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-12-00.zip" _write_backup_archive(archive_path) archives = BackupRestoreService(settings).list_archives() @@ -97,13 +99,22 @@ def test_backup_restore_service_rejects_path_traversal_archive_name(tmp_path): service.archive_path_for_name("../backup.zip") -def test_backup_restore_service_restores_compose_and_snapshots_current(tmp_path): +def test_backup_restore_service_restores_compose_and_snapshots_current(tmp_path, monkeypatch): compose_dir = tmp_path / "compose" compose_dir.mkdir() (compose_dir / "docker-compose.yml").write_text("old: true\n", encoding="utf-8") settings = _settings(tmp_path, compose_dir) - archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-120000+0300.zip" + monkeypatch.setattr( + "bot.services.backup_restore_service.backup_filename_timestamp", + lambda: "20260527-12-15", + ) + existing_snapshot = ( + Path(settings.BACKUP_DIR) / f"{COMPOSE_PRE_RESTORE_PREFIX}20260527-12-15.zip" + ) + existing_snapshot.parent.mkdir(parents=True, exist_ok=True) + existing_snapshot.write_text("existing", encoding="utf-8") + archive_path = Path(settings.BACKUP_DIR) / f"{BACKUP_FILENAME_PREFIX}20260527-12-00.zip" _write_backup_archive(archive_path, include_db=False) service = BackupRestoreService(settings) @@ -118,7 +129,10 @@ def test_backup_restore_service_restores_compose_and_snapshots_current(tmp_path) assert (compose_dir / "docker-compose.yml").read_text(encoding="utf-8") == "services: {}\n" assert (compose_dir / ".env").read_text(encoding="utf-8") == "POSTGRES_PASSWORD=secret\n" assert result.compose_pre_restore_archive - assert Path(result.compose_pre_restore_archive).is_file() + snapshot_path = Path(result.compose_pre_restore_archive) + assert snapshot_path.is_file() + assert snapshot_path.name == f"{COMPOSE_PRE_RESTORE_PREFIX}20260527-12-15-2.zip" + assert existing_snapshot.read_text(encoding="utf-8") == "existing" snapshot = service.inspect_archive(Path(result.compose_pre_restore_archive)) assert snapshot.has_compose is True assert snapshot.compose_files_count == 1 @@ -128,7 +142,7 @@ def test_backup_restore_service_prevents_zip_slip_in_compose_restore(tmp_path): compose_dir = tmp_path / "compose" compose_dir.mkdir() 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-12-00.zip" _write_backup_archive(archive_path, include_db=False, unsafe=True) with pytest.raises(BackupArchiveError): @@ -145,7 +159,7 @@ def test_backup_restore_service_runs_pg_restore_for_dump(tmp_path): compose_dir = tmp_path / "compose" compose_dir.mkdir() 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-12-00.zip" _write_backup_archive(archive_path, include_compose=False) service = BackupRestoreService(settings) restored_payloads = [] @@ -172,7 +186,7 @@ def test_backup_restore_service_accepts_archive_from_another_instance(tmp_path): 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" + f"{BACKUP_FILENAME_PREFIX}20260527-12-00.zip" ) _write_backup_archive(archive_path, include_compose=False) @@ -205,11 +219,29 @@ def test_backup_restore_service_validates_uploaded_zip(tmp_path): BackupRestoreService(settings).import_uploaded_archive(temp_path, "backup.zip") +def test_backup_restore_service_imports_uploaded_archive_with_compact_name(tmp_path): + compose_dir = tmp_path / "compose" + compose_dir.mkdir() + settings = _settings(tmp_path, compose_dir) + temp_path = tmp_path / "source.zip" + _write_backup_archive(temp_path) + + archive = BackupRestoreService(settings).import_uploaded_archive( + temp_path, + "very-long-original-backup-name.zip", + ) + + assert re.fullmatch( + r"minishop-uploaded-\d{8}-\d{2}-\d{2}-[a-f0-9]{16}\.zip", + archive.name, + ) + + def test_backup_restore_service_rejects_tampered_archive(tmp_path): compose_dir = tmp_path / "compose" compose_dir.mkdir() 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-12-00.zip" _write_backup_archive(archive_path, include_compose=False) tampered_path = archive_path.with_name("tampered.zip") diff --git a/tests/test_backup_worker.py b/tests/test_backup_worker.py index 61b2c72..f19b175 100644 --- a/tests/test_backup_worker.py +++ b/tests/test_backup_worker.py @@ -1,6 +1,7 @@ import asyncio import json import os +import re import zipfile from datetime import datetime, timezone from pathlib import Path @@ -76,6 +77,7 @@ def test_backup_worker_creates_archive_with_db_dump_and_compose_snapshot(tmp_pat assert result.archive_path.is_file() assert result.db_dump_included is True assert result.compose_files_count == 3 + assert re.fullmatch(r"minishop-\d{8}-\d{2}-\d{2}\.zip", result.archive_path.name) assert not old_archive.exists() bot.send_document.assert_awaited_once() send_kwargs = bot.send_document.await_args.kwargs @@ -146,12 +148,12 @@ def test_backup_worker_can_create_manual_backup(tmp_path): def test_backup_worker_allocates_unique_archive_path(tmp_path): settings = _settings(tmp_path, tmp_path / "compose") worker = _FakePgDumpBackupWorker(settings, _FakeBot()) - archive_path = tmp_path / "remnawave-minishop-backup-20260527-120000+0300.zip" + archive_path = tmp_path / "minishop-20260527-12-00.zip" archive_path.write_text("existing", encoding="utf-8") unique_path = worker._unique_archive_path(archive_path) - assert unique_path.name == "remnawave-minishop-backup-20260527-120000+0300-2.zip" + assert unique_path.name == "minishop-20260527-12-00-2.zip" def test_backup_worker_does_not_fail_when_compose_source_is_not_mounted(tmp_path):