feat: ✨ backup pruning
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user