<message>Update the _set_golden_from_path function to improve the handling of existing golden image files. Replace the existing unlink logic with a more robust method that safely removes files or broken symlinks using the missing_ok parameter. This change enhances the reliability of the backup upload process by ensuring that stale references are properly cleared before setting a new golden image path.
428 lines
14 KiB
Python
428 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Server Sync Service for GNSS Guard Client
|
|
|
|
Syncs validation data to the central GNSS Guard Server.
|
|
Features:
|
|
- Immediate sync on each validation
|
|
- Offline queue for failed syncs
|
|
- Batch catchup for queued records
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import sqlite3
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Dict, Any, List, Optional
|
|
import requests
|
|
|
|
logger = logging.getLogger("gnss_guard.server_sync")
|
|
|
|
|
|
class ServerSync:
|
|
"""
|
|
Syncs validation data to the central GNSS Guard Server.
|
|
|
|
Features:
|
|
- Sends validation results to server after each iteration
|
|
- Queues failed requests for retry
|
|
- Batch sends queued records on successful connection
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
database_path: Path,
|
|
server_url: str,
|
|
server_token: str,
|
|
asset_name: str,
|
|
batch_size: int = 100,
|
|
max_queue_size: int = 1000
|
|
):
|
|
"""
|
|
Initialize server sync service.
|
|
|
|
Args:
|
|
database_path: Path to SQLite database (for sync queue)
|
|
server_url: Base URL of GNSS Guard Server
|
|
server_token: Authentication token for this asset
|
|
asset_name: Name of this asset
|
|
batch_size: Max records to send in batch catchup
|
|
max_queue_size: Max records to keep in queue
|
|
"""
|
|
self.database_path = database_path
|
|
self.server_url = server_url.rstrip('/')
|
|
self.server_token = server_token
|
|
self.asset_name = asset_name
|
|
self.batch_size = batch_size
|
|
self.max_queue_size = max_queue_size
|
|
|
|
# Request timeout (seconds)
|
|
self.timeout = 10
|
|
|
|
# Initialize sync queue table
|
|
self._init_sync_queue_table()
|
|
|
|
logger.info(f"Server sync initialized for asset '{asset_name}' -> {server_url}")
|
|
|
|
def _init_sync_queue_table(self):
|
|
"""Create sync_queue table if it doesn't exist"""
|
|
try:
|
|
conn = sqlite3.connect(str(self.database_path), timeout=5.0)
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("""
|
|
CREATE TABLE IF NOT EXISTS sync_queue (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
validation_timestamp_unix REAL NOT NULL,
|
|
payload TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
attempts INTEGER DEFAULT 0,
|
|
last_attempt_at TEXT,
|
|
UNIQUE(validation_timestamp_unix)
|
|
)
|
|
""")
|
|
|
|
cursor.execute("""
|
|
CREATE INDEX IF NOT EXISTS idx_sync_queue_timestamp
|
|
ON sync_queue(validation_timestamp_unix)
|
|
""")
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
logger.debug("Sync queue table initialized")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to initialize sync queue table: {e}")
|
|
|
|
def _get_headers(self) -> Dict[str, str]:
|
|
"""Get request headers with authentication"""
|
|
return {
|
|
"Authorization": f"Bearer {self.server_token}",
|
|
"Content-Type": "application/json"
|
|
}
|
|
|
|
def sync_validation(self, validation_result: Dict[str, Any]) -> bool:
|
|
"""
|
|
Sync a validation result to the server.
|
|
|
|
If sync fails, the record is queued for later retry.
|
|
If sync succeeds, attempt to send any queued records.
|
|
|
|
Args:
|
|
validation_result: Validation result from CoordinateValidator
|
|
|
|
Returns:
|
|
bool: True if sync succeeded, False if queued
|
|
"""
|
|
# Prepare payload
|
|
payload = {
|
|
"validation_timestamp": validation_result.get("validation_timestamp"),
|
|
"validation_timestamp_unix": validation_result.get("validation_timestamp_unix"),
|
|
"is_valid": validation_result.get("is_valid", False),
|
|
"sources_missing": validation_result.get("sources_missing", []),
|
|
"sources_stale": validation_result.get("sources_stale", []),
|
|
"coordinate_differences": validation_result.get("coordinate_differences", {}),
|
|
"source_coordinates": validation_result.get("source_coordinates", {}),
|
|
"validation_details": validation_result.get("validation_details", {}),
|
|
}
|
|
|
|
# Try to send
|
|
success = self._send_validation(payload)
|
|
|
|
if success:
|
|
# On success, try to send queued records
|
|
self._process_queue()
|
|
else:
|
|
# On failure, queue the record
|
|
self._queue_record(payload)
|
|
|
|
return success
|
|
|
|
def _send_validation(self, payload: Dict[str, Any]) -> bool:
|
|
"""
|
|
Send a single validation record to the server.
|
|
|
|
Args:
|
|
payload: Validation data to send
|
|
|
|
Returns:
|
|
bool: True if successful
|
|
"""
|
|
try:
|
|
url = f"{self.server_url}/api/v1/validation"
|
|
|
|
response = requests.post(
|
|
url,
|
|
json=payload,
|
|
headers=self._get_headers(),
|
|
timeout=self.timeout
|
|
)
|
|
|
|
if response.status_code == 201:
|
|
logger.debug(f"Validation synced to server")
|
|
return True
|
|
elif response.status_code == 401:
|
|
logger.error(f"Server auth failed - check SERVER_TOKEN")
|
|
return False
|
|
else:
|
|
logger.warning(f"Server returned {response.status_code}: {response.text[:200]}")
|
|
return False
|
|
|
|
except requests.exceptions.Timeout:
|
|
logger.warning(f"Server request timed out")
|
|
return False
|
|
except requests.exceptions.ConnectionError:
|
|
logger.warning(f"Cannot connect to server at {self.server_url}")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Server sync error: {e}")
|
|
return False
|
|
|
|
def _send_batch(self, records: List[Dict[str, Any]]) -> bool:
|
|
"""
|
|
Send a batch of validation records to the server.
|
|
|
|
Args:
|
|
records: List of validation payloads
|
|
|
|
Returns:
|
|
bool: True if successful
|
|
"""
|
|
try:
|
|
url = f"{self.server_url}/api/v1/validation/batch"
|
|
|
|
response = requests.post(
|
|
url,
|
|
json={"records": records},
|
|
headers=self._get_headers(),
|
|
timeout=self.timeout * 3 # Longer timeout for batch
|
|
)
|
|
|
|
if response.status_code == 201:
|
|
result = response.json()
|
|
logger.info(f"Batch sync: {result.get('saved', 0)} saved, {result.get('skipped', 0)} skipped")
|
|
return True
|
|
else:
|
|
logger.warning(f"Batch sync failed: {response.status_code}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Batch sync error: {e}")
|
|
return False
|
|
|
|
def _queue_record(self, payload: Dict[str, Any]):
|
|
"""
|
|
Add a validation record to the sync queue.
|
|
|
|
Args:
|
|
payload: Validation data to queue
|
|
"""
|
|
try:
|
|
conn = sqlite3.connect(str(self.database_path), timeout=5.0)
|
|
cursor = conn.cursor()
|
|
|
|
# Check queue size and remove oldest if full
|
|
cursor.execute("SELECT COUNT(*) FROM sync_queue")
|
|
count = cursor.fetchone()[0]
|
|
|
|
if count >= self.max_queue_size:
|
|
# Remove oldest records to make room
|
|
remove_count = count - self.max_queue_size + 10
|
|
cursor.execute("""
|
|
DELETE FROM sync_queue
|
|
WHERE id IN (
|
|
SELECT id FROM sync_queue
|
|
ORDER BY validation_timestamp_unix ASC
|
|
LIMIT ?
|
|
)
|
|
""", (remove_count,))
|
|
logger.warning(f"Sync queue full, removed {remove_count} oldest records")
|
|
|
|
# Insert new record
|
|
cursor.execute("""
|
|
INSERT OR IGNORE INTO sync_queue
|
|
(validation_timestamp_unix, payload, created_at)
|
|
VALUES (?, ?, ?)
|
|
""", (
|
|
payload["validation_timestamp_unix"],
|
|
json.dumps(payload),
|
|
datetime.utcnow().isoformat()
|
|
))
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
logger.debug(f"Queued validation record for later sync")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to queue record: {e}")
|
|
|
|
def _process_queue(self):
|
|
"""Process queued records after successful connection"""
|
|
try:
|
|
conn = sqlite3.connect(str(self.database_path), timeout=5.0)
|
|
cursor = conn.cursor()
|
|
|
|
# Get queued records (oldest first)
|
|
cursor.execute("""
|
|
SELECT id, payload FROM sync_queue
|
|
ORDER BY validation_timestamp_unix ASC
|
|
LIMIT ?
|
|
""", (self.batch_size,))
|
|
|
|
rows = cursor.fetchall()
|
|
conn.close()
|
|
|
|
if not rows:
|
|
return
|
|
|
|
logger.info(f"Processing {len(rows)} queued records")
|
|
|
|
# Parse payloads
|
|
records = []
|
|
record_ids = []
|
|
for row_id, payload_json in rows:
|
|
try:
|
|
records.append(json.loads(payload_json))
|
|
record_ids.append(row_id)
|
|
except json.JSONDecodeError:
|
|
record_ids.append(row_id) # Still mark for deletion if corrupt
|
|
|
|
if not records:
|
|
return
|
|
|
|
# Send batch
|
|
if self._send_batch(records):
|
|
# Remove sent records from queue
|
|
self._remove_from_queue(record_ids)
|
|
else:
|
|
# Update attempt count
|
|
self._update_attempt_count(record_ids)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error processing queue: {e}")
|
|
|
|
def _remove_from_queue(self, record_ids: List[int]):
|
|
"""Remove successfully sent records from queue"""
|
|
if not record_ids:
|
|
return
|
|
|
|
try:
|
|
conn = sqlite3.connect(str(self.database_path), timeout=5.0)
|
|
cursor = conn.cursor()
|
|
|
|
placeholders = ','.join('?' * len(record_ids))
|
|
cursor.execute(f"DELETE FROM sync_queue WHERE id IN ({placeholders})", record_ids)
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
logger.debug(f"Removed {len(record_ids)} records from sync queue")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to remove records from queue: {e}")
|
|
|
|
def _update_attempt_count(self, record_ids: List[int]):
|
|
"""Update attempt count for failed records"""
|
|
if not record_ids:
|
|
return
|
|
|
|
try:
|
|
conn = sqlite3.connect(str(self.database_path), timeout=5.0)
|
|
cursor = conn.cursor()
|
|
|
|
now = datetime.utcnow().isoformat()
|
|
placeholders = ','.join('?' * len(record_ids))
|
|
cursor.execute(f"""
|
|
UPDATE sync_queue
|
|
SET attempts = attempts + 1, last_attempt_at = ?
|
|
WHERE id IN ({placeholders})
|
|
""", [now] + record_ids)
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to update attempt count: {e}")
|
|
|
|
def get_queue_status(self) -> Dict[str, Any]:
|
|
"""
|
|
Get current sync queue status.
|
|
|
|
Returns:
|
|
Dictionary with queue stats
|
|
"""
|
|
try:
|
|
conn = sqlite3.connect(str(self.database_path), timeout=5.0)
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("SELECT COUNT(*) FROM sync_queue")
|
|
count = cursor.fetchone()[0]
|
|
|
|
cursor.execute("SELECT MIN(validation_timestamp_unix), MAX(validation_timestamp_unix) FROM sync_queue")
|
|
oldest, newest = cursor.fetchone()
|
|
|
|
conn.close()
|
|
|
|
return {
|
|
"queued_count": count,
|
|
"oldest_timestamp": oldest,
|
|
"newest_timestamp": newest,
|
|
"queue_full": count >= self.max_queue_size
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get queue status: {e}")
|
|
return {"error": str(e)}
|
|
|
|
def force_sync(self) -> bool:
|
|
"""
|
|
Force a sync of all queued records.
|
|
|
|
Returns:
|
|
bool: True if all records synced successfully
|
|
"""
|
|
logger.info("Starting forced sync of queued records")
|
|
|
|
try:
|
|
conn = sqlite3.connect(str(self.database_path), timeout=5.0)
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT COUNT(*) FROM sync_queue")
|
|
total = cursor.fetchone()[0]
|
|
conn.close()
|
|
|
|
if total == 0:
|
|
logger.info("No records to sync")
|
|
return True
|
|
|
|
synced = 0
|
|
while True:
|
|
# Check if queue is empty
|
|
status = self.get_queue_status()
|
|
if status.get("queued_count", 0) == 0:
|
|
break
|
|
|
|
# Process a batch
|
|
before_count = status["queued_count"]
|
|
self._process_queue()
|
|
|
|
# Check if we made progress
|
|
after_status = self.get_queue_status()
|
|
if after_status.get("queued_count", 0) >= before_count:
|
|
# No progress, connection likely failed
|
|
logger.warning("Sync stalled, connection issue")
|
|
break
|
|
|
|
synced += before_count - after_status.get("queued_count", 0)
|
|
|
|
logger.info(f"Force sync completed: {synced}/{total} records synced")
|
|
return synced == total
|
|
|
|
except Exception as e:
|
|
logger.error(f"Force sync error: {e}")
|
|
return False
|
|
|