Add local backup retention: keep-local toggle, download, delete-local
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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user