feat: backup pruning

This commit is contained in:
2026-03-23 11:16:36 +00:00
parent dd3170464f
commit 8d12327752
4 changed files with 588 additions and 1 deletions
+271 -1
View File
@@ -7,7 +7,9 @@ from werkzeug.security import check_password_hash, generate_password_hash
from werkzeug.utils import secure_filename
from functools import wraps
from pathlib import Path
from datetime import datetime
from datetime import datetime, timedelta
import threading
import time
from dotenv import load_dotenv
from database import Database
@@ -392,6 +394,270 @@ def delete_backup(backup_id):
return redirect(url_for('backups'))
def prune_backups(
*,
scope_type: str,
scope_instance_id: int | None,
keep_days: int | None,
keep_count: int | None,
) -> dict:
"""
Prune backups by retention rules.
- keep_days: delete backups older than (now - keep_days)
- keep_count: keep N newest backups per instance
"""
if (keep_days is None and keep_count is None) or (keep_days is not None and keep_count is not None):
raise ValueError("Exactly one of keep_days or keep_count must be provided.")
if scope_type not in {"all", "instance"}:
raise ValueError("scope_type must be 'all' or 'instance'.")
instances = []
if scope_type == "instance":
if not scope_instance_id:
raise ValueError("scope_instance_id must be provided when scope_type is 'instance'.")
instance = db.get_instance_by_id(scope_instance_id)
if not instance:
return {"deleted_backups": 0, "deleted_files": 0, "skipped_files": 0, "errors": 1}
instances = [instance]
else:
instances = db.get_all_instances()
now = datetime.now()
cutoff = (now - timedelta(days=keep_days)) if keep_days is not None else None
backups_marked_for_deletion: list[dict] = []
for instance in instances:
backups = db.get_backups_for_instance(instance["id"])
if keep_days is not None:
# db query orders by uploaded_at DESC, but for "older than" it's fine to just filter.
for backup in backups:
uploaded_at = backup.get("uploaded_at")
if uploaded_at is None or uploaded_at < cutoff:
backups_marked_for_deletion.append(backup)
else:
# backups are already sorted DESC; keep first N, prune the rest.
backups_marked_for_deletion.extend(backups[keep_count:])
if not backups_marked_for_deletion:
return {"deleted_backups": 0, "deleted_files": 0, "skipped_files": 0, "errors": 0}
deleted_files = 0
skipped_files = 0
db_ids_to_delete: list[int] = []
for backup in backups_marked_for_deletion:
file_path = Path(backup["file_path"])
try:
if file_path.exists():
file_path.unlink()
deleted_files += 1
else:
# Stale DB rows are still safe to remove.
skipped_files += 1
db_ids_to_delete.append(backup["id"])
except Exception as e:
skipped_files += 1
logger.error(f"Failed deleting backup file {file_path}: {e}", exc_info=True)
deleted_backups = db.delete_backups_by_ids(db_ids_to_delete)
return {
"deleted_backups": deleted_backups,
"deleted_files": deleted_files,
"skipped_files": skipped_files,
"errors": 0,
}
@app.route("/backups/prune", methods=["GET"])
@login_required
def prune_page():
"""Backup pruning manual runner + automated retention settings."""
instances = db.get_all_instances()
prune_settings = db.get_backup_prune_settings()
return render_template("prune.html", instances=instances, prune_settings=prune_settings)
@app.route("/backups/prune/run", methods=["POST"])
@login_required
def prune_run():
"""Run a manual prune based on form criteria."""
if not request.form.get("confirm_prune"):
flash("Confirmation required. Check the confirmation box to prune backups.", "error")
return redirect(url_for("prune_page"))
scope_type = request.form.get("scope_type", "all")
scope_instance_id = request.form.get("scope_instance_id", type=int)
keep_mode = request.form.get("keep_mode")
keep_days = None
keep_count = None
if keep_mode == "days":
keep_days = request.form.get("keep_days", type=int)
elif keep_mode == "count":
keep_count = request.form.get("keep_count", type=int)
else:
flash("Invalid keep mode. Choose either 'Days' or 'Count'.", "error")
return redirect(url_for("prune_page"))
if scope_type == "instance" and not scope_instance_id:
flash("Please select an instance when pruning a single instance.", "error")
return redirect(url_for("prune_page"))
if keep_days is not None and keep_days < 1:
flash("Keep days must be >= 1.", "error")
return redirect(url_for("prune_page"))
if keep_count is not None and keep_count < 1:
flash("Keep count must be >= 1.", "error")
return redirect(url_for("prune_page"))
try:
result = prune_backups(
scope_type=scope_type,
scope_instance_id=scope_instance_id,
keep_days=keep_days,
keep_count=keep_count,
)
except Exception as e:
logger.error(f"Manual prune failed: {e}", exc_info=True)
flash(f"Prune failed: {e}", "error")
return redirect(url_for("prune_page"))
flash(
f"Prune complete. Deleted {result['deleted_backups']} backup records. "
f"Deleted {result['deleted_files']} files.",
"success",
)
return redirect(url_for("prune_page"))
@app.route("/backups/prune/settings", methods=["POST"])
@login_required
def prune_settings():
"""Update automated pruning settings (and optionally run immediately)."""
enabled = bool(request.form.get("enabled"))
scope_type = request.form.get("scope_type", "all")
scope_instance_id = request.form.get("scope_instance_id", type=int)
if scope_type == "all":
scope_instance_id = None
keep_mode = request.form.get("keep_mode")
keep_days = None
keep_count = None
if keep_mode == "days":
keep_days = request.form.get("keep_days", type=int)
elif keep_mode == "count":
keep_count = request.form.get("keep_count", type=int)
else:
flash("Invalid keep mode. Choose either 'Days' or 'Count'.", "error")
return redirect(url_for("prune_page"))
interval_hours = request.form.get("interval_hours", type=int) or 24
if interval_hours < 1:
interval_hours = 24
interval_seconds = interval_hours * 3600
if enabled and scope_type == "instance" and not scope_instance_id:
flash("Please select an instance for automated pruning.", "error")
return redirect(url_for("prune_page"))
if keep_days is not None and keep_days < 1:
flash("Keep days must be >= 1.", "error")
return redirect(url_for("prune_page"))
if keep_count is not None and keep_count < 1:
flash("Keep count must be >= 1.", "error")
return redirect(url_for("prune_page"))
db.upsert_backup_prune_settings(
enabled=enabled,
scope_type=scope_type,
scope_instance_id=scope_instance_id,
keep_days=keep_days if enabled else None,
keep_count=keep_count if enabled else None,
interval_seconds=interval_seconds,
)
# Optional: run immediately.
if request.form.get("action") == "run_now" and enabled:
try:
prune_backups(
scope_type=scope_type,
scope_instance_id=scope_instance_id,
keep_days=keep_days,
keep_count=keep_count,
)
db.set_backup_prune_last_run_at(datetime.now())
except Exception as e:
logger.error(f"Run-now prune failed: {e}", exc_info=True)
flash(f"Saved settings, but run now failed: {e}", "error")
return redirect(url_for("prune_page"))
flash("Saved settings and ran prune now.", "success")
return redirect(url_for("prune_page"))
flash("Automated prune settings saved.", "success")
return redirect(url_for("prune_page"))
def _auto_prune_loop():
"""Background worker that periodically prunes backups based on stored settings."""
while True:
try:
settings = db.get_backup_prune_settings()
enabled = bool(settings.get("enabled"))
interval_seconds = int(settings.get("interval_seconds") or 86400)
if enabled:
keep_days = settings.get("keep_days")
keep_count = settings.get("keep_count")
scope_type = settings.get("scope_type") or "all"
scope_instance_id = settings.get("scope_instance_id")
# Determine keep mode (validated by settings form, but be defensive).
if keep_days is not None and keep_days >= 1 and keep_count is None:
keep_kwargs = {"keep_days": int(keep_days), "keep_count": None}
elif keep_count is not None and keep_count >= 1 and keep_days is None:
keep_kwargs = {"keep_days": None, "keep_count": int(keep_count)}
elif keep_days is not None and keep_days >= 1 and keep_count is not None and keep_count >= 1:
# Prefer days if both are set.
keep_kwargs = {"keep_days": int(keep_days), "keep_count": None}
else:
keep_kwargs = None
last_run_at = settings.get("last_run_at")
should_run = last_run_at is None
if last_run_at is not None:
try:
should_run = (datetime.now() - last_run_at).total_seconds() >= interval_seconds
except Exception:
should_run = True
if keep_kwargs and should_run:
prune_backups(
scope_type=scope_type,
scope_instance_id=scope_instance_id,
keep_days=keep_kwargs["keep_days"],
keep_count=keep_kwargs["keep_count"],
)
db.set_backup_prune_last_run_at(datetime.now())
logger.info(
f"Auto prune ran. scope_type={scope_type}, "
f"scope_instance_id={scope_instance_id}"
)
time.sleep(interval_seconds)
else:
time.sleep(3600)
except Exception as e:
logger.error(f"Auto prune loop error: {e}", exc_info=True)
time.sleep(300)
@app.route('/api/backups/latest')
def api_latest_backups():
"""API endpoint to get the latest backup date and time for each instance."""
@@ -409,6 +675,10 @@ def api_latest_backups():
return jsonify(result)
# Start background auto-prune worker.
threading.Thread(target=_auto_prune_loop, daemon=True).start()
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)