Enhance WebDAV backup functionality: implement backup rotation for snapshots and update UI description for backup settings
This commit is contained in:
+96
-5
@@ -8,9 +8,10 @@ import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
import httpx
|
||||
from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile
|
||||
@@ -192,6 +193,79 @@ def webdav_file_url(folder_url: str) -> str:
|
||||
return folder_url.rstrip("/") + "/faerro-kb.sqlite3"
|
||||
|
||||
|
||||
def retained_backup_archives(names: list[str], now: datetime) -> set[str]:
|
||||
archives = []
|
||||
for name in names:
|
||||
match = re.fullmatch(r"faerro-kb-(\d{8}T\d{6}Z)-r\d+\.sqlite3", name)
|
||||
if not match:
|
||||
continue
|
||||
timestamp = datetime.strptime(match.group(1), "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
|
||||
if timestamp <= now:
|
||||
archives.append((name, timestamp))
|
||||
|
||||
daily: dict[object, tuple[str, datetime]] = {}
|
||||
weekly: dict[object, tuple[str, datetime]] = {}
|
||||
monthly: dict[object, tuple[str, datetime]] = {}
|
||||
yearly: dict[object, tuple[str, datetime]] = {}
|
||||
today = now.date()
|
||||
current_week = today - timedelta(days=today.weekday())
|
||||
current_month = now.year * 12 + now.month
|
||||
|
||||
for name, timestamp in archives:
|
||||
date = timestamp.date()
|
||||
age_days = (today - date).days
|
||||
week = date - timedelta(days=date.weekday())
|
||||
age_weeks = (current_week - week).days // 7
|
||||
age_months = current_month - (timestamp.year * 12 + timestamp.month)
|
||||
age_years = now.year - timestamp.year
|
||||
for buckets, key, age, limit in (
|
||||
(daily, date, age_days, 7),
|
||||
(weekly, (week.isocalendar().year, week.isocalendar().week), age_weeks, 4),
|
||||
(monthly, (timestamp.year, timestamp.month), age_months, 12),
|
||||
(yearly, timestamp.year, age_years, 5),
|
||||
):
|
||||
if 0 <= age < limit and (key not in buckets or timestamp > buckets[key][1]):
|
||||
buckets[key] = (name, timestamp)
|
||||
|
||||
return {
|
||||
name
|
||||
for buckets in (daily, weekly, monthly, yearly)
|
||||
for name, _timestamp in buckets.values()
|
||||
}
|
||||
|
||||
|
||||
async def rotate_remote_backups(client: httpx.AsyncClient, folder_url: str) -> None:
|
||||
request_body = b"""<?xml version="1.0" encoding="utf-8" ?>
|
||||
<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>"""
|
||||
response = await client.request(
|
||||
"PROPFIND",
|
||||
folder_url,
|
||||
headers={"Depth": "1", "Content-Type": "application/xml"},
|
||||
content=request_body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
root = ET.fromstring(response.content)
|
||||
folder_path = urlsplit(folder_url).path.rstrip("/")
|
||||
remote_archives = []
|
||||
for item in root.findall("{DAV:}response"):
|
||||
href = item.findtext("{DAV:}href")
|
||||
if not href:
|
||||
continue
|
||||
item_url = httpx.URL(folder_url).join(href)
|
||||
item_path = item_url.path
|
||||
if item_path.rsplit("/", 1)[0].rstrip("/") != folder_path:
|
||||
continue
|
||||
name = unquote(item_path.rsplit("/", 1)[-1])
|
||||
if re.fullmatch(r"faerro-kb-\d{8}T\d{6}Z-r\d+\.sqlite3", name):
|
||||
remote_archives.append((name, str(item_url)))
|
||||
|
||||
retained = retained_backup_archives([name for name, _url in remote_archives], datetime.now(timezone.utc))
|
||||
for name, item_url in remote_archives:
|
||||
if name not in retained:
|
||||
response = await client.delete(item_url)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def run_backup() -> None:
|
||||
async with BACKUP_LOCK:
|
||||
while True:
|
||||
@@ -217,9 +291,19 @@ async def run_backup() -> None:
|
||||
(revision,),
|
||||
)
|
||||
auth = (settings["username"], settings["password"]) if settings["username"] else None
|
||||
archive_name = (
|
||||
f"faerro-kb-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}"
|
||||
f"-r{revision:010d}.sqlite3"
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=120, auth=auth) as client:
|
||||
response = await client.put(
|
||||
f"{settings['url'].rstrip('/')}/{archive_name}",
|
||||
content=file_chunks(snapshot_path),
|
||||
)
|
||||
response.raise_for_status()
|
||||
response = await client.put(target_url, content=file_chunks(snapshot_path))
|
||||
response.raise_for_status()
|
||||
await rotate_remote_backups(client, settings["url"])
|
||||
with connect_db() as connection:
|
||||
connection.execute(
|
||||
"UPDATE backup_state SET completed_revision = ?, last_error = NULL WHERE id = 1",
|
||||
@@ -378,7 +462,7 @@ def get_backup_status() -> dict[str, object]:
|
||||
|
||||
|
||||
@app.put("/api/backup/config")
|
||||
def configure_backup(payload: dict[str, str]) -> dict[str, object]:
|
||||
async def configure_backup(payload: dict[str, str]) -> dict[str, object]:
|
||||
if BACKUP_WEBDAV_URL:
|
||||
raise HTTPException(status_code=409, detail="WebDAV backup is managed by environment variables")
|
||||
url = payload.get("url", "").strip()
|
||||
@@ -411,11 +495,18 @@ async def test_backup_connection() -> dict[str, str]:
|
||||
test_url = f"{folder_url}/faerro-kb-test-{uuid.uuid4().hex}"
|
||||
auth = (settings["username"], settings["password"]) if settings["username"] else None
|
||||
async with httpx.AsyncClient(timeout=30, auth=auth) as client:
|
||||
response = await client.request(
|
||||
"PROPFIND",
|
||||
folder_url,
|
||||
headers={"Depth": "1", "Content-Type": "application/xml"},
|
||||
content=b'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>',
|
||||
)
|
||||
response.raise_for_status()
|
||||
response = await client.put(test_url, content=b"Faerro KB WebDAV test")
|
||||
response.raise_for_status()
|
||||
response = await client.delete(test_url)
|
||||
response.raise_for_status()
|
||||
return {"status": "ok", "message": "WebDAV write and delete test passed."}
|
||||
return {"status": "ok", "message": "WebDAV listing, write, and delete test passed."}
|
||||
except httpx.HTTPStatusError as error:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
@@ -426,7 +517,7 @@ async def test_backup_connection() -> dict[str, str]:
|
||||
|
||||
|
||||
@app.post("/api/backup/retry", status_code=202)
|
||||
def retry_backup() -> dict[str, object]:
|
||||
async def retry_backup() -> dict[str, object]:
|
||||
if not get_backup_settings()["url"]:
|
||||
raise HTTPException(status_code=400, detail="Configure a WebDAV folder first")
|
||||
schedule_backup()
|
||||
|
||||
Reference in New Issue
Block a user