Initial pve cloud backup app

This commit is contained in:
Codex
2026-07-15 01:00:08 +02:00
commit 7a4c561f8e
37 changed files with 5495 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
import sys
from pathlib import Path
import pytest
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
@pytest.fixture()
def isolated_db(tmp_path, monkeypatch):
from app import db as db_module
monkeypatch.setattr(db_module, "DB_PATH", tmp_path / "app.db")
from app.migrations import run_migrations
run_migrations()
return tmp_path / "app.db"
@pytest.fixture()
def no_notifications(monkeypatch):
async def noop(*args, **kwargs):
return None
import app.backup_service as backup_service
monkeypatch.setattr(backup_service, "notify", noop)
+394
View File
@@ -0,0 +1,394 @@
import asyncio
from pathlib import Path
import pytest
from app.db import db, utc_now
from app.jobs import create_job
from app.settings_store import set_settings
def _settings(tmp_path):
return {
"setup_complete": False,
"proxmox_node": "pve",
"proxmox_storage": "hitachi",
"local_backup_dir": str(tmp_path),
"rclone_path": "/usr/bin/rclone",
"rclone_remote": "onedrive",
"rclone_remote_path": "Backups/Proxmox",
"discord_webhook_url": "",
"allowed_cors_origins": [],
"default_compression": "zstd",
"default_backup_mode": "snapshot",
"default_retention_type": "days",
"default_retention_value": 30,
"max_concurrent_backups": 1,
}
def _job():
return create_job(
{
"guest_vmid": 110,
"guest_name": "adguard",
"guest_type": "vm",
"node": "pve",
"enabled": True,
"cron_schedule": "0 2 * * *",
"proxmox_storage": "hitachi",
"backup_mode": "snapshot",
"compression": "zstd",
"retention_type": "latest",
"retention_value": 1,
}
)
def test_backup_state_transitions_complete_and_delete_local_safely(
isolated_db, no_notifications, tmp_path, monkeypatch
):
from app import backup_service, commands
set_settings(_settings(tmp_path))
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["upid"] == "UPID:pve:test"
assert final["remote_path"].startswith("onedrive:Backups/Proxmox/")
assert not archive.exists()
def test_only_one_worker_can_claim_queued_backup(isolated_db, no_notifications, tmp_path):
from app import backup_service
set_settings(_settings(tmp_path))
job = _job()
backup = backup_service.queue_backup_for_job(job)
first = backup_service._claim_queued_backup(backup["id"])
second = backup_service._claim_queued_backup(backup["id"])
assert first is not None
assert first["state"] == "pve_running"
assert second is None
def test_pve_running_without_upid_fails_without_polling_none(isolated_db, no_notifications, monkeypatch):
from app import backup_service, commands
poll_calls = []
monkeypatch.setattr(commands, "get_task_status", lambda *args: poll_calls.append(args))
with db() as conn:
conn.execute(
"""
INSERT INTO backups(id, guest_vmid, guest_name, guest_type, node, state, started_at, updated_at)
VALUES ('missing-upid', 110, 'adguard', 'lxc', 'astrid', 'pve_running', ?, ?)
""",
(utc_now(), utc_now()),
)
asyncio.run(backup_service.process_backup("missing-upid"))
final = backup_service.get_backup("missing-upid")
assert final["state"] == "failed"
assert "no Proxmox UPID" in final["error_message"]
assert poll_calls == []
def test_local_archive_is_not_deleted_when_remote_verification_fails(
isolated_db, no_notifications, tmp_path, monkeypatch
):
from app import backup_service, commands
set_settings(_settings(tmp_path))
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: False)
asyncio.run(backup_service.process_backup(backup["id"]))
final = backup_service.get_backup(backup["id"])
assert final["state"] == "failed"
assert archive.exists()
def test_retention_latest_selects_expired_remote_backups(
isolated_db, no_notifications, tmp_path, monkeypatch
):
from app import backup_service
set_settings(_settings(tmp_path))
job = _job()
deleted = []
async def fake_delete_remote_backup(backup, reason):
deleted.append((backup["id"], reason))
monkeypatch.setattr(backup_service, "delete_remote_backup", fake_delete_remote_backup)
with db() as conn:
for index in range(3):
backup_id = f"backup-{index}"
conn.execute(
"""
INSERT INTO backups(
id, job_id, guest_vmid, guest_name, guest_type, node, state,
remote_path, started_at, completed_at, updated_at
)
VALUES (?, ?, 110, 'adguard', 'vm', 'pve', 'completed', ?, ?, ?, ?)
""",
(
backup_id,
job["id"],
f"crypt:backup/{index}",
f"2026-01-01T00:0{index}:00+00:00",
f"2026-01-01T00:0{index}:30+00:00",
utc_now(),
),
)
asyncio.run(backup_service.apply_retention_for_job(job["id"]))
assert deleted == [
("backup-1", "retention latest=1"),
("backup-0", "retention latest=1"),
]
def test_startup_recovery_resumes_known_states(isolated_db, no_notifications, monkeypatch):
from app import backup_service
resumed = []
deleted = []
async def fake_process_backup(backup_id):
resumed.append(backup_id)
async def fake_delete_remote_backup(backup, reason):
deleted.append((backup["id"], reason))
monkeypatch.setattr(backup_service, "process_backup", fake_process_backup)
monkeypatch.setattr(backup_service, "delete_remote_backup", fake_delete_remote_backup)
with db() as conn:
for backup_id, state in (("b1", "local_ready"), ("b2", "deleting"), ("b3", "completed")):
conn.execute(
"""
INSERT INTO backups(id, guest_vmid, guest_name, guest_type, node, state, started_at, updated_at)
VALUES (?, 110, 'adguard', 'vm', 'pve', ?, ?, ?)
""",
(backup_id, state, utc_now(), utc_now()),
)
asyncio.run(backup_service.recover_unfinished())
assert resumed == ["b1"]
assert deleted == [("b2", "recovery delete resume")]
def test_retention_sweep_applies_all_jobs_with_completed_remote_backups(
isolated_db, no_notifications, tmp_path, monkeypatch
):
from app import backup_service
set_settings(_settings(tmp_path))
job = _job()
swept = []
async def fake_apply_retention_for_job(job_id):
swept.append(job_id)
monkeypatch.setattr(backup_service, "apply_retention_for_job", fake_apply_retention_for_job)
with db() as conn:
conn.execute(
"""
INSERT INTO backups(
id, job_id, guest_vmid, guest_name, guest_type, node, state,
remote_path, started_at, completed_at, updated_at
)
VALUES ('sweep-test', ?, 110, 'adguard', 'vm', 'pve', 'completed',
'onedrive:pve-cloud-backup/sweep-test/archive.tar.zst', ?, ?, ?)
""",
(job["id"], utc_now(), utc_now(), utc_now()),
)
asyncio.run(backup_service.retention_sweep())
assert swept == [job["id"]]
def test_manual_backup_delete_removes_remote_and_known_local_file(
isolated_db, no_notifications, tmp_path, monkeypatch
):
from app import backup_service, commands
set_settings(_settings(tmp_path))
local_file = tmp_path / "orphaned-local.tar.zst"
local_file.write_text("backup")
deleted_files = []
removed_dirs = []
monkeypatch.setattr(commands, "rclone_object_exists", lambda *args: True)
monkeypatch.setattr(commands, "rclone_delete_file", lambda *args: deleted_files.append(args))
monkeypatch.setattr(commands, "rclone_rmdir", lambda *args: removed_dirs.append(args))
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 ('manual-delete', 110, 'adguard', 'vm', 'pve', 'completed',
?, 'onedrive:pve-cloud-backup/manual-delete/archive.tar.zst', ?, ?, ?)
""",
(str(local_file), utc_now(), utc_now(), utc_now()),
)
deleted = asyncio.run(backup_service.delete_backup_manually("manual-delete"))
assert deleted["state"] == "deleted"
assert not local_file.exists()
assert deleted_files == [
(
"/usr/bin/rclone",
"onedrive:pve-cloud-backup/manual-delete/archive.tar.zst",
)
]
assert removed_dirs == [
(
"/usr/bin/rclone",
"onedrive:pve-cloud-backup/manual-delete",
)
]
def test_manual_backup_delete_removes_empty_remote_folder_when_file_is_already_missing(
isolated_db, no_notifications, tmp_path, monkeypatch
):
from app import backup_service, commands
set_settings(_settings(tmp_path))
removed_dirs = []
monkeypatch.setattr(commands, "rclone_object_exists", lambda *args: False)
monkeypatch.setattr(commands, "rclone_delete_file", lambda *args: pytest.fail("deletefile should not run"))
monkeypatch.setattr(commands, "rclone_rmdir", lambda *args: removed_dirs.append(args))
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 ('missing-remote-file', 110, 'adguard', 'vm', 'pve', 'completed',
'onedrive:pve-cloud-backup/missing-remote-file/archive.tar.zst', ?, ?, ?)
""",
(utc_now(), utc_now(), utc_now()),
)
deleted = asyncio.run(backup_service.delete_backup_manually("missing-remote-file"))
assert deleted["state"] == "deleted"
assert removed_dirs == [
(
"/usr/bin/rclone",
"onedrive:pve-cloud-backup/missing-remote-file",
)
]
def test_manual_backup_delete_refuses_active_backup(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, started_at, updated_at)
VALUES ('active-delete', 110, 'adguard', 'vm', 'pve', 'uploading', ?, ?)
""",
(utc_now(), utc_now()),
)
with pytest.raises(RuntimeError):
asyncio.run(backup_service.delete_backup_manually("active-delete"))
def test_deleting_already_deleted_backup_purges_metadata_row(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, deleted_at, updated_at
)
VALUES ('purge-deleted', 110, 'adguard', 'vm', 'pve', 'deleted',
'onedrive:pve-cloud-backup/purge-deleted/archive.tar.zst', ?, ?, ?)
""",
(utc_now(), utc_now(), utc_now()),
)
result = asyncio.run(backup_service.delete_backup_manually("purge-deleted"))
assert result == {"id": "purge-deleted", "purged": True}
with db() as conn:
assert conn.execute("SELECT COUNT(*) AS count FROM backups WHERE id = 'purge-deleted'").fetchone()["count"] == 0
def test_delete_job_deletes_backups_before_removing_job(
isolated_db, no_notifications, tmp_path, monkeypatch
):
from app import backup_service
set_settings(_settings(tmp_path))
job = _job()
deleted = []
async def fake_delete_backup_manually(backup_id, reason="manual deletion"):
deleted.append((backup_id, reason))
with db() as conn:
conn.execute("UPDATE backups SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?", (utc_now(), utc_now(), backup_id))
row = conn.execute("SELECT * FROM backups WHERE id = ?", (backup_id,)).fetchone()
return dict(row)
monkeypatch.setattr(backup_service, "delete_backup_manually", fake_delete_backup_manually)
with db() as conn:
conn.execute(
"""
INSERT INTO backups(id, job_id, guest_vmid, guest_name, guest_type, node, state, remote_path, started_at, completed_at, updated_at)
VALUES ('job-delete-backup', ?, 110, 'adguard', 'vm', 'pve', 'completed',
'onedrive:pve-cloud-backup/job-delete-backup/archive.tar.zst', ?, ?, ?)
""",
(job["id"], utc_now(), utc_now(), utc_now()),
)
result = asyncio.run(backup_service.delete_job_with_backups(job["id"]))
assert result == {"job_id": job["id"], "deleted_backups": ["job-delete-backup"]}
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
+112
View File
@@ -0,0 +1,112 @@
import json
from pathlib import Path
import pytest
from app import commands
def test_start_proxmox_backup_parses_upid(monkeypatch):
expected = "UPID:pve:0001:0002:0003:vzdump:110:root@pam:"
def fake_run(command, timeout=None):
assert command[:3] == ["pvesh", "create", "/nodes/pve/vzdump"]
assert "--output-format" in command
return commands.CommandResult(stdout=json.dumps(expected), stderr="")
monkeypatch.setattr(commands, "run", fake_run)
assert commands.start_proxmox_backup(
node="pve",
vmid=110,
storage="hitachi",
mode="snapshot",
compression="zstd",
) == expected
def test_start_proxmox_backup_parses_plain_upid(monkeypatch):
expected = "UPID:pve:0001:0002:0003:vzdump:110:root@pam:"
monkeypatch.setattr(commands, "run", lambda command, timeout=None: commands.CommandResult(stdout=expected, stderr=""))
assert commands.start_proxmox_backup(
node="pve",
vmid=110,
storage="hitachi",
mode="snapshot",
compression="zstd",
) == expected
def test_start_proxmox_backup_parses_upid_after_info_lines(monkeypatch):
expected = "UPID:pve:0001:0002:0003:vzdump:110:root@pam:"
output = f"INFO: starting new backup job\nINFO: Backup job finished successfully\n\"{expected}\""
monkeypatch.setattr(commands, "run", lambda command, timeout=None: commands.CommandResult(stdout=output, stderr=""))
assert commands.start_proxmox_backup(
node="pve",
vmid=110,
storage="hitachi",
mode="snapshot",
compression="zstd",
) == expected
def test_list_guests_normalizes_vm_and_lxc(monkeypatch):
payload = [
{"vmid": "110", "name": "adguard", "type": "qemu", "node": "pve", "status": "running"},
{"vmid": "111", "name": "ct", "type": "lxc", "node": "pve", "status": "stopped"},
]
monkeypatch.setattr(commands, "pvesh_json", lambda args: payload)
guests = commands.list_guests()
assert guests == [
{"vmid": 110, "name": "adguard", "type": "vm", "node": "pve", "status": "running"},
{"vmid": 111, "name": "ct", "type": "lxc", "node": "pve", "status": "stopped"},
]
def test_discover_archive_finds_newest_matching_file(tmp_path):
old_file = tmp_path / "vzdump-qemu-110-2026_01_01-00_00_00.vma.zst"
new_file = tmp_path / "vzdump-qemu-110-2026_01_01-00_01_00.vma.zst"
log_file = tmp_path / "vzdump-qemu-110-2026_01_01-00_01_00.log"
wrong_guest = tmp_path / "vzdump-qemu-999-2026_01_01-00_01_00.vma.zst"
for path in (old_file, new_file, log_file, wrong_guest):
path.write_text("data")
old_file.touch()
new_file.touch()
log_file.touch()
found = commands.discover_archive(str(tmp_path), 110, "vm", "2020-01-01T00:00:00+00:00")
assert found == new_file
def test_discover_archive_raises_when_missing(tmp_path):
with pytest.raises(FileNotFoundError):
commands.discover_archive(str(tmp_path), 110, "vm", "2020-01-01T00:00:00+00:00")
def test_rclone_commands_use_exact_objects(monkeypatch):
calls = []
def fake_run(command, timeout=None):
calls.append(command)
return commands.CommandResult(stdout="", stderr="")
monkeypatch.setattr(commands, "run", fake_run)
commands.rclone_copyto("/usr/bin/rclone", "/tmp/archive.zst", "crypt:backup/archive.zst")
commands.rclone_delete_file("/usr/bin/rclone", "crypt:backup/archive.zst")
commands.rclone_rmdir("/usr/bin/rclone", "crypt:backup")
assert calls == [
["/usr/bin/rclone", "copyto", "/tmp/archive.zst", "crypt:backup/archive.zst"],
["/usr/bin/rclone", "deletefile", "crypt:backup/archive.zst"],
["/usr/bin/rclone", "rmdir", "crypt:backup"],
]
assert all("sync" not in call for call in calls for call in call)
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime, timezone
import pytest
from app.jobs import next_run, validate_cron
def test_cron_validation_accepts_standard_expression():
validate_cron("0 2 * * *")
assert next_run("0 2 * * *")
def test_cron_validation_rejects_invalid_expression():
with pytest.raises(ValueError):
validate_cron("not a cron")
def test_next_run_interprets_cron_in_configured_timezone():
base = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
assert next_run("0 2 * * *", base=base, timezone_name="Europe/Berlin") == "2026-01-01T01:00:00+00:00"
+42
View File
@@ -0,0 +1,42 @@
from app import commands
from app.main import _validate_settings
def test_plain_onedrive_remote_is_accepted(monkeypatch):
monkeypatch.setattr(commands, "rclone_list_remotes", lambda rclone_path: ["onedrive"])
_validate_settings(
{
"setup_complete": True,
"proxmox_node": "pve",
"proxmox_storage": "hitachi",
"local_backup_dir": "/mnt/pve/hitachi/dump",
"rclone_path": "/usr/bin/rclone",
"rclone_remote": "onedrive",
"rclone_remote_path": "Backups/Proxmox",
"max_concurrent_backups": 1,
"timezone": "Europe/Berlin",
}
)
def test_invalid_timezone_is_rejected(monkeypatch):
monkeypatch.setattr(commands, "rclone_list_remotes", lambda rclone_path: ["onedrive"])
import pytest
from fastapi import HTTPException
with pytest.raises(HTTPException):
_validate_settings(
{
"setup_complete": True,
"proxmox_node": "pve",
"proxmox_storage": "hitachi",
"local_backup_dir": "/mnt/pve/hitachi/dump",
"rclone_path": "/usr/bin/rclone",
"rclone_remote": "onedrive",
"rclone_remote_path": "Backups/Proxmox",
"max_concurrent_backups": 1,
"timezone": "Not/AZone",
}
)