chore: change backup archive name style

This commit is contained in:
3252a8
2026-05-27 23:01:42 +03:00
parent 50dd1951c4
commit ef4b493e65
7 changed files with 79 additions and 36 deletions
+6 -1
View File
@@ -1,15 +1,20 @@
import hashlib import hashlib
import json import json
import zipfile import zipfile
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
BACKUP_APP_ID = "remnawave-minishop" BACKUP_APP_ID = "remnawave-minishop"
BACKUP_FILENAME_PREFIX = "remnawave-minishop-backup-" BACKUP_FILENAME_PREFIX = "minishop-"
BACKUP_FORMAT_VERSION = 1 BACKUP_FORMAT_VERSION = 1
BACKUP_MANIFEST_NAME = "manifest.json" 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: def file_sha256(path: Path) -> str:
digest = hashlib.sha256() digest = hashlib.sha256()
with path.open("rb") as handle: with path.open("rb") as handle:
@@ -20,6 +20,7 @@ from bot.services.backup_archive import (
BACKUP_FORMAT_VERSION, BACKUP_FORMAT_VERSION,
BACKUP_MANIFEST_NAME, BACKUP_MANIFEST_NAME,
attach_archive_integrity, attach_archive_integrity,
backup_filename_timestamp,
build_file_records, build_file_records,
write_manifest, write_manifest,
write_zip_from_directory, 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_COMPOSE_MEMBER_BYTES = 256 * 1024 * 1024
BACKUP_MAX_COMPRESSION_RATIO = 200 BACKUP_MAX_COMPRESSION_RATIO = 200
BACKUP_ZIP_BOMB_MIN_BYTES = 100 * 1024 * 1024 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$") 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: ) -> BackupArchiveInfo:
self._validate_archive_for_restore(temp_path) self._validate_archive_for_restore(temp_path)
digest = self._file_digest(temp_path) digest = self._file_digest(temp_path)
stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S%z") stamp = backup_filename_timestamp()
safe_original = self._safe_original_stem(original_filename) archive_name = f"{BACKUP_FILENAME_PREFIX}uploaded-{stamp}-{digest}.zip"
archive_name = f"{BACKUP_FILENAME_PREFIX}uploaded-{stamp}-{digest}-{safe_original}.zip"
target_path = self._unique_archive_path(archive_name) target_path = self._unique_archive_path(archive_name)
temp_path.replace(target_path) temp_path.replace(target_path)
return self.inspect_archive(target_path) return self.inspect_archive(target_path)
@@ -346,12 +346,12 @@ class BackupRestoreService:
) from exc ) from exc
def _snapshot_current_compose(self, target_dir: Path) -> Optional[Path]: def _snapshot_current_compose(self, target_dir: Path) -> Optional[Path]:
stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S%z") stamp = backup_filename_timestamp()
archive_path = self.backup_dir() / f"{COMPOSE_PRE_RESTORE_PREFIX}{stamp}.zip" archive_path = self._unique_archive_path(f"{COMPOSE_PRE_RESTORE_PREFIX}{stamp}.zip")
excluded_dirs = self._compose_excluded_dirs() excluded_dirs = self._compose_excluded_dirs()
files_count = 0 files_count = 0
with tempfile.TemporaryDirectory( with tempfile.TemporaryDirectory(
prefix=f"{COMPOSE_PRE_RESTORE_PREFIX}{stamp}-", prefix=f"{archive_path.stem}-",
dir=self.backup_dir(), dir=self.backup_dir(),
) as tmp: ) as tmp:
staging_dir = Path(tmp) staging_dir = Path(tmp)
+2 -1
View File
@@ -19,6 +19,7 @@ from bot.services.backup_archive import (
BACKUP_FILENAME_PREFIX, BACKUP_FILENAME_PREFIX,
BACKUP_FORMAT_VERSION, BACKUP_FORMAT_VERSION,
attach_archive_integrity, attach_archive_integrity,
backup_filename_timestamp,
build_file_records, build_file_records,
write_manifest, write_manifest,
write_zip_from_directory, write_zip_from_directory,
@@ -145,7 +146,7 @@ class BackupWorker:
async def create_backup(self, *, backup_type: str = "scheduled") -> BackupResult: async def create_backup(self, *, backup_type: str = "scheduled") -> BackupResult:
started_at = datetime.now(timezone.utc) 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" archive_name = f"{BACKUP_FILENAME_PREFIX}{stamp}.zip"
backup_dir = Path(self.settings.BACKUP_DIR).expanduser() backup_dir = Path(self.settings.BACKUP_DIR).expanduser()
backup_dir.mkdir(parents=True, exist_ok=True) backup_dir.mkdir(parents=True, exist_ok=True)
+3 -3
View File
@@ -9,7 +9,7 @@ Minishop умеет автоматически собирать ZIP-бэкапы
Типовой файл называется так: Типовой файл называется так:
```text ```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-каталога рядом с остальными архивами: Compose-файлы восстанавливаются поверх текущей папки. Перед заменой backend создает pre-restore snapshot текущего compose-каталога рядом с остальными архивами:
```text ```text
remnawave-minishop-compose-pre-restore-YYYYMMDD-HHMMSS+ZZZZ.zip minishop-pre-restore-YYYYMMDD-HH-MM.zip
``` ```
После восстановления compose-папки перезапустите нужные сервисы, чтобы изменения `docker-compose.yml`, `.env`, Caddyfile/Nginx-конфигов и других файлов реально применились: После восстановления compose-папки перезапустите нужные сервисы, чтобы изменения `docker-compose.yml`, `.env`, Caddyfile/Nginx-конфигов и других файлов реально применились:
@@ -131,7 +131,7 @@ Backend валидирует архив до восстановления:
Если админка недоступна, можно восстановить дамп вручную: Если админка недоступна, можно восстановить дамп вручную:
```bash ```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 cp restore/database/remnawave_minishop.dump postgres:/tmp/remnawave_minishop.dump
docker compose stop backend worker 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' 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'
+17 -14
View File
@@ -270,9 +270,17 @@ export async function mockApi(path, options = {}, context = {}) {
} }
return out; 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 = [ const mockBackups = [
{ {
name: "remnawave-minishop-backup-20260527-120000+0300.zip", name: "minishop-20260527-12-00.zip",
size_bytes: 184320, size_bytes: 184320,
modified_at: "2026-05-27T09:00:00Z", modified_at: "2026-05-27T09:00:00Z",
created_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: {}, manifest: {},
}, },
{ {
name: "remnawave-minishop-backup-20260527-110000+0300.zip", name: "minishop-20260527-11-00.zip",
size_bytes: 153600, size_bytes: 153600,
modified_at: "2026-05-27T08:00:00Z", modified_at: "2026-05-27T08:00:00Z",
created_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") { if (path === "/admin/backups/create") {
const createdAt = new Date(); const createdAt = new Date();
const stamp = createdAt
.toISOString()
.replace(/[-:]/g, "")
.replace("T", "-")
.replace(/\.\d{3}Z$/, "+0000");
const archive = { const archive = {
...mockBackups[0], ...mockBackups[0],
name: `remnawave-minishop-backup-${stamp}.zip`, name: `minishop-${compactBackupStamp(createdAt)}.zip`,
modified_at: createdAt.toISOString(), modified_at: createdAt.toISOString(),
created_at: createdAt.toISOString(), created_at: createdAt.toISOString(),
created_at_local: createdAt.toISOString(), created_at_local: createdAt.toISOString(),
@@ -505,14 +508,15 @@ export async function mockApi(path, options = {}, context = {}) {
}; };
} }
if (path === "/admin/backups/upload") { if (path === "/admin/backups/upload") {
const uploadedAt = new Date();
return { return {
ok: true, ok: true,
archive: { archive: {
...mockBackups[0], ...mockBackups[0],
name: `remnawave-minishop-backup-uploaded-${Date.now()}.zip`, name: `minishop-uploaded-${compactBackupStamp(uploadedAt)}-0000000000000000.zip`,
modified_at: new Date().toISOString(), modified_at: uploadedAt.toISOString(),
created_at: new Date().toISOString(), created_at: uploadedAt.toISOString(),
created_at_local: new Date().toISOString(), created_at_local: uploadedAt.toISOString(),
}, },
}; };
} }
@@ -526,8 +530,7 @@ export async function mockApi(path, options = {}, context = {}) {
database_restored: true, database_restored: true,
compose_files_restored: 6, compose_files_restored: 6,
compose_target_dir: "/app/compose-source", compose_target_dir: "/app/compose-source",
compose_pre_restore_archive: compose_pre_restore_archive: "data/backups/minishop-pre-restore-20260527-12-15.zip",
"data/backups/remnawave-minishop-compose-pre-restore-20260527-121500+0300.zip",
warnings: [], warnings: [],
}, },
}; };
+40 -8
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import re
import tempfile import tempfile
import zipfile import zipfile
from pathlib import Path from pathlib import Path
@@ -12,6 +13,7 @@ from bot.services.backup_archive import (
write_zip_from_directory, write_zip_from_directory,
) )
from bot.services.backup_restore_service import ( from bot.services.backup_restore_service import (
COMPOSE_PRE_RESTORE_PREFIX,
BackupArchiveError, BackupArchiveError,
BackupRestoreService, BackupRestoreService,
) )
@@ -77,7 +79,7 @@ def test_backup_restore_service_lists_archives_with_contents(tmp_path):
compose_dir = tmp_path / "compose" compose_dir = tmp_path / "compose"
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-12-00.zip"
_write_backup_archive(archive_path) _write_backup_archive(archive_path)
archives = BackupRestoreService(settings).list_archives() 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") 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 = tmp_path / "compose"
compose_dir.mkdir() compose_dir.mkdir()
(compose_dir / "docker-compose.yml").write_text("old: true\n", encoding="utf-8") (compose_dir / "docker-compose.yml").write_text("old: true\n", encoding="utf-8")
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" 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) _write_backup_archive(archive_path, include_db=False)
service = BackupRestoreService(settings) 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 / "docker-compose.yml").read_text(encoding="utf-8") == "services: {}\n"
assert (compose_dir / ".env").read_text(encoding="utf-8") == "POSTGRES_PASSWORD=secret\n" assert (compose_dir / ".env").read_text(encoding="utf-8") == "POSTGRES_PASSWORD=secret\n"
assert result.compose_pre_restore_archive 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)) snapshot = service.inspect_archive(Path(result.compose_pre_restore_archive))
assert snapshot.has_compose is True assert snapshot.has_compose is True
assert snapshot.compose_files_count == 1 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 = tmp_path / "compose"
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-12-00.zip"
_write_backup_archive(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):
@@ -145,7 +159,7 @@ def test_backup_restore_service_runs_pg_restore_for_dump(tmp_path):
compose_dir = tmp_path / "compose" compose_dir = tmp_path / "compose"
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-12-00.zip"
_write_backup_archive(archive_path, include_compose=False) _write_backup_archive(archive_path, include_compose=False)
service = BackupRestoreService(settings) service = BackupRestoreService(settings)
restored_payloads = [] restored_payloads = []
@@ -172,7 +186,7 @@ def test_backup_restore_service_accepts_archive_from_another_instance(tmp_path):
compose_dir.mkdir() compose_dir.mkdir()
target_settings = _settings(tmp_path, compose_dir, BOT_TOKEN="target-token") target_settings = _settings(tmp_path, compose_dir, BOT_TOKEN="target-token")
archive_path = Path(target_settings.BACKUP_DIR) / ( 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) _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") 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): def test_backup_restore_service_rejects_tampered_archive(tmp_path):
compose_dir = tmp_path / "compose" compose_dir = tmp_path / "compose"
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-12-00.zip"
_write_backup_archive(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")
+4 -2
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import json import json
import os import os
import re
import zipfile import zipfile
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path 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.archive_path.is_file()
assert result.db_dump_included is True assert result.db_dump_included is True
assert result.compose_files_count == 3 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() assert not old_archive.exists()
bot.send_document.assert_awaited_once() bot.send_document.assert_awaited_once()
send_kwargs = bot.send_document.await_args.kwargs 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): def test_backup_worker_allocates_unique_archive_path(tmp_path):
settings = _settings(tmp_path, tmp_path / "compose") settings = _settings(tmp_path, tmp_path / "compose")
worker = _FakePgDumpBackupWorker(settings, _FakeBot()) 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") archive_path.write_text("existing", encoding="utf-8")
unique_path = worker._unique_archive_path(archive_path) 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): def test_backup_worker_does_not_fail_when_compose_source_is_not_mounted(tmp_path):