Files
onever_drive/windows-agent/agent/service.py
T

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)