feat(v2): complete v2 reimplementation
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from backup_tool.config import Settings
|
||||
from backup_tool.db.engine import create_engine
|
||||
from backup_tool.db.models import Backup, Execution, Repository, Restore
|
||||
from backup_tool.snapshot import verify_published_snapshot
|
||||
from backup_tool.worker import Worker
|
||||
from sqlalchemy import select
|
||||
|
||||
PASSWORD = "correct-horse-battery-staple"
|
||||
|
||||
|
||||
async def login(client: httpx.AsyncClient) -> dict[str, str]:
|
||||
response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD})
|
||||
assert response.status_code == 201
|
||||
return {"X-CSRF-Token": client.cookies["backup_tool_csrf"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_publishes_a_verified_signed_full_backup_and_atomic_restore(
|
||||
app_client: tuple[httpx.AsyncClient, Settings],
|
||||
) -> None:
|
||||
client, settings = app_client
|
||||
source_root = settings.local_source_roots[0] / "project"
|
||||
source_root.mkdir()
|
||||
(source_root / "nested").mkdir()
|
||||
(source_root / "nested" / "hello.txt").write_text("hello backup\n", encoding="utf-8")
|
||||
headers = await login(client)
|
||||
repository_response = await client.post(
|
||||
"/api/v2/repositories",
|
||||
json={
|
||||
"name": "primary",
|
||||
"relative_path": "primary",
|
||||
"compression": "none",
|
||||
"encryption": "none",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert repository_response.status_code == 201
|
||||
source_response = await client.post(
|
||||
"/api/v2/sources",
|
||||
json={
|
||||
"name": "local",
|
||||
"kind": "local",
|
||||
"public_config": {"root": str(source_root)},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert source_response.status_code == 201
|
||||
job_response = await client.post(
|
||||
"/api/v2/jobs",
|
||||
json={
|
||||
"name": "full-backup",
|
||||
"source_id": source_response.json()["id"],
|
||||
"repository_id": repository_response.json()["id"],
|
||||
"requested_mode": "full",
|
||||
"exclusions": [],
|
||||
"retention": {},
|
||||
"enabled": True,
|
||||
"allow_empty": False,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert job_response.status_code == 201
|
||||
execution_response = await client.post(
|
||||
f"/api/v2/jobs/{job_response.json()['id']}/executions", headers=headers
|
||||
)
|
||||
assert execution_response.status_code == 202
|
||||
execution_id = execution_response.json()["id"]
|
||||
|
||||
worker = Worker(settings, owner="snapshot-worker")
|
||||
try:
|
||||
assert await worker.run_once()
|
||||
finally:
|
||||
await worker.engine.dispose()
|
||||
|
||||
engine = create_engine(settings)
|
||||
try:
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with sessions() as db:
|
||||
execution = await db.get(Execution, execution_id)
|
||||
backup = await db.scalar(select(Backup).where(Backup.execution_id == execution_id))
|
||||
repository = await db.get(Repository, repository_response.json()["id"])
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
assert execution is not None
|
||||
assert execution.state == "committed"
|
||||
assert backup is not None
|
||||
assert backup.integrity == "verified"
|
||||
assert repository is not None
|
||||
root = Path(repository.root)
|
||||
manifest_path = root / "manifests" / f"{backup.manifest_id}.json"
|
||||
manifest = verify_published_snapshot(root, manifest_path, repository.signing_public_key)
|
||||
file_entry = next(entry for entry in manifest["entries"] if entry["type"] == "file")
|
||||
assert file_entry["path"] == "nested/hello.txt"
|
||||
assert (root / "blobs" / "sha256" / file_entry["blob_digest"]).read_text() == "hello backup\n"
|
||||
|
||||
dry_run_destination = settings.restore_roots[0] / "dry-run-backup"
|
||||
dry_run_response = await client.post(
|
||||
f"/api/v2/backups/{backup.id}/restores",
|
||||
json={
|
||||
"destination": str(dry_run_destination),
|
||||
"selection": ["nested"],
|
||||
"dry_run": True,
|
||||
"overwrite_policy": "fail",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert dry_run_response.status_code == 202
|
||||
dry_run_worker = Worker(settings, owner="dry-run-worker")
|
||||
try:
|
||||
assert await dry_run_worker.run_once()
|
||||
finally:
|
||||
await dry_run_worker.engine.dispose()
|
||||
dry_run = await client.get(f"/api/v2/restores/{dry_run_response.json()['id']}", headers=headers)
|
||||
assert dry_run.json()["state"] == "committed"
|
||||
assert dry_run.json()["result"]["dry_run"]
|
||||
assert dry_run.json()["result"]["entry_count"] == 2
|
||||
assert not dry_run_destination.exists()
|
||||
|
||||
destination = settings.restore_roots[0] / "restored-backup"
|
||||
restore_response = await client.post(
|
||||
f"/api/v2/backups/{backup.id}/restores",
|
||||
json={
|
||||
"destination": str(destination),
|
||||
"selection": [],
|
||||
"overwrite_policy": "fail",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert restore_response.status_code == 202
|
||||
restore_id = restore_response.json()["id"]
|
||||
assert restore_response.json()["state"] == "queued"
|
||||
source_root.rename(settings.data_dir / "removed-source")
|
||||
|
||||
restore_worker = Worker(settings, owner="restore-worker")
|
||||
try:
|
||||
assert await restore_worker.run_once()
|
||||
finally:
|
||||
await restore_worker.engine.dispose()
|
||||
|
||||
restored = await client.get(f"/api/v2/restores/{restore_id}", headers=headers)
|
||||
assert restored.status_code == 200
|
||||
assert restored.json()["state"] == "committed"
|
||||
assert restored.json()["result"]["manifest_digest"] == backup.manifest_digest
|
||||
assert (destination / "nested" / "hello.txt").read_text(encoding="utf-8") == "hello backup\n"
|
||||
|
||||
engine = create_engine(settings)
|
||||
try:
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with sessions() as db:
|
||||
interrupted_restore = await db.get(Restore, restore_id)
|
||||
assert interrupted_restore is not None
|
||||
interrupted_restore.state = "running"
|
||||
interrupted_restore.result = None
|
||||
await db.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
recovery_worker = Worker(settings, owner="recovery-worker")
|
||||
try:
|
||||
assert await recovery_worker.startup() == 1
|
||||
finally:
|
||||
await recovery_worker.engine.dispose()
|
||||
recovered = await client.get(f"/api/v2/restores/{restore_id}", headers=headers)
|
||||
assert recovered.json()["state"] == "committed"
|
||||
|
||||
skipped_restore = await client.post(
|
||||
f"/api/v2/backups/{backup.id}/restores",
|
||||
json={
|
||||
"destination": str(destination),
|
||||
"selection": [],
|
||||
"overwrite_policy": "skip",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert skipped_restore.status_code == 202
|
||||
skip_worker = Worker(settings, owner="skip-worker")
|
||||
try:
|
||||
assert await skip_worker.run_once()
|
||||
finally:
|
||||
await skip_worker.engine.dispose()
|
||||
skipped = await client.get(f"/api/v2/restores/{skipped_restore.json()['id']}", headers=headers)
|
||||
assert skipped.json()["result"]["skipped"]
|
||||
|
||||
(destination / "nested" / "hello.txt").write_text("replaced", encoding="utf-8")
|
||||
replaced_restore = await client.post(
|
||||
f"/api/v2/backups/{backup.id}/restores",
|
||||
json={
|
||||
"destination": str(destination),
|
||||
"selection": [],
|
||||
"overwrite_policy": "replace",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert replaced_restore.status_code == 202
|
||||
replace_worker = Worker(settings, owner="replace-worker")
|
||||
try:
|
||||
assert await replace_worker.run_once()
|
||||
finally:
|
||||
await replace_worker.engine.dispose()
|
||||
assert (destination / "nested" / "hello.txt").read_text(encoding="utf-8") == "hello backup\n"
|
||||
|
||||
(root / "blobs" / "sha256" / file_entry["blob_digest"]).write_text("tampered")
|
||||
corrupt_destination = settings.restore_roots[0] / "corrupt-restore"
|
||||
corrupt_restore = await client.post(
|
||||
f"/api/v2/backups/{backup.id}/restores",
|
||||
json={
|
||||
"destination": str(corrupt_destination),
|
||||
"selection": [],
|
||||
"overwrite_policy": "fail",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert corrupt_restore.status_code == 202
|
||||
|
||||
corrupt_worker = Worker(settings, owner="corrupt-restore-worker")
|
||||
try:
|
||||
assert await corrupt_worker.run_once()
|
||||
finally:
|
||||
await corrupt_worker.engine.dispose()
|
||||
|
||||
corrupt_status = await client.get(
|
||||
f"/api/v2/restores/{corrupt_restore.json()['id']}", headers=headers
|
||||
)
|
||||
assert corrupt_status.json()["state"] == "failed"
|
||||
assert not corrupt_destination.exists()
|
||||
engine = create_engine(settings)
|
||||
try:
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with sessions() as db:
|
||||
corrupted_backup = await db.get(Backup, backup.id)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
assert corrupted_backup is not None
|
||||
assert corrupted_backup.integrity == "corrupt"
|
||||
Reference in New Issue
Block a user