CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 1s
CI and images / extension-test (push) Successful in 17s
CI and images / frontend-build (push) Successful in 19s
CI and images / backend-lint-and-test (push) Successful in 45s
CI and images / integration (push) Failing after 2m26s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped
Settings → Maintenance → Backups gains "Restore from the bucket": pick one of the bucket's dated dumps, type its confirmation, and FabledCurator saves the current database to the bucket, replaces it with the dump, migrates it to this version, then fetches every image and attachment back to the path its row records. Each file is checked against its sha256 on arrival and moved into place only then; a file already on disk is left alone, so "Fetch missing files" repairs a library that lost files without touching the database. - services/backup_restore.py: list/find/fetch dumps, migrate, keep this install's destination (the dump's sealed secret can't be opened here), library_files work list, restore_file (one fetch per sha, copies for duplicate paths, refuses paths outside /images). - restore_offsite_task: the database phase, then 45-minute file chunks with a heartbeat; a failed load leaves an empty migrated database and says why. The dump's own run row is marked ok, other runs then going "Interrupted". - backup_s3.get_file: sha/size check on arrival, ObjectMissing for a 404, download pacing. - backup_service: every dump leaves the credential table's rows out; restore_db reports pg_restore's last line, stops at the first error, ends other connections and waits at most 60 s for locks. - Backups and orphan repair wait while a restore runs; the stall sweep judges restores by heartbeat; restore runs are pruned to 20. - CLI: python -m backend.app.scripts.restore list|database|files. - README: backup and restore section; logins are in no backup. - Tests: a round trip through an in-process S3 bucket (tests/fake_s3.py) with real pg_dump/pg_restore, plus file, run, API and get_file cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
439 lines
15 KiB
Python
439 lines
15 KiB
Python
"""FC-3h: backup_service unit tests.
|
|
|
|
Subprocess calls (pg_dump, tar, psql) are monkeypatched so tests run
|
|
without external binaries. The real subprocess behavior is exercised
|
|
implicitly via the Celery task tests in test_tasks_backup.py.
|
|
"""
|
|
import json
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from backend.app.services import backup_service
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_subprocess(monkeypatch):
|
|
"""Fake both subprocess.run (restore path) AND subprocess.Popen (the
|
|
bounded-kill backup path) so tests run without external binaries. Each
|
|
writes the target sentinel and records the cmd in a shared list."""
|
|
calls = []
|
|
|
|
class _FakeProc:
|
|
returncode = 0
|
|
stdout = b""
|
|
stderr = b""
|
|
|
|
def _write_sentinel(cmd):
|
|
if "pg_dump" in cmd:
|
|
i = cmd.index("-f")
|
|
Path(cmd[i + 1]).write_bytes(b"-- fake pg_dump\n")
|
|
elif cmd[0] == "tar" and "-cf" in cmd:
|
|
i = cmd.index("-cf")
|
|
Path(cmd[i + 1]).write_bytes(b"fake tar payload")
|
|
|
|
def _fake_run(cmd, **kwargs):
|
|
calls.append(list(cmd))
|
|
_write_sentinel(cmd)
|
|
return _FakeProc()
|
|
|
|
class _FakePopen:
|
|
def __init__(self, cmd, **kwargs):
|
|
calls.append(list(cmd))
|
|
self.returncode = 0
|
|
_write_sentinel(cmd)
|
|
|
|
def communicate(self, timeout=None):
|
|
return (b"", b"")
|
|
|
|
def kill(self):
|
|
self.returncode = -9
|
|
|
|
monkeypatch.setattr("subprocess.run", _fake_run)
|
|
monkeypatch.setattr("subprocess.Popen", _FakePopen)
|
|
return calls
|
|
|
|
|
|
# --- backup_db -------------------------------------------------------
|
|
|
|
|
|
def test_backup_db_writes_sql_and_manifest(tmp_path, fake_subprocess):
|
|
result = backup_service.backup_db(
|
|
db_url="postgresql://test@x/db",
|
|
images_root=tmp_path,
|
|
tag="pre-cutover",
|
|
triggered_by="manual",
|
|
)
|
|
sql = Path(result["sql_path"])
|
|
manifest = Path(result["manifest_path"])
|
|
assert sql.is_file() and sql.suffix == ".dump"
|
|
assert manifest.is_file() and manifest.suffix == ".json"
|
|
assert result["kind"] == "db"
|
|
assert result["tar_path"] is None
|
|
assert result["size_bytes"] == len(b"-- fake pg_dump\n")
|
|
|
|
parsed = json.loads(manifest.read_text())
|
|
assert parsed["kind"] == "db"
|
|
assert parsed["tag"] == "pre-cutover"
|
|
assert parsed["triggered_by"] == "manual"
|
|
assert parsed["artifact_path"] == str(sql)
|
|
|
|
|
|
def test_backup_db_strips_sqlalchemy_psycopg_driver(tmp_path, fake_subprocess):
|
|
backup_service.backup_db(
|
|
db_url="postgresql+psycopg://u:p@h/d", images_root=tmp_path,
|
|
)
|
|
cmd = fake_subprocess[0]
|
|
assert "pg_dump" in cmd
|
|
assert cmd[-1].startswith("postgresql://")
|
|
assert "+psycopg" not in cmd[-1]
|
|
|
|
|
|
def test_backup_db_uses_compressed_custom_format(tmp_path, fake_subprocess):
|
|
# -Fc → pg_restore-loadable, compressed dump (#739 backup polish).
|
|
backup_service.backup_db(db_url="postgresql://u@h/d", images_root=tmp_path)
|
|
assert "-Fc" in fake_subprocess[0]
|
|
|
|
|
|
def test_backup_db_strips_asyncpg_driver(tmp_path, fake_subprocess):
|
|
backup_service.backup_db(
|
|
db_url="postgresql+asyncpg://u:p@h/d", images_root=tmp_path,
|
|
)
|
|
assert "+asyncpg" not in fake_subprocess[0][-1]
|
|
|
|
|
|
def test_backup_db_default_tag_is_none(tmp_path, fake_subprocess):
|
|
result = backup_service.backup_db(
|
|
db_url="postgresql://u@h/d", images_root=tmp_path,
|
|
)
|
|
parsed = json.loads(Path(result["manifest_path"]).read_text())
|
|
assert parsed["tag"] is None
|
|
|
|
|
|
# --- backup_images ---------------------------------------------------
|
|
|
|
|
|
def test_backup_images_writes_tar_and_manifest(tmp_path, fake_subprocess):
|
|
result = backup_service.backup_images(
|
|
images_root=tmp_path, tag="monthly", triggered_by="manual",
|
|
)
|
|
tar = Path(result["tar_path"])
|
|
assert tar.is_file() and tar.name.endswith(".tar.zst")
|
|
assert result["sql_path"] is None
|
|
assert result["size_bytes"] == len(b"fake tar payload")
|
|
|
|
|
|
def test_backup_images_excludes_backups_and_quarantine(tmp_path, fake_subprocess):
|
|
backup_service.backup_images(images_root=tmp_path)
|
|
cmd = fake_subprocess[0]
|
|
excludes = [arg for arg in cmd if arg.startswith("--exclude=")]
|
|
assert any("_backups" in e for e in excludes)
|
|
assert any("_quarantine" in e for e in excludes)
|
|
|
|
|
|
def test_backup_images_excludes_credentials(tmp_path, fake_subprocess):
|
|
"""#4234: the images tarball carried `secrets/credential_key.b64` — the key
|
|
that decrypts the stored session cookies — and `cookies/` itself. A media
|
|
archive must not be a credential leak; encryption at rest is worth nothing
|
|
if the key travels with the data."""
|
|
backup_service.backup_images(images_root=tmp_path)
|
|
excludes = [a for a in fake_subprocess[0] if a.startswith("--exclude=")]
|
|
assert any(e.endswith("/secrets") for e in excludes)
|
|
assert any(e.endswith("/cookies") for e in excludes)
|
|
|
|
|
|
def test_backup_images_excludes_are_root_relative(tmp_path, fake_subprocess):
|
|
"""tar matches --exclude against the archived path, which is prefixed with
|
|
the root's own directory name (`-C <parent> <name>`). A bare `secrets`
|
|
would also match an ARTIST folder called secrets; the prefix is what keeps
|
|
the exclusion to the top level."""
|
|
backup_service.backup_images(images_root=tmp_path)
|
|
excludes = [a for a in fake_subprocess[0] if a.startswith("--exclude=")]
|
|
assert excludes and all(
|
|
e.startswith(f"--exclude={tmp_path.name}/") for e in excludes
|
|
)
|
|
|
|
|
|
# --- restore_db ------------------------------------------------------
|
|
|
|
|
|
def test_restore_db_drops_schema_then_loads(tmp_path, fake_subprocess):
|
|
dump_path = tmp_path / "fake.dump"
|
|
dump_path.write_bytes(b"\x00fake custom dump")
|
|
backup_service.restore_db(
|
|
db_url="postgresql://u@h/d", sql_path=dump_path,
|
|
)
|
|
# Two calls: psql -c (DROP SCHEMA), then pg_restore -d (load the dump).
|
|
assert len(fake_subprocess) == 2
|
|
assert fake_subprocess[0][0] == "psql"
|
|
assert "-c" in fake_subprocess[0]
|
|
assert "DROP SCHEMA IF EXISTS public CASCADE" in fake_subprocess[0][-1]
|
|
assert "pg_terminate_backend" in fake_subprocess[0][-1]
|
|
assert "pg_restore" in fake_subprocess[1] and "--exit-on-error" in fake_subprocess[1]
|
|
assert "-d" in fake_subprocess[1]
|
|
assert str(dump_path) in fake_subprocess[1]
|
|
|
|
|
|
def test_a_failed_restore_says_what_pg_restore_said(tmp_path, monkeypatch):
|
|
class _Done:
|
|
def __init__(self, code):
|
|
self.returncode = code
|
|
|
|
def run(cmd, stderr=None, **kw):
|
|
if "pg_restore" in cmd:
|
|
stderr.write(b"pg_restore: processing data\npg_restore: error: disk full\n")
|
|
return _Done(1)
|
|
return _Done(0)
|
|
|
|
monkeypatch.setattr("subprocess.run", run)
|
|
with pytest.raises(backup_service.RestoreFailed, match="error: disk full"):
|
|
backup_service.restore_db(db_url="postgresql://u@h/d", sql_path=tmp_path / "x.dump")
|
|
|
|
|
|
def test_every_dump_leaves_out_account_logins(tmp_path, fake_subprocess):
|
|
backup_service.backup_db(db_url="postgresql://u@h/d", images_root=tmp_path)
|
|
assert "--exclude-table-data=credential" in fake_subprocess[0]
|
|
|
|
|
|
# --- restore_images --------------------------------------------------
|
|
|
|
|
|
def test_restore_images_untar_to_parent(tmp_path, fake_subprocess):
|
|
tar_path = tmp_path / "fake.tar.zst"
|
|
tar_path.write_bytes(b"fake")
|
|
backup_service.restore_images(images_root=tmp_path, tar_path=tar_path)
|
|
cmd = fake_subprocess[0]
|
|
assert cmd[:3] == ["tar", "--zstd", "-xf"]
|
|
assert str(tar_path) in cmd
|
|
assert "-C" in cmd
|
|
|
|
|
|
# --- unlink ----------------------------------------------------------
|
|
|
|
|
|
def test_unlink_removes_present_files_and_reports(tmp_path):
|
|
sql = tmp_path / "x.sql"
|
|
sql.write_bytes(b"x")
|
|
tar = tmp_path / "x.tar.zst"
|
|
tar.write_bytes(b"x")
|
|
manifest = tmp_path / "x.json"
|
|
manifest.write_text("{}")
|
|
result = backup_service.unlink_artifact_files(
|
|
sql_path=str(sql), tar_path=str(tar), manifest_path=str(manifest),
|
|
)
|
|
assert result == {"sql": True, "tar": True, "manifest": True}
|
|
assert not sql.exists() and not tar.exists() and not manifest.exists()
|
|
|
|
|
|
def test_unlink_missing_files_returns_true(tmp_path):
|
|
"""missing_ok semantics: a non-existent file isn't an error."""
|
|
result = backup_service.unlink_artifact_files(
|
|
sql_path=str(tmp_path / "nope.sql"),
|
|
tar_path=None, manifest_path=None,
|
|
)
|
|
assert result == {"sql": True}
|
|
|
|
|
|
def test_unlink_skips_none_paths():
|
|
"""A None path is skipped — not added to the result dict."""
|
|
result = backup_service.unlink_artifact_files(
|
|
sql_path=None, tar_path=None, manifest_path=None,
|
|
)
|
|
assert result == {}
|
|
|
|
|
|
# --- helpers ---------------------------------------------------------
|
|
|
|
|
|
def test_libpq_url_strips_each_known_driver():
|
|
assert backup_service._libpq_url(
|
|
"postgresql+psycopg://u@h/d"
|
|
) == "postgresql://u@h/d"
|
|
assert backup_service._libpq_url(
|
|
"postgresql+asyncpg://u@h/d"
|
|
) == "postgresql://u@h/d"
|
|
assert backup_service._libpq_url(
|
|
"postgresql+psycopg2://u@h/d"
|
|
) == "postgresql://u@h/d"
|
|
# Plain URL passes through unchanged.
|
|
assert backup_service._libpq_url(
|
|
"postgresql://u@h/d"
|
|
) == "postgresql://u@h/d"
|
|
|
|
|
|
def test_backups_dir_created_on_first_use(tmp_path):
|
|
d = backup_service._backups_dir(tmp_path)
|
|
assert d.is_dir()
|
|
assert d.name == "_backups"
|
|
|
|
|
|
# --- bounded-kill + local-temp (FC #739) -----------------------------
|
|
|
|
|
|
def test_backup_db_dumps_to_local_temp_not_nfs_backups_dir(tmp_path, fake_subprocess):
|
|
"""pg_dump must target a LOCAL temp path, not the (NFS) _backups dir —
|
|
so its long phase can't hang uninterruptibly on an NFS write."""
|
|
backup_service.backup_db(db_url="postgresql://u@h/d", images_root=tmp_path)
|
|
cmd = fake_subprocess[0]
|
|
dump_target = cmd[cmd.index("-f") + 1]
|
|
assert "_backups" not in dump_target
|
|
# The finished file still ends up in _backups (moved there).
|
|
result = backup_service.backup_db(
|
|
db_url="postgresql://u@h/d", images_root=tmp_path,
|
|
)
|
|
assert "_backups" in result["sql_path"]
|
|
|
|
|
|
def test_run_bounded_fails_fast_when_unkillable(monkeypatch):
|
|
"""A child stuck in D-state (communicate keeps timing out even after kill)
|
|
must NOT block the reaper — _run_bounded kills then re-raises promptly."""
|
|
killed = {"n": 0}
|
|
|
|
class _Hang:
|
|
def __init__(self, cmd, **kwargs):
|
|
pass
|
|
|
|
def communicate(self, timeout=None):
|
|
raise subprocess.TimeoutExpired(cmd="x", timeout=timeout or 0)
|
|
|
|
def kill(self):
|
|
killed["n"] += 1
|
|
|
|
monkeypatch.setattr("subprocess.Popen", _Hang)
|
|
with pytest.raises(subprocess.TimeoutExpired):
|
|
backup_service._run_bounded(["pg_dump"], 1)
|
|
assert killed["n"] == 1
|
|
|
|
|
|
# --- backup_db_offsite: pg_dump streamed to the bucket ---------------
|
|
|
|
|
|
class _Dump:
|
|
"""A pg_dump stand-in: `output` on stdout, `said` on stderr, exiting with
|
|
`code` once its output has been read."""
|
|
|
|
def __init__(self, output=b"", said=b"", code=0):
|
|
self.output, self.said, self.code = output, said, code
|
|
self.cmd = None
|
|
self.killed = False
|
|
|
|
def __call__(self, cmd, stdout=None, stderr=None, **kwargs):
|
|
import io
|
|
self.cmd = list(cmd)
|
|
self.args = cmd
|
|
self.stdout = io.BytesIO(self.output)
|
|
stderr.write(self.said)
|
|
self.returncode = None
|
|
return self
|
|
|
|
def poll(self):
|
|
return self.returncode
|
|
|
|
def wait(self, timeout=None):
|
|
if self.returncode is None:
|
|
self.returncode = -9 if self.killed else self.code
|
|
return self.returncode
|
|
|
|
def kill(self):
|
|
self.killed = True
|
|
|
|
|
|
class _Bucket:
|
|
"""Records what an Upload was asked to do instead of sending it."""
|
|
|
|
def __init__(self, fail_on_part=None):
|
|
self.fail_on_part = fail_on_part
|
|
self.uploads = []
|
|
|
|
def __call__(self, target, key, **kwargs):
|
|
from backend.app.services.backup_s3 import S3Error
|
|
bucket = self
|
|
|
|
class _Upload:
|
|
def __init__(self):
|
|
self.key, self.received = key, []
|
|
self.completed = self.aborted = False
|
|
bucket.uploads.append(self)
|
|
|
|
@property
|
|
def parts(self):
|
|
return len(self.received)
|
|
|
|
@property
|
|
def size(self):
|
|
return sum(len(p) for p in self.received)
|
|
|
|
def add(self, data):
|
|
if bucket.fail_on_part == len(self.received) + 1:
|
|
raise S3Error("HTTP 503: busy.")
|
|
self.received.append(data)
|
|
|
|
def complete(self):
|
|
self.completed = True
|
|
|
|
def abort(self):
|
|
self.aborted = True
|
|
|
|
return _Upload()
|
|
|
|
|
|
@pytest.fixture
|
|
def offsite(monkeypatch):
|
|
from backend.app.services import backup_s3
|
|
monkeypatch.setattr(backup_s3, "PART_SIZE", 4)
|
|
bucket = _Bucket()
|
|
monkeypatch.setattr(backup_s3, "Upload", bucket)
|
|
target = backup_s3.S3Target("http://archives.example:8081", "curator", "AK", "SK")
|
|
return bucket, target
|
|
|
|
|
|
def test_a_dump_streams_to_the_bucket_in_parts_with_nothing_kept_here(offsite, monkeypatch):
|
|
bucket, target = offsite
|
|
dump = _Dump(output=b"0123456789")
|
|
monkeypatch.setattr("subprocess.Popen", dump)
|
|
result = backup_service.backup_db_offsite(
|
|
db_url="postgresql+psycopg://u:p@h/d", target=target, limit_mbps=50,
|
|
)
|
|
(upload,) = bucket.uploads
|
|
assert upload.received == [b"0123", b"4567", b"89"]
|
|
assert upload.completed and not upload.aborted
|
|
assert re.fullmatch(r"database/fabledcurator-\d{8}T\d{6}Z\.dump", result["key"])
|
|
assert result["key"] == upload.key
|
|
assert (result["bucket"], result["size_bytes"], result["parts"]) == ("curator", 10, 3)
|
|
assert dump.cmd[0] == "nice" and "pg_dump" in dump.cmd and "-Fc" in dump.cmd
|
|
assert "-f" not in dump.cmd # stdout, not a file
|
|
assert dump.cmd[-1] == "postgresql://u:p@h/d"
|
|
|
|
|
|
def test_a_failed_dump_leaves_nothing_in_the_bucket_and_says_why(offsite, monkeypatch):
|
|
bucket, target = offsite
|
|
said = b"pg_dump: dumping contents of table x\npg_dump: error: connection lost\n"
|
|
monkeypatch.setattr("subprocess.Popen", _Dump(output=b"012345", said=said, code=1))
|
|
with pytest.raises(backup_service.DumpFailed, match="^pg_dump: error: connection lost$"):
|
|
backup_service.backup_db_offsite(db_url="postgresql://u@h/d", target=target)
|
|
(upload,) = bucket.uploads
|
|
assert upload.aborted and not upload.completed
|
|
|
|
|
|
def test_a_bucket_that_stops_taking_parts_stops_the_dump(offsite, monkeypatch):
|
|
from backend.app.services.backup_s3 import S3Error
|
|
bucket, target = offsite
|
|
bucket.fail_on_part = 2
|
|
dump = _Dump(output=b"0123456789")
|
|
monkeypatch.setattr("subprocess.Popen", dump)
|
|
with pytest.raises(S3Error):
|
|
backup_service.backup_db_offsite(db_url="postgresql://u@h/d", target=target)
|
|
assert dump.killed
|
|
(upload,) = bucket.uploads
|
|
assert upload.aborted and not upload.completed
|
|
|
|
|
|
def test_dump_names_sort_by_time():
|
|
assert backup_service.offsite_dump_key("20261107T030000Z") == (
|
|
"database/fabledcurator-20261107T030000Z.dump"
|
|
)
|