feat: Initial commit for OnEver Drive centralized backup system with Windows PyQt6 agent and Proxmox LXC deployment
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Set
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models.models import BackupJob, BackupFile, Client
|
||||
from app.storage.local import storage_provider
|
||||
from app.services.event_service import log_event
|
||||
|
||||
async def apply_retention_policy(db: AsyncSession, job_id: int) -> int:
|
||||
"""
|
||||
Applies retention rules (daily, weekly, monthly) for a specific backup job.
|
||||
Safely deletes obsolete backup files from storage and database.
|
||||
Returns the number of pruned backup files.
|
||||
"""
|
||||
# Fetch job details
|
||||
result = await db.execute(select(BackupJob).where(BackupJob.id == job_id))
|
||||
job = result.scalar_one_or_none()
|
||||
if not job:
|
||||
return 0
|
||||
|
||||
# Fetch all active backup files for this job ordered by creation date descending
|
||||
result = await db.execute(
|
||||
select(BackupFile)
|
||||
.where(BackupFile.job_id == job_id, BackupFile.is_active == True)
|
||||
.order_by(BackupFile.created_at.desc())
|
||||
)
|
||||
backup_files: List[BackupFile] = result.scalars().all()
|
||||
|
||||
if not backup_files:
|
||||
return 0
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
protected_file_ids: Set[int] = set()
|
||||
|
||||
# Always keep the most recent backup regardless of age to avoid empty repo
|
||||
protected_file_ids.add(backup_files[0].id)
|
||||
|
||||
# 1. Daily retention: Keep 1 backup per calendar day for the last `job.keep_daily` days
|
||||
seen_days = set()
|
||||
for bf in backup_files:
|
||||
day_key = bf.created_at.strftime("%Y-%m-%d")
|
||||
age_days = (now - bf.created_at).total_seconds() / 86400.0
|
||||
if age_days <= job.keep_daily:
|
||||
if day_key not in seen_days:
|
||||
seen_days.add(day_key)
|
||||
protected_file_ids.add(bf.id)
|
||||
|
||||
# 2. Weekly retention: Keep 1 backup per calendar week for the last `job.keep_weekly` weeks
|
||||
seen_weeks = set()
|
||||
for bf in backup_files:
|
||||
week_key = f"{bf.created_at.year}-W{bf.created_at.isocalendar()[1]:02d}"
|
||||
age_weeks = (now - bf.created_at).total_seconds() / (86400.0 * 7)
|
||||
if age_weeks <= job.keep_weekly:
|
||||
if week_key not in seen_weeks:
|
||||
seen_weeks.add(week_key)
|
||||
protected_file_ids.add(bf.id)
|
||||
|
||||
# 3. Monthly retention: Keep 1 backup per calendar month for the last `job.keep_monthly` months
|
||||
seen_months = set()
|
||||
for bf in backup_files:
|
||||
month_key = bf.created_at.strftime("%Y-%m")
|
||||
# Approximate age in months (30 days per month)
|
||||
age_months = (now - bf.created_at).total_seconds() / (86400.0 * 30.4375)
|
||||
if age_months <= job.keep_monthly:
|
||||
if month_key not in seen_months:
|
||||
seen_months.add(month_key)
|
||||
protected_file_ids.add(bf.id)
|
||||
|
||||
# Identify files to delete
|
||||
files_to_delete = [bf for bf in backup_files if bf.id not in protected_file_ids]
|
||||
pruned_count = 0
|
||||
reclaimed_bytes = 0
|
||||
|
||||
for bf in files_to_delete:
|
||||
try:
|
||||
# Delete from physical storage
|
||||
await storage_provider.delete_backup_file(bf.relative_path)
|
||||
bf.is_active = False
|
||||
pruned_count += 1
|
||||
reclaimed_bytes += bf.file_size
|
||||
except Exception as ex:
|
||||
# Log failure but continue processing other files
|
||||
await log_event(
|
||||
db=db,
|
||||
event_type="RETENTION_ERROR",
|
||||
message=f"Failed to delete pruned backup file {bf.filename}: {str(ex)}",
|
||||
severity="WARNING",
|
||||
client_id=job.client_id,
|
||||
job_id=job.id
|
||||
)
|
||||
|
||||
if pruned_count > 0:
|
||||
# Update client storage used counter
|
||||
client_res = await db.execute(select(Client).where(Client.id == job.client_id))
|
||||
client = client_res.scalar_one_or_none()
|
||||
if client:
|
||||
client.storage_used_bytes = max(0, client.storage_used_bytes - reclaimed_bytes)
|
||||
|
||||
await db.commit()
|
||||
|
||||
await log_event(
|
||||
db=db,
|
||||
event_type="RETENTION_APPLIED",
|
||||
message=f"Retention policy applied for job '{job.name}': {pruned_count} obsolete backups removed, {reclaimed_bytes / (1024*1024):.2f} MB reclaimed.",
|
||||
severity="INFO",
|
||||
client_id=job.client_id,
|
||||
job_id=job.id
|
||||
)
|
||||
|
||||
return pruned_count
|
||||
Reference in New Issue
Block a user