204 lines
6.8 KiB
Python
204 lines
6.8 KiB
Python
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
|