refactor: retire the images tarball backup
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 25s
CI and images / backend-lint-and-test (push) Successful in 50s
CI and images / integration (push) Successful in 2m38s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 8s
CI and images / build-web (push) Successful in 4m10s
CI and images / smoke-web (push) Successful in 1m3s
CI and images / promote (push) Successful in 1s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped

Images and attachments are backed up off-site, one object per file, so the
tar+zstd of /images goes: its service and restore, both tasks, the trigger and
restore routes, the Keep last setting, the screen's Images section, and the
tests. Migration 0122 drops backup_run.tar_path and
import_settings.backup_images_keep_last_n and deletes any images runs.

The local database dump stays for an install with no off-site destination,
and the Backups card now says plainly that images and attachments are backed
up only off-site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 22:07:44 -05:00
co-authored by Claude Opus 5.5
parent 794b04a64e
commit 14936b62dc
14 changed files with 92 additions and 393 deletions
+7 -28
View File
@@ -36,9 +36,6 @@ def fake_subprocess_and_images_root(monkeypatch, tmp_path):
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):
_sentinel(cmd)
@@ -55,7 +52,7 @@ def fake_subprocess_and_images_root(monkeypatch, tmp_path):
def kill(self):
self.returncode = -9
# backup_db/backup_images go through _run_bounded (Popen); restore via run.
# backup_db goes through _run_bounded (Popen); restore via run.
monkeypatch.setattr("subprocess.run", _fake_run)
monkeypatch.setattr("subprocess.Popen", _FakePopen)
@@ -67,7 +64,6 @@ def _seed_backup(db_sync, *, kind, status, started_at, tag=None,
triggered_by="manual", started_at=started_at,
finished_at=finished_at or (started_at + timedelta(seconds=10)),
sql_path="/tmp/fake.sql" if kind == "db" else None,
tar_path="/tmp/fake.tar.zst" if kind == "images" else None,
manifest={},
)
db_sync.add(row)
@@ -185,22 +181,6 @@ async def test_an_off_site_dump_that_fails_is_recorded_with_why(db_sync, monkeyp
assert row.error == "DumpFailed: pg_dump: error: connection lost"
# --- backup_images_task ---------------------------------------------
@pytest.mark.asyncio
async def test_backup_images_task_creates_backup_run(db_sync):
from backend.app.tasks.backup import backup_images_task
result = backup_images_task.delay().get()
row = db_sync.execute(
select(BackupRun.kind, BackupRun.status, BackupRun.tar_path)
.where(BackupRun.id == result["backup_run_id"])
).one()
assert row.kind == "images"
assert row.status == "ok"
assert row.tar_path and row.tar_path.endswith(".tar.zst")
# --- restore_db_task ------------------------------------------------
@@ -226,11 +206,11 @@ async def test_restore_db_task_rejects_non_db_source(db_sync):
from backend.app.tasks.backup import restore_db_task
now = datetime.now(UTC)
img_id = _seed_backup(db_sync, kind="images", status="ok", started_at=now)
media_id = _seed_backup(db_sync, kind="media", status="ok", started_at=now)
db_sync.commit()
with pytest.raises(ValueError):
restore_db_task.delay(source_backup_run_id=img_id).get()
restore_db_task.delay(source_backup_run_id=media_id).get()
# --- prune_backups --------------------------------------------------
@@ -243,7 +223,6 @@ def test_prune_backups_keeps_last_n_untagged_per_kind(db_sync):
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
s.backup_db_keep_last_n = 2
s.backup_images_keep_last_n = 1
db_sync.commit()
now = datetime.now(UTC)
@@ -251,13 +230,13 @@ def test_prune_backups_keeps_last_n_untagged_per_kind(db_sync):
_seed_backup(db_sync, kind="db", status="ok",
started_at=now - timedelta(hours=i))
for i in range(3):
_seed_backup(db_sync, kind="images", status="ok",
_seed_backup(db_sync, kind="media", status="ok",
started_at=now - timedelta(hours=i))
db_sync.commit()
result = prune_backups.apply().get()
assert result["db_deleted"] == 2
assert result["images_deleted"] == 2
assert result["media_deleted"] == 0 # 60 media runs are kept
def test_prune_backups_protects_tagged_rows(db_sync):
@@ -321,9 +300,9 @@ def test_stall_sweep_flips_db_fast_but_spares_running_images(db_sync):
db_sync, kind="db", status="running",
started_at=now - timedelta(minutes=50),
)
# An images backup running 50 min is still well under the 7h window → spared.
# A test restore running 50 min is still well under the 7h window → spared.
img_id = _seed_backup(
db_sync, kind="images", status="running",
db_sync, kind="test", status="running",
started_at=now - timedelta(minutes=50),
)
db_sync.commit()