feat: Initial commit for OnEver Drive centralized backup system with Windows PyQt6 agent and Proxmox LXC deployment

This commit is contained in:
2026-08-13 19:33:44 -03:00
commit 1bfb808c79
77 changed files with 10675 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
import math
import hashlib
from pathlib import Path
from typing import Tuple
def compute_file_sha256(filepath: Path, buffer_size: int = 1024 * 1024) -> str:
"""Calculates full SHA-256 checksum of a file using streaming buffers to avoid memory overhead."""
hasher = hashlib.sha256()
with open(filepath, "rb") as f:
while True:
chunk = f.read(buffer_size)
if not chunk:
break
hasher.update(chunk)
return hasher.hexdigest()
class FileChunker:
"""Handles splitting large files into discrete chunks and calculating block hashes."""
def __init__(self, filepath: Path, chunk_size: int = 4 * 1024 * 1024):
self.filepath = Path(filepath).resolve()
if not self.filepath.exists():
raise FileNotFoundError(f"File {filepath} not found")
self.file_size = self.filepath.stat().st_size
self.chunk_size = chunk_size
self.total_chunks = max(1, math.ceil(self.file_size / self.chunk_size))
self._cached_sha256 = None
@property
def full_sha256(self) -> str:
if self._cached_sha256 is None:
self._cached_sha256 = compute_file_sha256(self.filepath)
return self._cached_sha256
def get_chunk(self, chunk_index: int) -> Tuple[bytes, str]:
"""
Reads the bytes for chunk `chunk_index` and returns (chunk_data, chunk_sha256).
"""
if chunk_index < 0 or chunk_index >= self.total_chunks:
raise IndexError(f"Chunk index {chunk_index} out of bounds (total: {self.total_chunks})")
offset = chunk_index * self.chunk_size
with open(self.filepath, "rb") as f:
f.seek(offset)
data = f.read(self.chunk_size)
chunk_sha256 = hashlib.sha256(data).hexdigest()
return data, chunk_sha256
+62
View File
@@ -0,0 +1,62 @@
import os
import json
import uuid
from pathlib import Path
from typing import Optional, List
from pydantic import BaseModel, Field
AGENT_HOME = Path(os.environ.get("PROGRAMDATA", "C:/ProgramData")) / "OnEverDrive"
if not AGENT_HOME.exists():
try:
AGENT_HOME.mkdir(parents=True, exist_ok=True)
except Exception:
AGENT_HOME = Path(__file__).resolve().parent.parent / "data"
AGENT_HOME.mkdir(parents=True, exist_ok=True)
CONFIG_FILE = AGENT_HOME / "config.json"
class LocalFolderJob(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
name: str
source_path: str
file_patterns: str = "*.bak,*.mdf"
schedule_interval_minutes: int = 60
min_stable_seconds: int = 60
is_active: bool = True
last_backup_at: Optional[str] = None
last_status: Optional[str] = "En espera"
class AgentConfig(BaseModel):
server_url: str = "http://127.0.0.1:8000"
client_code: Optional[str] = None
device_id: Optional[str] = None
device_token: Optional[str] = None
client_name: Optional[str] = None
chunk_size: int = 4 * 1024 * 1024 # 4 MB
heartbeat_interval_seconds: int = 30
min_stable_time_seconds: int = 60
log_level: str = "INFO"
local_folders: List[LocalFolderJob] = []
# Notification preferences (configurable from GUI and Tray)
enable_notifications: bool = True
notify_on_start: bool = True
notify_on_complete: bool = True
notify_on_error: bool = True
def load_config() -> AgentConfig:
"""Loads agent configuration from disk or returns default configuration."""
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return AgentConfig(**data)
except Exception:
pass
return AgentConfig()
def save_config(config: AgentConfig) -> None:
"""Persists agent configuration to disk."""
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(config.model_dump(), f, indent=2)
+82
View File
@@ -0,0 +1,82 @@
import os
import time
import fnmatch
from pathlib import Path
from typing import List, Tuple, Optional
def is_file_locked(filepath: Path) -> bool:
"""
Checks if a file is locked exclusively by another process (e.g. SQL Server writing .bak).
Attempts to open the file with read-shared permissions.
"""
if not filepath.exists() or not filepath.is_file():
return True
try:
# On Windows, try opening in append/read mode to detect exclusive write lock
with open(filepath, "rb") as f:
f.seek(0, os.SEEK_END)
return False
except (PermissionError, IOError, OSError):
return True
def is_file_stable(filepath: Path, min_stable_seconds: int = 60, sample_interval_seconds: float = 0.5) -> bool:
"""
Ensures that a file is not actively growing or being modified.
Verifies that modification timestamp and size are stable.
"""
if is_file_locked(filepath):
return False
try:
stat_initial = filepath.stat()
initial_size = stat_initial.st_size
initial_mtime = stat_initial.st_mtime
# Check if the file was modified very recently compared to current time
current_time = time.time()
if (current_time - initial_mtime) < min_stable_seconds:
# File was modified less than min_stable_seconds ago; perform sample check
time.sleep(sample_interval_seconds)
stat_second = filepath.stat()
if stat_second.st_size != initial_size or stat_second.st_mtime != initial_mtime:
return False
return True
except Exception:
return False
class DirectoryScanner:
"""Scans Windows source paths for files matching specific backup patterns."""
def __init__(self, source_path: str, file_patterns: str = "*.bak,*.mdf", min_stable_seconds: int = 60):
self.source_path = Path(source_path).resolve()
self.patterns = [p.strip() for p in file_patterns.split(",") if p.strip()]
self.min_stable_seconds = min_stable_seconds
def scan(self) -> List[Path]:
"""Returns list of all matching files that are stable and ready for backup."""
if not self.source_path.exists():
return []
matched_files: List[Path] = []
if self.source_path.is_file():
if self._matches_patterns(self.source_path.name):
matched_files.append(self.source_path)
return matched_files
for root, _, files in os.walk(self.source_path):
for file in files:
if self._matches_patterns(file):
full_path = Path(root) / file
matched_files.append(full_path)
return matched_files
def _matches_patterns(self, filename: str) -> bool:
if not self.patterns or "*" in self.patterns:
return True
for pattern in self.patterns:
if fnmatch.fnmatch(filename.lower(), pattern.lower()):
return True
return False
+193
View File
@@ -0,0 +1,193 @@
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")
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._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):
self.config = load_config()
self.uploader.config = self.config
# 1. Fetch server-assigned jobs
server_jobs = []
try:
base_url = self.config.server_url.rstrip("/")
with httpx.Client(base_url=base_url, headers=self._get_headers(), timeout=15.0) as client:
resp = client.get("/api/jobs/agent/assigned")
if resp.status_code == 200:
server_jobs = resp.json()
except Exception:
pass
# 2. Combine server jobs + user local folders
all_jobs = []
for sj in server_jobs:
all_jobs.append({
"job_id": sj.get("id"),
"name": sj.get("name"),
"source_path": sj.get("source_path"),
"file_patterns": sj.get("file_patterns", "*.*"),
"min_stable_seconds": sj.get("min_stable_time_seconds", 60)
})
for lj in self.config.local_folders:
if lj.is_active:
all_jobs.append({
"job_id": None,
"local_job_id": lj.id,
"name": lj.name,
"source_path": lj.source_path,
"file_patterns": lj.file_patterns,
"min_stable_seconds": lj.min_stable_seconds
})
# 3. Process jobs
for job in all_jobs:
source_path = job["source_path"]
file_patterns = job["file_patterns"]
min_stable = job["min_stable_seconds"]
job_name = job["name"]
scanner = DirectoryScanner(source_path, file_patterns, min_stable_seconds=min_stable)
files = scanner.scan()
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)")
# Single notification when the process starts
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.get("job_id"),
progress_callback=on_chunk_progress
)
logger.info(f"Successfully backed up {filepath.name}!")
# Update local folder last backup status
if "local_job_id" in job:
for folder in self.config.local_folders:
if folder.id == job["local_job_id"]:
folder.last_backup_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
folder.last_status = "Backup Exitoso"
save_config(self.config)
# Single notification when the process finishes
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)}")
if self.on_error:
self.on_error(filepath.name, str(ex))
+102
View File
@@ -0,0 +1,102 @@
import sqlite3
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Dict, Any, List
from agent.config import AGENT_HOME
STATE_DB_PATH = AGENT_HOME / "agent_state.db"
class StateDatabase:
"""Local SQLite database for agent offline resiliency, session resume tracking and file caching."""
def __init__(self, db_path: Path = STATE_DB_PATH):
self.db_path = db_path
self._init_db()
def _get_conn(self) -> sqlite3.Connection:
return sqlite3.connect(str(self.db_path))
def _init_db(self):
with self._get_conn() as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS upload_sessions (
filepath TEXT PRIMARY KEY,
sha256 TEXT NOT NULL,
session_code TEXT NOT NULL,
total_chunks INTEGER NOT NULL,
chunk_size INTEGER NOT NULL,
completed_chunks INTEGER DEFAULT 0,
status TEXT NOT NULL,
last_updated TIMESTAMP NOT NULL
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS completed_files (
filepath TEXT PRIMARY KEY,
sha256 TEXT NOT NULL,
file_size INTEGER NOT NULL,
job_id INTEGER,
last_backup_time TIMESTAMP NOT NULL
)
""")
conn.commit()
def save_session(
self,
filepath: str,
sha256: str,
session_code: str,
total_chunks: int,
chunk_size: int,
status: str = "UPLOADING"
):
with self._get_conn() as conn:
cursor = conn.cursor()
now = datetime.now(timezone.utc).isoformat()
cursor.execute("""
INSERT OR REPLACE INTO upload_sessions
(filepath, sha256, session_code, total_chunks, chunk_size, status, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (filepath, sha256, session_code, total_chunks, chunk_size, status, now))
conn.commit()
def get_session(self, filepath: str) -> Optional[Dict[str, Any]]:
with self._get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT filepath, sha256, session_code, total_chunks, chunk_size, completed_chunks, status FROM upload_sessions WHERE filepath = ?", (filepath,))
row = cursor.fetchone()
if row:
return {
"filepath": row[0],
"sha256": row[1],
"session_code": row[2],
"total_chunks": row[3],
"chunk_size": row[4],
"completed_chunks": row[5],
"status": row[6]
}
return None
def mark_session_completed(self, filepath: str, sha256: str, file_size: int, job_id: Optional[int] = None):
with self._get_conn() as conn:
cursor = conn.cursor()
now = datetime.now(timezone.utc).isoformat()
cursor.execute("DELETE FROM upload_sessions WHERE filepath = ?", (filepath,))
cursor.execute("""
INSERT OR REPLACE INTO completed_files (filepath, sha256, file_size, job_id, last_backup_time)
VALUES (?, ?, ?, ?, ?)
""", (filepath, sha256, file_size, job_id, now))
conn.commit()
def is_file_already_backed_up(self, filepath: str, current_sha256: str) -> bool:
with self._get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT sha256 FROM completed_files WHERE filepath = ?", (filepath,))
row = cursor.fetchone()
if row and row[0] == current_sha256:
return True
return False
state_db = StateDatabase()
+117
View File
@@ -0,0 +1,117 @@
import time
from pathlib import Path
from typing import Optional, Callable, Dict, Any, List
import httpx
from agent.config import AgentConfig, load_config
from agent.chunker import FileChunker
from agent.state_db import state_db
class ChunkUploader:
"""HTTP/HTTPS Chunk transfer client with automatic resumption and retry backoff."""
def __init__(self, config: Optional[AgentConfig] = None):
self.config = config or load_config()
def _get_headers(self) -> Dict[str, str]:
if not self.config.device_id or not self.config.device_token:
raise ValueError("Agent is not registered. Run 'agent_cli.py register' first.")
return {
"X-Device-Id": self.config.device_id,
"X-Device-Token": self.config.device_token
}
def upload_file(
self,
filepath: Path,
job_id: Optional[int] = None,
progress_callback: Optional[Callable[[int, int, float], None]] = None,
max_retries: int = 5
) -> Dict[str, Any]:
"""
Uploads a file chunk by chunk. If interrupted, querying the server will return
already received chunks, allowing immediate resumption without re-uploading completed parts.
"""
filepath = Path(filepath).resolve()
chunker = FileChunker(filepath=filepath, chunk_size=self.config.chunk_size)
full_sha256 = chunker.full_sha256
filename = filepath.name
headers = self._get_headers()
base_url = self.config.server_url.rstrip("/")
with httpx.Client(base_url=base_url, headers=headers, timeout=60.0) as client:
# 1. Initialize or resume upload session
init_payload = {
"filename": filename,
"file_size": chunker.file_size,
"sha256": full_sha256,
"chunk_size": chunker.chunk_size,
"job_id": job_id
}
resp = client.post("/api/upload/session", json=init_payload)
resp.raise_for_status()
session_data = resp.json()
session_code = session_data["session_code"]
total_chunks = session_data["total_chunks"]
received_chunks = set(session_data.get("received_chunks", []))
state_db.save_session(
filepath=str(filepath),
sha256=full_sha256,
session_code=session_code,
total_chunks=total_chunks,
chunk_size=chunker.chunk_size,
status="UPLOADING"
)
# 2. Upload missing chunks
missing_chunks = [i for i in range(total_chunks) if i not in received_chunks]
for idx in missing_chunks:
chunk_bytes, chunk_hash = chunker.get_chunk(idx)
# Retry loop with exponential backoff for network resilience
success = False
attempt = 0
while not success and attempt < max_retries:
try:
chunk_headers = {
"Content-Type": "application/octet-stream",
"X-Chunk-Index": str(idx),
"X-Chunk-SHA256": chunk_hash
}
c_resp = client.post(
f"/api/upload/{session_code}/chunk?chunk_index={idx}&chunk_sha256={chunk_hash}",
content=chunk_bytes,
headers=chunk_headers
)
c_resp.raise_for_status()
success = True
except Exception as ex:
attempt += 1
if attempt >= max_retries:
raise ConnectionError(f"Failed to upload chunk {idx} after {max_retries} attempts: {str(ex)}")
time.sleep(2 ** attempt)
received_chunks.add(idx)
if progress_callback:
pct = round((len(received_chunks) / total_chunks) * 100, 2)
progress_callback(len(received_chunks), total_chunks, pct)
# 3. Complete and verify full file assembly on server
complete_resp = client.post(f"/api/upload/{session_code}/complete")
complete_resp.raise_for_status()
result = complete_resp.json()
# 4. Update local state database
state_db.mark_session_completed(
filepath=str(filepath),
sha256=full_sha256,
file_size=chunker.file_size,
job_id=job_id
)
return result