291 lines
12 KiB
Python
291 lines
12 KiB
Python
import time
|
|
import socket
|
|
import platform
|
|
import threading
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Optional, Callable, Dict, Any, List
|
|
import httpx
|
|
|
|
from agent.config import AgentConfig, load_config, save_config, LocalFolderJob
|
|
from agent.scanner import DirectoryScanner, is_file_stable
|
|
from agent.chunker import compute_file_sha256
|
|
from agent.uploader import ChunkUploader
|
|
from agent.state_db import state_db
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] [OnEver Agent] %(message)s"
|
|
)
|
|
logger = logging.getLogger("OnEverAgent")
|
|
|
|
def is_job_due(schedule_str: str, last_run_str: Optional[str]) -> bool:
|
|
if not schedule_str:
|
|
return True
|
|
if not last_run_str:
|
|
return True
|
|
|
|
try:
|
|
# Try parsing ISO (from server) or standard YYYY-MM-DD HH:MM:SS (local)
|
|
if "T" in last_run_str:
|
|
last_run = datetime.fromisoformat(last_run_str.replace("Z", "+00:00"))
|
|
else:
|
|
last_run = datetime.strptime(last_run_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
|
|
except Exception:
|
|
return True
|
|
|
|
now = datetime.now(timezone.utc)
|
|
delta = now - last_run
|
|
|
|
sched = schedule_str.lower().strip()
|
|
if sched == "hourly":
|
|
return delta.total_seconds() >= 3600
|
|
elif sched == "daily":
|
|
return delta.total_seconds() >= 86400
|
|
elif sched == "weekly":
|
|
return delta.total_seconds() >= 86400 * 7
|
|
elif sched == "monthly":
|
|
return delta.total_seconds() >= 86400 * 30
|
|
else:
|
|
return True
|
|
|
|
class AgentDaemon:
|
|
"""Background service worker for Windows: handles heartbeats, job polling and scheduled backups."""
|
|
|
|
def __init__(
|
|
self,
|
|
config: Optional[AgentConfig] = None,
|
|
on_started: Optional[Callable[[str, int], None]] = None,
|
|
on_progress: Optional[Callable[[str, int, int, float], None]] = None,
|
|
on_completed: Optional[Callable[[str, str, int], None]] = None,
|
|
on_error: Optional[Callable[[str, str], None]] = None,
|
|
on_status: Optional[Callable[[str, str], None]] = None
|
|
):
|
|
self.config = config or load_config()
|
|
self.running = False
|
|
self.uploader = ChunkUploader(self.config)
|
|
self.last_server_jobs = []
|
|
self._heartbeat_thread: Optional[threading.Thread] = None
|
|
self._worker_thread: Optional[threading.Thread] = None
|
|
|
|
# Event callbacks
|
|
self.on_started = on_started
|
|
self.on_progress = on_progress
|
|
self.on_completed = on_completed
|
|
self.on_error = on_error
|
|
self.on_status = on_status
|
|
|
|
def start(self):
|
|
self.config = load_config()
|
|
if not self.config.device_id or not self.config.device_token:
|
|
logger.warning("Agent is not registered yet. Waiting for registration.")
|
|
if self.on_status:
|
|
self.on_status("UNREGISTERED", "El agente no está registrado en el servidor.")
|
|
return
|
|
|
|
self.running = True
|
|
logger.info(f"Starting OnEver Drive Windows Agent ({self.config.client_code} - {self.config.client_name})")
|
|
logger.info(f"Target Server: {self.config.server_url}")
|
|
|
|
if self.on_status:
|
|
self.on_status("ONLINE", f"Conectado a {self.config.server_url} ({self.config.client_code})")
|
|
|
|
self._heartbeat_thread = threading.Thread(target=self._heartbeat_loop, daemon=True)
|
|
self._worker_thread = threading.Thread(target=self._backup_worker_loop, daemon=True)
|
|
|
|
self._heartbeat_thread.start()
|
|
self._worker_thread.start()
|
|
|
|
def stop(self):
|
|
logger.info("Stopping agent daemon...")
|
|
self.running = False
|
|
if self.on_status:
|
|
self.on_status("PAUSED", "Servicio en pausa.")
|
|
|
|
def _get_headers(self):
|
|
return {
|
|
"X-Device-Id": self.config.device_id,
|
|
"X-Device-Token": self.config.device_token
|
|
}
|
|
|
|
def _heartbeat_loop(self):
|
|
while self.running:
|
|
try:
|
|
base_url = self.config.server_url.rstrip("/")
|
|
with httpx.Client(base_url=base_url, headers=self._get_headers(), timeout=10.0) as client:
|
|
resp = client.get("/api/jobs/agent/assigned")
|
|
if resp.status_code == 200:
|
|
logger.debug("Heartbeat acknowledged by server.")
|
|
except Exception as ex:
|
|
logger.warning(f"Heartbeat failed: {str(ex)}")
|
|
|
|
time.sleep(self.config.heartbeat_interval_seconds)
|
|
|
|
def _backup_worker_loop(self):
|
|
while self.running:
|
|
try:
|
|
self._run_backup_cycle()
|
|
except Exception as ex:
|
|
logger.error(f"Error during backup cycle: {str(ex)}")
|
|
|
|
time.sleep(30)
|
|
|
|
def _run_backup_cycle(self, force: bool = False):
|
|
self.config = load_config()
|
|
self.uploader.config = self.config
|
|
|
|
# 1. Fetch server-assigned jobs and run bidirectional sync
|
|
server_jobs = []
|
|
sync_success = False
|
|
try:
|
|
base_url = self.config.server_url.rstrip("/")
|
|
with httpx.Client(base_url=base_url, headers=self._get_headers(), timeout=10.0) as client:
|
|
resp = client.get("/api/jobs/agent/assigned")
|
|
if resp.status_code == 200:
|
|
server_jobs = resp.json()
|
|
self.last_server_jobs = server_jobs
|
|
sync_success = True
|
|
except Exception as e:
|
|
logger.warning(f"Could not fetch server jobs: {e}")
|
|
|
|
config_changed = False
|
|
|
|
if sync_success:
|
|
# A. Sync Server -> Local
|
|
server_job_ids = {sj["id"] for sj in server_jobs}
|
|
|
|
# Remove local jobs that have a job_id but are not on the server anymore (deleted on server)
|
|
local_jobs_to_keep = []
|
|
for lj in self.config.local_folders:
|
|
if lj.job_id is None:
|
|
# New local job, keep it so we register it next
|
|
local_jobs_to_keep.append(lj)
|
|
elif lj.job_id in server_job_ids:
|
|
# Keep it and update local properties from server
|
|
sj = next(x for x in server_jobs if x["id"] == lj.job_id)
|
|
lj.name = sj.get("name", lj.name)
|
|
lj.source_path = sj.get("source_path", lj.source_path)
|
|
lj.file_patterns = sj.get("file_patterns", lj.file_patterns)
|
|
lj.schedule_cron = sj.get("schedule_cron", lj.schedule_cron)
|
|
lj.min_stable_seconds = sj.get("min_stable_time_seconds", lj.min_stable_seconds)
|
|
|
|
# Also sync last_run_at from server if available and newer
|
|
if sj.get("last_run_at"):
|
|
lj.last_backup_at = sj["last_run_at"].replace("T", " ")[:19]
|
|
lj.last_status = sj.get("status", lj.last_status)
|
|
|
|
local_jobs_to_keep.append(lj)
|
|
else:
|
|
# Deleted on server, don't keep it
|
|
config_changed = True
|
|
|
|
self.config.local_folders = local_jobs_to_keep
|
|
|
|
# Add server jobs that are missing locally
|
|
local_job_ids = {lj.job_id for lj in self.config.local_folders if lj.job_id is not None}
|
|
for sj in server_jobs:
|
|
if sj["id"] not in local_job_ids:
|
|
new_job = LocalFolderJob(
|
|
job_id=sj["id"],
|
|
name=sj["name"],
|
|
source_path=sj["source_path"],
|
|
file_patterns=sj["file_patterns"],
|
|
schedule_cron=sj["schedule_cron"],
|
|
min_stable_seconds=sj["min_stable_time_seconds"],
|
|
last_status=sj.get("status", "En espera")
|
|
)
|
|
if sj.get("last_run_at"):
|
|
new_job.last_backup_at = sj["last_run_at"].replace("T", " ")[:19]
|
|
self.config.local_folders.append(new_job)
|
|
config_changed = True
|
|
|
|
# B. Sync Local -> Server (Register new local folders on the server)
|
|
for lj in self.config.local_folders:
|
|
if lj.job_id is None:
|
|
try:
|
|
base_url = self.config.server_url.rstrip("/")
|
|
payload = {
|
|
"name": lj.name,
|
|
"source_path": lj.source_path,
|
|
"file_patterns": lj.file_patterns,
|
|
"schedule_cron": lj.schedule_cron,
|
|
"min_stable_time_seconds": lj.min_stable_seconds
|
|
}
|
|
resp = httpx.post(f"{base_url}/api/jobs/agent/register", headers=self._get_headers(), json=payload, timeout=10.0)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
lj.job_id = data["id"]
|
|
config_changed = True
|
|
logger.info(f"Registered local job '{lj.name}' on server with ID {lj.job_id}")
|
|
except Exception as e:
|
|
logger.warning(f"Could not register local job '{lj.name}' on server: {e}")
|
|
|
|
if config_changed:
|
|
save_config(self.config)
|
|
|
|
# 2. Process active jobs
|
|
for job in self.config.local_folders:
|
|
if not job.is_active:
|
|
continue
|
|
|
|
# Check if job is due or forced
|
|
if not force and not is_job_due(job.schedule_cron, job.last_backup_at):
|
|
continue
|
|
|
|
source_path = job.source_path
|
|
file_patterns = job.file_patterns
|
|
min_stable = job.min_stable_seconds
|
|
job_name = job.name
|
|
|
|
# Skip if path does not exist
|
|
if not Path(source_path).exists():
|
|
logger.warning(f"Source path {source_path} for job {job_name} does not exist. Skipping.")
|
|
continue
|
|
|
|
scanner = DirectoryScanner(source_path, file_patterns, min_stable_seconds=min_stable)
|
|
files = scanner.scan()
|
|
|
|
# Track files successfully backed up in this run
|
|
for filepath in files:
|
|
if not is_file_stable(filepath, min_stable_seconds=min_stable):
|
|
logger.warning(f"File {filepath.name} is currently locked or growing. Skipping.")
|
|
continue
|
|
|
|
current_sha = compute_file_sha256(filepath)
|
|
if state_db.is_file_already_backed_up(str(filepath), current_sha):
|
|
continue
|
|
|
|
file_size_bytes = filepath.stat().st_size
|
|
logger.info(f"Starting backup for file: {filepath.name} ({file_size_bytes / (1024*1024):.2f} MB)")
|
|
|
|
if self.on_started:
|
|
self.on_started(filepath.name, file_size_bytes)
|
|
|
|
def on_chunk_progress(done, total, pct):
|
|
if self.on_progress:
|
|
self.on_progress(filepath.name, done, total, pct)
|
|
|
|
try:
|
|
res = self.uploader.upload_file(
|
|
filepath,
|
|
job_id=job.job_id,
|
|
progress_callback=on_chunk_progress
|
|
)
|
|
logger.info(f"Successfully backed up {filepath.name}!")
|
|
|
|
if self.on_completed:
|
|
self.on_completed(filepath.name, res.get("sha256", ""), file_size_bytes)
|
|
|
|
except Exception as ex:
|
|
logger.error(f"Failed to backup {filepath.name}: {str(ex)}")
|
|
job.last_status = "Error"
|
|
save_config(self.config)
|
|
if self.on_error:
|
|
self.on_error(filepath.name, str(ex))
|
|
|
|
# Update job state in config after checking directory
|
|
job.last_backup_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
|
job.last_status = "Backup Exitoso" if job.last_status != "Error" else "Error"
|
|
save_config(self.config)
|