feat(v2): complete v2 reimplementation
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from backup_tool.config import Settings
|
||||
from backup_tool.db.engine import create_engine
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
config = importlib.import_module("backup_tool.config")
|
||||
adapters = importlib.import_module("backup_tool.adapters")
|
||||
faults = importlib.import_module("backup_tool.faults")
|
||||
models = importlib.import_module("backup_tool.db.models")
|
||||
snapshot = importlib.import_module("backup_tool.snapshot")
|
||||
worker_module = importlib.import_module("backup_tool.worker")
|
||||
|
||||
PASSWORD = "correct-horse-battery-staple"
|
||||
|
||||
|
||||
def settings_for(tmp_path: Path):
|
||||
key = tmp_path / "master.key"
|
||||
key.write_bytes(b"m6-publication-fault-test-master-key-material")
|
||||
key.chmod(0o600)
|
||||
data = tmp_path / "data"
|
||||
repositories = tmp_path / "repositories"
|
||||
sources = tmp_path / "sources"
|
||||
restores = tmp_path / "restores"
|
||||
for directory in (data, repositories, sources, restores):
|
||||
directory.mkdir()
|
||||
return config.Settings(
|
||||
data_dir=data,
|
||||
database_url=f"sqlite+aiosqlite:///{data / 'metadata.db'}",
|
||||
repository_roots=(repositories,),
|
||||
local_source_roots=(sources,),
|
||||
restore_roots=(restores,),
|
||||
master_key_file=key,
|
||||
min_free_bytes=1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("point", ["blob.before_write", "blob.after_write", "blob.after_fsync"])
|
||||
async def test_blob_write_crash_points_leave_no_published_blob(tmp_path: Path, point: str) -> None:
|
||||
settings = settings_for(tmp_path)
|
||||
source_root = settings.local_source_roots[0] / "source"
|
||||
source_root.mkdir()
|
||||
(source_root / "data.txt").write_text("backup data", encoding="utf-8")
|
||||
adapter = adapters.LocalAdapter(source_root, settings)
|
||||
staged_blob = tmp_path / "staged.blob"
|
||||
|
||||
with pytest.raises(faults.InjectedCrash):
|
||||
await snapshot._copy_file(adapter, "data.txt", staged_blob, faults.CrashAt(point))
|
||||
|
||||
assert not (settings.repository_roots[0] / "blobs" / "sha256").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_staging_is_owner_only_with_a_permissive_umask(tmp_path: Path) -> None:
|
||||
settings = settings_for(tmp_path)
|
||||
source_root = settings.local_source_roots[0] / "source"
|
||||
source_root.mkdir()
|
||||
(source_root / "data.txt").write_text("plaintext", encoding="utf-8")
|
||||
adapter = adapters.LocalAdapter(source_root, settings)
|
||||
staging = tmp_path / "staging"
|
||||
staged_blobs = staging / "blobs"
|
||||
staged_blob = staged_blobs / "0.blob"
|
||||
|
||||
old_umask = os.umask(0)
|
||||
try:
|
||||
snapshot._private_directory(staging)
|
||||
snapshot._private_directory(staged_blobs)
|
||||
with pytest.raises(faults.InjectedCrash):
|
||||
await snapshot._copy_file(
|
||||
adapter,
|
||||
"data.txt",
|
||||
staged_blob,
|
||||
faults.CrashAt("blob.after_write"),
|
||||
)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
assert stat.S_IMODE(staging.stat().st_mode) == 0o700
|
||||
assert stat.S_IMODE(staged_blobs.stat().st_mode) == 0o700
|
||||
assert stat.S_IMODE(staged_blob.stat().st_mode) == 0o600
|
||||
assert staged_blob.read_text(encoding="utf-8") == "plaintext"
|
||||
|
||||
|
||||
def test_blob_install_crash_point_leaves_staged_blob_unpublished(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
settings = settings_for(tmp_path)
|
||||
staged_blob = tmp_path / "staged.blob"
|
||||
staged_blob.write_bytes(b"backup data")
|
||||
digest = "a" * 64
|
||||
target = settings.repository_roots[0] / "blobs" / "sha256" / digest
|
||||
|
||||
with pytest.raises(faults.InjectedCrash):
|
||||
snapshot._install_blob(staged_blob, target, digest, faults.CrashAt("blob.before_rename"))
|
||||
|
||||
assert staged_blob.exists()
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
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_metadata_crash_is_reconciled_without_republishing(
|
||||
app_client: tuple[httpx.AsyncClient, Settings],
|
||||
) -> None:
|
||||
client, settings = app_client
|
||||
source_root = settings.local_source_roots[0] / "source"
|
||||
source_root.mkdir()
|
||||
(source_root / "data.txt").write_text("backup data", encoding="utf-8")
|
||||
headers = await _login(client)
|
||||
repository = await client.post(
|
||||
"/api/v2/repositories",
|
||||
json={
|
||||
"name": "repo",
|
||||
"relative_path": "repo",
|
||||
"compression": "none",
|
||||
"encryption": "none",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
source = await client.post(
|
||||
"/api/v2/sources",
|
||||
json={
|
||||
"name": "source",
|
||||
"kind": "local",
|
||||
"public_config": {"root": str(source_root)},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
job = await client.post(
|
||||
"/api/v2/jobs",
|
||||
json={
|
||||
"name": "job",
|
||||
"source_id": source.json()["id"],
|
||||
"repository_id": repository.json()["id"],
|
||||
"requested_mode": "full",
|
||||
"exclusions": [],
|
||||
"retention": {},
|
||||
"enabled": True,
|
||||
"allow_empty": False,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
execution = await client.post(f"/api/v2/jobs/{job.json()['id']}/executions", headers=headers)
|
||||
execution_id = execution.json()["id"]
|
||||
|
||||
crashing_worker = worker_module.Worker(
|
||||
settings,
|
||||
owner="crashing-worker",
|
||||
fault_injector=faults.CrashAt("metadata.before_commit"),
|
||||
)
|
||||
try:
|
||||
with pytest.raises(faults.InjectedCrash):
|
||||
await crashing_worker.run_once()
|
||||
finally:
|
||||
await crashing_worker.engine.dispose()
|
||||
|
||||
engine = create_engine(settings)
|
||||
try:
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with sessions() as db:
|
||||
assert (
|
||||
await db.scalar(
|
||||
select(models.Backup).where(models.Backup.execution_id == execution_id)
|
||||
)
|
||||
is None
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
recovery_worker = worker_module.Worker(settings, owner="recovery-worker")
|
||||
try:
|
||||
assert await recovery_worker.startup() == 1
|
||||
finally:
|
||||
await recovery_worker.engine.dispose()
|
||||
|
||||
engine = create_engine(settings)
|
||||
try:
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with sessions() as db:
|
||||
recovered = await db.get(models.Execution, execution_id)
|
||||
backups = list(
|
||||
await db.scalars(
|
||||
select(models.Backup).where(models.Backup.execution_id == execution_id)
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
assert recovered is not None and recovered.state == "committed"
|
||||
assert len(backups) == 1
|
||||
Reference in New Issue
Block a user