feat: implementar Fase 2 del roadmap (Modo Replica Exacta / Mirroring)
This commit is contained in:
+83
-2
@@ -5,11 +5,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.models import User, Client, BackupJob
|
||||
from app.schemas.schemas import JobCreate, JobUpdate, JobResponse, AgentJobRegister
|
||||
from app.models.models import User, Client, BackupJob, BackupFile
|
||||
from app.schemas.schemas import JobCreate, JobUpdate, JobResponse, AgentJobRegister, PurgeOrphansRequest
|
||||
from app.api.deps import get_current_user, require_admin, get_current_client
|
||||
from app.services.event_service import log_event
|
||||
from app.ws.manager import ws_manager
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
router = APIRouter(prefix="/jobs", tags=["Backup Jobs"])
|
||||
|
||||
@@ -53,6 +56,11 @@ async def agent_register_job(
|
||||
name=payload.name,
|
||||
source_path=payload.source_path,
|
||||
file_patterns=payload.file_patterns,
|
||||
exclude_patterns=payload.exclude_patterns,
|
||||
skip_hidden=payload.skip_hidden,
|
||||
skip_system=payload.skip_system,
|
||||
skip_readonly=payload.skip_readonly,
|
||||
sync_deletions=payload.sync_deletions,
|
||||
schedule_cron=payload.schedule_cron,
|
||||
keep_daily=payload.keep_daily,
|
||||
keep_weekly=payload.keep_weekly,
|
||||
@@ -126,6 +134,11 @@ async def create_job(
|
||||
name=payload.name,
|
||||
source_path=payload.source_path,
|
||||
file_patterns=payload.file_patterns,
|
||||
exclude_patterns=payload.exclude_patterns,
|
||||
skip_hidden=payload.skip_hidden,
|
||||
skip_system=payload.skip_system,
|
||||
skip_readonly=payload.skip_readonly,
|
||||
sync_deletions=payload.sync_deletions,
|
||||
schedule_cron=payload.schedule_cron,
|
||||
keep_daily=payload.keep_daily,
|
||||
keep_weekly=payload.keep_weekly,
|
||||
@@ -241,3 +254,71 @@ async def trigger_job(
|
||||
})
|
||||
|
||||
return {"message": f"Job {job.job_code} triggered"}
|
||||
|
||||
@router.post("/{job_id}/purge-orphans")
|
||||
async def purge_orphans(
|
||||
job_id: int,
|
||||
payload: PurgeOrphansRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_client: Client = Depends(get_current_client)
|
||||
):
|
||||
"""
|
||||
Called by the Windows Agent to delete backup files on the server that no longer exist
|
||||
in the client's local directory (orphans), enforcing replica mode.
|
||||
"""
|
||||
res = await db.execute(select(BackupJob).where(BackupJob.id == job_id, BackupJob.client_id == current_client.id))
|
||||
job = res.scalar_one_or_none()
|
||||
if not job:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Job not found or not owned by this client"
|
||||
)
|
||||
|
||||
if not job.sync_deletions:
|
||||
return {"purged_count": 0, "message": "Replica mode (sync_deletions) is not enabled for this job."}
|
||||
|
||||
files_res = await db.execute(
|
||||
select(BackupFile)
|
||||
.where(
|
||||
BackupFile.job_id == job_id,
|
||||
BackupFile.client_id == current_client.id,
|
||||
BackupFile.is_active == True
|
||||
)
|
||||
)
|
||||
db_files = files_res.scalars().all()
|
||||
|
||||
client_paths = {p.strip().replace("\\", "/").lower() for p in payload.active_relative_paths}
|
||||
|
||||
purged_count = 0
|
||||
total_freed_bytes = 0
|
||||
from app.storage import storage_provider
|
||||
|
||||
for db_file in db_files:
|
||||
normalized_db_path = db_file.client_relative_path.strip().replace("\\", "/").lower()
|
||||
if normalized_db_path not in client_paths:
|
||||
try:
|
||||
await storage_provider.delete_backup_file(db_file.relative_path)
|
||||
db_file.is_active = False
|
||||
total_freed_bytes += db_file.file_size
|
||||
purged_count += 1
|
||||
|
||||
await log_event(
|
||||
db=db,
|
||||
event_type="FILE_PURGED",
|
||||
message=f"Purged orphan file '{db_file.filename}' from server storage (Replica Mode).",
|
||||
severity="WARNING",
|
||||
client_id=current_client.id,
|
||||
job_id=job.id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to purge orphan file {db_file.filename}: {e}")
|
||||
|
||||
if purged_count > 0:
|
||||
current_client.storage_used_bytes = max(0, current_client.storage_used_bytes - total_freed_bytes)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"purged_count": purged_count,
|
||||
"freed_bytes": total_freed_bytes,
|
||||
"message": f"Successfully purged {purged_count} orphan files from server."
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ async def init_session(
|
||||
file_size=payload.file_size,
|
||||
sha256_full=payload.sha256,
|
||||
chunk_size=payload.chunk_size,
|
||||
job_id=payload.job_id
|
||||
job_id=payload.job_id,
|
||||
client_relative_path=payload.client_relative_path
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user