Files
pve-cloud-backups/backend/app/migrations.py
T
Codex 508decffe3
CI / Frontend build (push) Successful in 12s
CI / Script syntax (push) Successful in 3s
CI / Backend tests (push) Successful in 17s
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>
2026-07-15 12:58:52 +02:00

204 lines
7.3 KiB
Python

from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from .db import db, utc_now
def _normalize_timezone(value: str) -> str:
timezone_name = value.strip()
if not timezone_name:
return "UTC"
if timezone_name.startswith("/"):
marker = "/zoneinfo/"
if marker in timezone_name:
timezone_name = timezone_name.split(marker, 1)[1]
else:
timezone_name = timezone_name.lstrip("/")
try:
ZoneInfo(timezone_name)
except (ValueError, ZoneInfoNotFoundError):
return "UTC"
return timezone_name
def _system_timezone() -> str:
timezone_file = Path("/etc/timezone")
if timezone_file.exists():
value = timezone_file.read_text().strip()
if value:
return _normalize_timezone(value)
return "UTC"
DEFAULT_SETTINGS = {
"setup_complete": "false",
"proxmox_node": "",
"proxmox_storage": "",
"local_backup_dir": "/var/lib/vz/dump",
"rclone_path": "/usr/bin/rclone",
"rclone_remote": "",
"rclone_remote_path": "pve-cloud-backup",
"discord_webhook_url": "",
"allowed_cors_origins": "http://localhost:5173,http://127.0.0.1:5173",
"default_compression": "zstd",
"default_backup_mode": "snapshot",
"default_retention_type": "days",
"default_retention_value": "30",
"max_concurrent_backups": "1",
"timezone": _system_timezone(),
"keep_local_backups": "false",
}
def run_migrations() -> None:
with db() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
)
"""
)
applied = {
row["version"]
for row in conn.execute("SELECT version FROM schema_migrations").fetchall()
}
if 1 not in applied:
conn.executescript(
"""
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE backup_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guest_vmid INTEGER NOT NULL,
guest_name TEXT NOT NULL,
guest_type TEXT NOT NULL CHECK (guest_type IN ('vm','lxc')),
node TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
cron_schedule TEXT NOT NULL,
proxmox_storage TEXT NOT NULL,
backup_mode TEXT NOT NULL,
compression TEXT NOT NULL,
retention_type TEXT NOT NULL CHECK (retention_type IN ('days','latest')),
retention_value INTEGER NOT NULL,
next_run_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE backups (
id TEXT PRIMARY KEY,
job_id INTEGER,
guest_vmid INTEGER NOT NULL,
guest_name TEXT NOT NULL,
guest_type TEXT NOT NULL,
node TEXT NOT NULL,
state TEXT NOT NULL,
upid TEXT,
local_path TEXT,
remote_path TEXT,
size_bytes INTEGER,
error_message TEXT,
started_at TEXT NOT NULL,
pve_completed_at TEXT,
upload_started_at TEXT,
completed_at TEXT,
deleted_at TEXT,
updated_at TEXT NOT NULL,
FOREIGN KEY (job_id) REFERENCES backup_jobs(id) ON DELETE SET NULL
);
CREATE TABLE notification_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
backup_id TEXT,
payload TEXT NOT NULL,
success INTEGER NOT NULL,
error_message TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (backup_id) REFERENCES backups(id) ON DELETE SET NULL
);
CREATE INDEX idx_backup_jobs_enabled_next_run ON backup_jobs(enabled, next_run_at);
CREATE INDEX idx_backups_guest_state ON backups(guest_vmid, state);
CREATE INDEX idx_backups_state ON backups(state);
"""
)
now = utc_now()
conn.executemany(
"INSERT INTO settings(key, value, updated_at) VALUES (?, ?, ?)",
[(key, value, now) for key, value in DEFAULT_SETTINGS.items()],
)
conn.execute(
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
(1, now),
)
if 2 not in applied:
now = utc_now()
conn.execute(
"""
INSERT INTO settings(key, value, updated_at)
VALUES ('rclone_remote_path', 'pve-cloud-backup', ?)
ON CONFLICT(key) DO NOTHING
""",
(now,),
)
conn.execute(
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
(2, now),
)
if 3 not in applied:
now = utc_now()
conn.execute(
"""
INSERT INTO settings(key, value, updated_at)
VALUES ('timezone', ?, ?)
ON CONFLICT(key) DO NOTHING
""",
(DEFAULT_SETTINGS["timezone"], now),
)
conn.execute(
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
(3, now),
)
if 4 not in applied:
now = utc_now()
row = conn.execute("SELECT value FROM settings WHERE key = 'timezone'").fetchone()
if row:
normalized_timezone = _normalize_timezone(row["value"])
if normalized_timezone != row["value"]:
conn.execute(
"UPDATE settings SET value = ?, updated_at = ? WHERE key = 'timezone'",
(normalized_timezone, now),
)
conn.execute(
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
(4, now),
)
if 5 not in applied:
now = utc_now()
conn.execute("ALTER TABLE backup_jobs DROP COLUMN proxmox_storage")
conn.execute(
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
(5, now),
)
if 6 not in applied:
now = utc_now()
conn.execute(
"""
INSERT INTO settings(key, value, updated_at)
VALUES ('keep_local_backups', 'false', ?)
ON CONFLICT(key) DO NOTHING
""",
(now,),
)
conn.execute(
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
(6, now),
)