Add local backup retention: keep-local toggle, download, delete-local
CI / Frontend build (push) Successful in 12s
CI / Script syntax (push) Successful in 3s
CI / Backend tests (push) Successful in 17s

Lets operators opt into keeping the local archive after upload (global
keep_local_backups setting), pull a remote archive back down into the
Proxmox dump directory for manual restore, and drop a locally-retained
copy without touching the remote object. Consolidates deletion so
retention, job-cascade delete, manual delete, and crash-recovery resume
all clean up local copies alongside remote ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-07-15 12:58:52 +02:00
parent 1288ba731d
commit 508decffe3
9 changed files with 404 additions and 35 deletions
+205
View File
@@ -71,6 +71,34 @@ def test_backup_state_transitions_complete_and_delete_local_safely(
assert not archive.exists()
def test_keep_local_backups_setting_preserves_archive_after_completion(
isolated_db, no_notifications, tmp_path, monkeypatch
):
from app import backup_service, commands
settings = _settings(tmp_path)
settings["keep_local_backups"] = True
set_settings(settings)
job = _job()
backup = backup_service.queue_backup_for_job(job)
archive = tmp_path / "vzdump-qemu-110-2026_01_01-00_00_00.vma.zst"
archive.write_bytes(b"backup")
monkeypatch.setattr(commands, "start_proxmox_backup", lambda **kwargs: "UPID:pve:test")
monkeypatch.setattr(commands, "get_task_status", lambda node, upid: {"status": "stopped", "exitstatus": "OK"})
monkeypatch.setattr(commands, "discover_archive", lambda *args: archive)
monkeypatch.setattr(commands, "rclone_copyto", lambda *args: None)
monkeypatch.setattr(commands, "rclone_object_exists", lambda *args: True)
monkeypatch.setattr(backup_service, "apply_retention_for_job", lambda job_id: asyncio.sleep(0))
asyncio.run(backup_service.process_backup(backup["id"]))
final = backup_service.get_backup(backup["id"])
assert final["state"] == "completed"
assert final["local_path"] == str(archive)
assert archive.exists()
def test_only_one_worker_can_claim_queued_backup(isolated_db, no_notifications, tmp_path):
from app import backup_service
@@ -391,3 +419,180 @@ def test_delete_job_deletes_backups_before_removing_job(
assert deleted == [("job-delete-backup", f"job {job['id']} deleted")]
with db() as conn:
assert conn.execute("SELECT COUNT(*) AS count FROM backup_jobs WHERE id = ?", (job["id"],)).fetchone()["count"] == 0
def test_download_backup_locally_refetches_missing_archive(
isolated_db, no_notifications, tmp_path, monkeypatch
):
from app import backup_service, commands
set_settings(_settings(tmp_path))
downloads = []
def fake_rclone_download(rclone_path, remote_object, local_path):
downloads.append((rclone_path, remote_object, local_path))
Path(local_path).write_bytes(b"restored")
monkeypatch.setattr(commands, "rclone_download", fake_rclone_download)
with db() as conn:
conn.execute(
"""
INSERT INTO backups(
id, guest_vmid, guest_name, guest_type, node, state,
remote_path, started_at, completed_at, updated_at
)
VALUES ('download-test', 110, 'test-guest', 'vm', 'pve', 'completed',
'onedrive:pve-cloud-backup/download-test/archive.tar.zst', ?, ?, ?)
""",
(utc_now(), utc_now(), utc_now()),
)
result = asyncio.run(backup_service.download_backup_locally("download-test"))
expected_path = tmp_path / "archive.tar.zst"
assert result["state"] == "completed"
assert result["local_path"] == str(expected_path)
assert expected_path.read_bytes() == b"restored"
assert downloads == [
("/usr/bin/rclone", "onedrive:pve-cloud-backup/download-test/archive.tar.zst", str(expected_path))
]
def test_download_backup_locally_is_noop_when_local_file_present(
isolated_db, no_notifications, tmp_path, monkeypatch
):
from app import backup_service, commands
set_settings(_settings(tmp_path))
local_file = tmp_path / "already-here.tar.zst"
local_file.write_bytes(b"existing")
monkeypatch.setattr(commands, "rclone_download", lambda *args: pytest.fail("rclone_download should not run"))
with db() as conn:
conn.execute(
"""
INSERT INTO backups(
id, guest_vmid, guest_name, guest_type, node, state,
local_path, remote_path, started_at, completed_at, updated_at
)
VALUES ('download-noop', 110, 'test-guest', 'vm', 'pve', 'completed',
?, 'onedrive:pve-cloud-backup/download-noop/archive.tar.zst', ?, ?, ?)
""",
(str(local_file), utc_now(), utc_now(), utc_now()),
)
result = asyncio.run(backup_service.download_backup_locally("download-noop"))
assert result["local_path"] == str(local_file)
assert result["state"] == "completed"
def test_delete_local_copy_removes_only_local_file(isolated_db, no_notifications, tmp_path):
from app import backup_service
local_file = tmp_path / "keep-local.tar.zst"
local_file.write_bytes(b"local copy")
with db() as conn:
conn.execute(
"""
INSERT INTO backups(
id, guest_vmid, guest_name, guest_type, node, state,
local_path, remote_path, started_at, completed_at, updated_at
)
VALUES ('delete-local', 110, 'test-guest', 'vm', 'pve', 'completed',
?, 'onedrive:pve-cloud-backup/delete-local/archive.tar.zst', ?, ?, ?)
""",
(str(local_file), utc_now(), utc_now(), utc_now()),
)
result = asyncio.run(backup_service.delete_local_copy("delete-local"))
assert not local_file.exists()
assert result["local_path"] is None
assert result["state"] == "completed"
assert result["remote_path"] == "onedrive:pve-cloud-backup/delete-local/archive.tar.zst"
def test_delete_local_copy_noop_without_local_path(isolated_db, no_notifications):
from app import backup_service
with db() as conn:
conn.execute(
"""
INSERT INTO backups(
id, guest_vmid, guest_name, guest_type, node, state,
remote_path, started_at, completed_at, updated_at
)
VALUES ('no-local', 110, 'test-guest', 'vm', 'pve', 'completed',
'onedrive:pve-cloud-backup/no-local/archive.tar.zst', ?, ?, ?)
""",
(utc_now(), utc_now(), utc_now()),
)
result = asyncio.run(backup_service.delete_local_copy("no-local"))
assert result["local_path"] is None
assert result["state"] == "completed"
def test_retention_delete_also_removes_local_copy(
isolated_db, no_notifications, tmp_path, monkeypatch
):
from app import backup_service, commands
set_settings(_settings(tmp_path))
job = _job()
with db() as conn:
conn.execute("UPDATE backup_jobs SET retention_value = 0 WHERE id = ?", (job["id"],))
local_file = tmp_path / "retention-local.tar.zst"
local_file.write_bytes(b"local copy")
monkeypatch.setattr(commands, "rclone_object_exists", lambda *args: True)
monkeypatch.setattr(commands, "rclone_delete_file", lambda *args: None)
monkeypatch.setattr(commands, "rclone_rmdir", lambda *args: None)
with db() as conn:
conn.execute(
"""
INSERT INTO backups(
id, job_id, guest_vmid, guest_name, guest_type, node, state,
local_path, remote_path, started_at, completed_at, updated_at
)
VALUES ('retention-local', ?, 110, 'test-guest', 'vm', 'pve', 'completed',
?, 'onedrive:pve-cloud-backup/retention-local/archive.tar.zst', ?, ?, ?)
""",
(job["id"], str(local_file), utc_now(), utc_now(), utc_now()),
)
asyncio.run(backup_service.apply_retention_for_job(job["id"]))
assert not local_file.exists()
final = backup_service.get_backup("retention-local")
assert final["state"] == "deleted"
assert final["local_path"] is None
def test_recover_unfinished_resumes_local_downloading(isolated_db, no_notifications, monkeypatch):
from app import backup_service
resumed = []
async def fake_download_backup_locally(backup_id):
resumed.append(backup_id)
monkeypatch.setattr(backup_service, "download_backup_locally", fake_download_backup_locally)
with db() as conn:
conn.execute(
"""
INSERT INTO backups(id, guest_vmid, guest_name, guest_type, node, state, started_at, updated_at)
VALUES ('resume-download', 110, 'test-guest', 'vm', 'pve', 'local_downloading', ?, ?)
""",
(utc_now(), utc_now()),
)
asyncio.run(backup_service.recover_unfinished())
assert resumed == ["resume-download"]
final = backup_service.get_backup("resume-download")
assert final["state"] == "completed"