583 lines
22 KiB
Python
583 lines
22 KiB
Python
# =================== AIPass ====================
|
|
# Name: google_drive_sync.py
|
|
# Description: Google Drive Integration for AIPass Backup System
|
|
# Version: 2.6.0
|
|
# Created: 2025-10-30
|
|
# Modified: 2026-03-09
|
|
# =============================================
|
|
|
|
"""
|
|
Google Drive Sync Module - Orchestrates Drive backup operations.
|
|
|
|
Routes drive-sync commands, displays CLI output, delegates to
|
|
drive_sync_client handler for API operations.
|
|
"""
|
|
|
|
# =============================================
|
|
# IMPORTS
|
|
# =============================================
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from rich.progress import Progress, BarColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn
|
|
from aipass.cli.apps.modules import console
|
|
from aipass.cli.apps.modules.display import error, warning
|
|
from aipass.prax import logger
|
|
from aipass.backup.apps.handlers.json import json_handler
|
|
|
|
_BACKUP_ROOT = Path(__file__).resolve().parents[2] # src/aipass/backup/
|
|
|
|
# =============================================
|
|
# CONSTANTS & CONFIG
|
|
# =============================================
|
|
|
|
# OAuth scopes for Drive access
|
|
SCOPES = ['https://www.googleapis.com/auth/drive.file']
|
|
|
|
# Module configuration
|
|
MODULE_NAME = "google_drive_sync"
|
|
JSON_DIR = _BACKUP_ROOT / "backup_json"
|
|
CONFIG_FILE = JSON_DIR / f"{MODULE_NAME}_config.json"
|
|
DATA_FILE = JSON_DIR / f"{MODULE_NAME}_data.json"
|
|
LOG_FILE = JSON_DIR / f"{MODULE_NAME}_log.json"
|
|
|
|
# =============================================
|
|
# CLIENT CLASS (delegated to handler)
|
|
# =============================================
|
|
|
|
try:
|
|
from aipass.backup.apps.handlers.operations.drive_sync_client import (
|
|
GoogleDriveSync,
|
|
)
|
|
from aipass.backup.apps.handlers.json.drive_sync_json import (
|
|
load_config as _load_config_fn,
|
|
load_data as _load_data_fn,
|
|
)
|
|
DRIVE_AVAILABLE = True
|
|
except ImportError:
|
|
GoogleDriveSync = None # type: ignore
|
|
_load_config_fn = None # type: ignore
|
|
_load_data_fn = None # type: ignore
|
|
DRIVE_AVAILABLE = False
|
|
logger.info("[google_drive_sync] Google Drive dependencies not available")
|
|
|
|
def _load_config():
|
|
"""Load config using module JSON paths."""
|
|
if _load_config_fn is None:
|
|
return {}
|
|
return _load_config_fn(CONFIG_FILE)
|
|
|
|
def _load_data():
|
|
"""Load data using module JSON paths."""
|
|
if _load_data_fn is None:
|
|
return {}
|
|
return _load_data_fn(DATA_FILE)
|
|
|
|
|
|
# =============================================
|
|
# BUSINESS OPERATIONS (delegated to handlers)
|
|
# =============================================
|
|
|
|
try:
|
|
from aipass.backup.apps.handlers.operations.drive_sync_ops import (
|
|
clear_file_tracker as _clear_file_tracker_handler,
|
|
get_file_tracker_stats,
|
|
test_drive_connection as _test_drive_connection,
|
|
)
|
|
except ImportError:
|
|
_clear_file_tracker_handler = None # type: ignore
|
|
get_file_tracker_stats = None # type: ignore
|
|
_test_drive_connection = None # type: ignore
|
|
|
|
from aipass.backup.apps.handlers.operations.sync_test_ops import (
|
|
create_sync_test_files,
|
|
cleanup_sync_test_dir,
|
|
)
|
|
|
|
|
|
def _show_file_tracker_stats() -> bool:
|
|
"""Display file tracker statistics."""
|
|
try:
|
|
if get_file_tracker_stats is None:
|
|
error("Drive sync dependencies not available")
|
|
return False
|
|
stats = get_file_tracker_stats()
|
|
console.print(f"File Tracker Statistics:")
|
|
console.print(f" - Total tracked files: {stats['total']}")
|
|
if stats['sample']:
|
|
console.print(f" - Sample entries:")
|
|
for i, entry in enumerate(stats['sample']):
|
|
console.print(f" {i+1}. {entry['file']} (last sync: {entry['last_sync']})")
|
|
if stats['truncated']:
|
|
remaining = stats['total'] - len(stats['sample'])
|
|
console.print(f" ... and {remaining} more files")
|
|
return True
|
|
except Exception as e:
|
|
error(f"Error showing tracker stats: {e}")
|
|
logger.error(f"Error showing tracker stats: {e}")
|
|
return False
|
|
|
|
|
|
def _clear_file_tracker() -> bool:
|
|
"""Clear the file tracker cache for fresh sync."""
|
|
try:
|
|
data = _load_data()
|
|
tracker_count = len(data.get("runtime_state", {}).get("file_tracker", {}))
|
|
if _clear_file_tracker_handler is None:
|
|
error("Drive sync dependencies not available")
|
|
return False
|
|
success = _clear_file_tracker_handler()
|
|
if success:
|
|
console.print(f"Cleared {tracker_count} entries from file tracker")
|
|
logger.info(f"Cleared {tracker_count} entries from file tracker")
|
|
else:
|
|
console.print("File tracker already empty or clear failed")
|
|
return success
|
|
except Exception as e:
|
|
error(f"Error clearing file tracker: {e}")
|
|
logger.error(f"Error clearing file tracker: {e}")
|
|
return False
|
|
|
|
|
|
def _test_drive_sync() -> bool:
|
|
"""Test Drive integration."""
|
|
try:
|
|
if GoogleDriveSync is None:
|
|
error("Drive sync dependencies not available")
|
|
return False
|
|
sync = GoogleDriveSync()
|
|
if not sync.authenticate():
|
|
return False
|
|
console.print("Testing folder creation...")
|
|
if _test_drive_connection is None:
|
|
error("Drive sync dependencies not available")
|
|
return False
|
|
result = _test_drive_connection(sync)
|
|
if result:
|
|
folder_id = sync.get_or_create_backup_folder()
|
|
console.print(f"Backup folder ready: {folder_id}")
|
|
else:
|
|
error("Failed to create backup folder")
|
|
return result
|
|
except Exception as e:
|
|
error(f"Test failed: {e}")
|
|
logger.error(f"Test failed: {e}")
|
|
return False
|
|
|
|
def _run_sync_test() -> bool:
|
|
"""Run a small test sync to verify Drive integration."""
|
|
console.print("[bold cyan]Drive Sync Test[/bold cyan]")
|
|
console.print()
|
|
|
|
# Create test files via handler
|
|
setup = create_sync_test_files(_BACKUP_ROOT)
|
|
if not setup["success"]:
|
|
error(f"Failed to create test files: {setup['error']}")
|
|
return False
|
|
|
|
test_dir = setup["test_dir"]
|
|
console.print(f" Created {setup['file_count']} test files in {test_dir}")
|
|
|
|
# Run sync
|
|
if GoogleDriveSync is None:
|
|
error("Drive sync dependencies not available")
|
|
cleanup_sync_test_dir(test_dir)
|
|
return False
|
|
sync = GoogleDriveSync()
|
|
if not sync.authenticate():
|
|
error("Auth failed")
|
|
cleanup_sync_test_dir(test_dir)
|
|
return False
|
|
|
|
console.print(f"\nScanning...")
|
|
files_to_upload, skipped, total = sync.prepare_sync(test_dir, force_sync=True)
|
|
upload_count = len(files_to_upload)
|
|
|
|
console.print(f" Files: {total} total, {upload_count} to upload")
|
|
|
|
# Verify Drive folder before uploading
|
|
console.print(f"\nVerifying Drive folder: AIPass_Test...")
|
|
folder_id = sync.get_or_create_project_folder("AIPass_Test")
|
|
if not folder_id:
|
|
error_msg = sync.last_error or "Unknown error"
|
|
error(f"FAILED: {error_msg}")
|
|
cleanup_sync_test_dir(test_dir)
|
|
return False
|
|
console.print(f" Drive folder ready: AIPass_Test")
|
|
|
|
console.print(f"Uploading...")
|
|
|
|
def show_test_progress(completed, total_upload, _successes):
|
|
"""Display test upload progress to console."""
|
|
console.print(f" {completed}/{total_upload}")
|
|
|
|
result = sync.sync_backup_files(
|
|
test_dir, "AIPass_Test", "sync test", True,
|
|
prepared_files=files_to_upload,
|
|
skipped_count=skipped,
|
|
total_count=total,
|
|
progress_fn=show_test_progress
|
|
)
|
|
|
|
if result.get("error"):
|
|
error(f"FAILED: {result['error']}")
|
|
cleanup_sync_test_dir(test_dir)
|
|
return False
|
|
|
|
console.print()
|
|
if result["success"]:
|
|
console.print(f"[green]Test passed: {result['uploaded']}/{result['total']} files synced[/green]")
|
|
else:
|
|
error(f"Test failed: {result['uploaded']} OK, {result['failed']} failed")
|
|
|
|
# Run it again to verify no duplicates
|
|
console.print(f"\nRe-running sync (should show 0 to upload)...")
|
|
files_to_upload2, _, _ = sync.prepare_sync(test_dir, force_sync=False)
|
|
|
|
if len(files_to_upload2) == 0:
|
|
console.print(f"[green]Dedup check passed: 0 files need re-upload[/green]")
|
|
else:
|
|
error(f"Dedup check failed: {len(files_to_upload2)} files flagged for re-upload")
|
|
|
|
# Cleanup local test dir via handler
|
|
cleanup_sync_test_dir(test_dir)
|
|
console.print(f"\nCleaned up local test files")
|
|
console.print(f"[dim]Test Drive folder 'AIPass_Test' left on Drive for inspection[/dim]")
|
|
|
|
return result["success"]
|
|
|
|
|
|
# =============================================
|
|
# HANDLE_COMMAND (Drone Integration)
|
|
# =============================================
|
|
|
|
def handle_command(args) -> bool:
|
|
"""Route Google Drive sync commands from backup_system orchestrator.
|
|
|
|
Args:
|
|
args: Command-line arguments from CLI parser
|
|
|
|
Returns:
|
|
bool: True if command was handled, False if not a drive sync command
|
|
"""
|
|
if not args:
|
|
print_introspection()
|
|
return True
|
|
|
|
if not hasattr(args, 'command'):
|
|
return False
|
|
|
|
command = args.command
|
|
|
|
if command in ['--help', '-h', 'help']:
|
|
console.print()
|
|
console.print("[bold cyan]google_drive_sync - Google Drive Integration[/bold cyan]")
|
|
console.print()
|
|
console.print("Syncs AIPass backups to Google Drive using OAuth2.")
|
|
console.print()
|
|
console.print("[yellow]Commands:[/yellow]")
|
|
console.print(" drive-test - Test Google Drive connectivity")
|
|
console.print(" drive-sync - Sync backup directory to Google Drive")
|
|
console.print(" drive-sync --test - Run a small test sync to verify integration")
|
|
console.print(" drive-clear-tracker - Clear file tracker cache")
|
|
console.print(" drive-stats - Show file tracker statistics")
|
|
console.print()
|
|
console.print("[yellow]Options:[/yellow]")
|
|
console.print(" --project Project name (default: AIPass)")
|
|
console.print(" --note Sync note (default: Manual sync)")
|
|
console.print(" --force Force upload all files")
|
|
console.print()
|
|
return True
|
|
|
|
if command == 'drive-test':
|
|
return _test_drive_sync()
|
|
|
|
elif command == 'drive-sync':
|
|
# --test flag routes to test mode
|
|
if getattr(args, 'test', False):
|
|
return _run_sync_test()
|
|
|
|
raw_path = getattr(args, 'path', None)
|
|
if raw_path:
|
|
backup_path = Path(raw_path)
|
|
else:
|
|
# Default to snapshot backup directory
|
|
backup_path = _BACKUP_ROOT / "backups" / "system_snapshot"
|
|
if not backup_path.exists():
|
|
error(f"Backup directory not found: {backup_path}")
|
|
return False
|
|
|
|
project = getattr(args, 'project', 'AIPass') or 'AIPass'
|
|
note = getattr(args, 'note', 'Manual sync') or 'Manual sync'
|
|
force = getattr(args, 'force', False)
|
|
|
|
if GoogleDriveSync is None:
|
|
error("Drive sync dependencies not available")
|
|
return False
|
|
sync = GoogleDriveSync()
|
|
if not sync.authenticate():
|
|
error("FAILED: Could not authenticate with Google Drive")
|
|
return False
|
|
|
|
limit = getattr(args, 'limit', 0) or 0
|
|
|
|
# Show last backup timestamps for all modes
|
|
from aipass.backup.apps.handlers.utils.backup_timestamps import get_timestamps, format_age
|
|
ts = get_timestamps()
|
|
console.print()
|
|
console.print("[dim]Last backups:[/dim]")
|
|
console.print(f" [dim]Snapshot: {format_age(ts.get('snapshot'))}[/dim]")
|
|
console.print(f" [dim]Versioned: {format_age(ts.get('versioned'))}[/dim]")
|
|
console.print(f" [dim]Drive sync: {format_age(ts.get('drive_sync'))}[/dim]")
|
|
console.print()
|
|
|
|
# Pre-flight: verify Drive folder FIRST (may reset tracker)
|
|
console.print(f"Verifying Drive folder (project: {project})...")
|
|
folder_id = sync.get_or_create_project_folder(project)
|
|
if not folder_id:
|
|
error_msg = sync.last_error or "Unknown error"
|
|
error(f"FAILED: {error_msg}")
|
|
error("Sync aborted - no files uploaded")
|
|
return False
|
|
console.print(f" Drive folder ready: {project}")
|
|
|
|
if sync.tracker_was_reset:
|
|
warning("Drive folder is new - tracker reset, full re-sync needed")
|
|
|
|
# Phase 1: Scan (local only, fast) - runs AFTER folder check so tracker is accurate
|
|
console.print(f"\nScanning {backup_path}...")
|
|
files_to_upload, skipped, total = sync.prepare_sync(backup_path, force, limit)
|
|
upload_count = len(files_to_upload)
|
|
|
|
# Display plan
|
|
console.print(f" Files considered: {total}{f' (limited to {limit})' if limit > 0 else ''}")
|
|
console.print(f" To upload: {upload_count} ({'forced' if force else 'changed/new'})")
|
|
console.print(f" Unchanged: {skipped}")
|
|
|
|
if upload_count == 0:
|
|
console.print("[green]All files up to date - nothing to sync[/green]")
|
|
return True
|
|
|
|
console.print()
|
|
|
|
# Phase 2: Upload with progress bar
|
|
with Progress(
|
|
TextColumn("{task.description}"),
|
|
BarColumn(bar_width=40),
|
|
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
TextColumn("{task.completed}/{task.total}"),
|
|
TextColumn("[green]{task.fields[ok]} OK[/green]"),
|
|
TimeElapsedColumn(),
|
|
TimeRemainingColumn(),
|
|
console=console,
|
|
) as progress:
|
|
task_id = progress.add_task("Uploading...", total=upload_count, ok=0)
|
|
|
|
def show_progress(completed, total_upload, successes):
|
|
"""Update progress bar."""
|
|
progress.update(task_id, completed=completed, ok=successes)
|
|
|
|
result = sync.sync_backup_files(
|
|
backup_path, project, note, force,
|
|
prepared_files=files_to_upload,
|
|
skipped_count=skipped,
|
|
total_count=total,
|
|
progress_fn=show_progress
|
|
)
|
|
|
|
# Update label when complete
|
|
progress.update(task_id, description="Uploaded ")
|
|
|
|
# Check for errors
|
|
if result.get("error"):
|
|
error(f"FAILED: {result['error']}")
|
|
error("Sync aborted - no files uploaded")
|
|
return False
|
|
|
|
# Summary
|
|
console.print()
|
|
if result["success"]:
|
|
from aipass.backup.apps.handlers.utils.backup_timestamps import update_timestamp, get_timestamps, format_age
|
|
update_timestamp("drive_sync")
|
|
ts = get_timestamps()
|
|
console.print(f"[green]Sync complete: {result['uploaded']} uploaded, {result['skipped']} unchanged[/green]")
|
|
console.print()
|
|
console.print(f"[dim]Backups now:[/dim]")
|
|
console.print(f" [dim]Snapshot: {format_age(ts.get('snapshot'))}[/dim]")
|
|
console.print(f" [dim]Versioned: {format_age(ts.get('versioned'))}[/dim]")
|
|
console.print(f" [dim]Drive sync: {format_age(ts.get('drive_sync'))}[/dim]")
|
|
else:
|
|
error(f"Sync failed: {result['uploaded']} uploaded, {result['failed']} failed, {result['skipped']} unchanged")
|
|
|
|
return result["success"]
|
|
|
|
elif command == 'drive-sync-test':
|
|
return _run_sync_test()
|
|
|
|
elif command == 'drive-clear-tracker':
|
|
return _clear_file_tracker()
|
|
|
|
elif command == 'drive-stats':
|
|
return _show_file_tracker_stats()
|
|
|
|
json_handler.log_operation("drive_command", {"command": command})
|
|
return False
|
|
|
|
# =============================================
|
|
# CLI/EXECUTION
|
|
# =============================================
|
|
|
|
def print_introspection():
|
|
"""Display module introspection info."""
|
|
console.print()
|
|
console.print("google_drive_sync Module")
|
|
console.print("Orchestrates Google Drive backup sync with two-phase upload pipeline")
|
|
console.print()
|
|
console.print("Connected Handlers:")
|
|
console.print(" handlers/operations/")
|
|
console.print(" - drive_sync_client.py (GoogleDriveSync — Drive API client and upload engine)")
|
|
console.print(" - drive_sync_ops.py (clear_file_tracker — reset sync tracker cache)")
|
|
console.print(" - drive_sync_ops.py (get_file_tracker_stats — tracker statistics)")
|
|
console.print(" - drive_sync_ops.py (test_drive_connection — connectivity verification)")
|
|
console.print(" - sync_test_ops.py (create_sync_test_files — generate test fixtures)")
|
|
console.print(" - sync_test_ops.py (cleanup_sync_test_dir — remove test fixtures)")
|
|
console.print(" handlers/json/")
|
|
console.print(" - drive_sync_json.py (load_config — read module config JSON)")
|
|
console.print(" - drive_sync_json.py (load_data — read module data JSON)")
|
|
console.print(" handlers/utils/")
|
|
console.print(" - backup_timestamps.py (get_timestamps, update_timestamp, format_age — backup timing)")
|
|
console.print()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Google Drive Sync for AIPass Backup System",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
COMMANDS:
|
|
Commands: test, sync, sync-test, clear-tracker, show-stats
|
|
|
|
test - Test Google Drive connectivity
|
|
sync - Sync backup directory to Google Drive
|
|
sync-test - Run a small test sync to verify integration
|
|
clear-tracker - Clear file tracker cache
|
|
show-stats - Show file tracker statistics
|
|
|
|
OPTIONS:
|
|
--project - Project name for sync (default: AIPass)
|
|
--note - Note for sync operation (default: Manual sync)
|
|
--force - Force sync all files (ignore tracker)
|
|
|
|
EXAMPLES:
|
|
python3 google_drive_sync.py test
|
|
python3 google_drive_sync.py sync /path/to/backups
|
|
python3 google_drive_sync.py sync /path/to/backups --project "MyProject" --note "Daily backup"
|
|
python3 google_drive_sync.py sync /path/to/backups --force
|
|
python3 google_drive_sync.py clear-tracker
|
|
python3 google_drive_sync.py show-stats
|
|
"""
|
|
)
|
|
|
|
parser.add_argument("command",
|
|
choices=['test', 'sync', 'sync-test', 'clear-tracker', 'show-stats'],
|
|
help="Command to execute")
|
|
parser.add_argument("path", nargs='?', help="Backup directory path (required for sync command)")
|
|
parser.add_argument("--project", type=str, default="AIPass", help="Project name for sync")
|
|
parser.add_argument("--note", type=str, default="Manual sync", help="Note for sync operation")
|
|
parser.add_argument("--force", action="store_true", help="Force sync all files (ignore tracker)")
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Check if module is enabled
|
|
config = _load_config()
|
|
if not config.get("config", {}).get("enabled", True):
|
|
warning("Google Drive sync is disabled")
|
|
sys.exit(0)
|
|
|
|
if args.command == 'clear-tracker':
|
|
if _clear_file_tracker():
|
|
console.print("File tracker cleared successfully")
|
|
sys.exit(0)
|
|
else:
|
|
error("Failed to clear file tracker")
|
|
sys.exit(1)
|
|
|
|
elif args.command == 'show-stats':
|
|
if _show_file_tracker_stats():
|
|
sys.exit(0)
|
|
else:
|
|
sys.exit(1)
|
|
|
|
elif args.command == 'sync':
|
|
if not args.path:
|
|
error("sync command requires a path argument")
|
|
console.print("Usage: python3 google_drive_sync.py sync /path/to/backups")
|
|
sys.exit(1)
|
|
|
|
backup_path = Path(args.path)
|
|
|
|
if not backup_path.exists():
|
|
error(f"Backup directory not found: {backup_path}")
|
|
sys.exit(1)
|
|
|
|
if GoogleDriveSync is None:
|
|
error("Drive sync dependencies not available")
|
|
sys.exit(1)
|
|
sync = GoogleDriveSync()
|
|
if not sync.authenticate():
|
|
error("Failed to authenticate with Google Drive")
|
|
sys.exit(1)
|
|
|
|
# Phase 1: Scan
|
|
console.print(f"Scanning {backup_path}...")
|
|
files_to_upload, skipped, total = sync.prepare_sync(backup_path, args.force)
|
|
upload_count = len(files_to_upload)
|
|
|
|
console.print(f" Files scanned: {total}")
|
|
console.print(f" To upload: {upload_count} ({'forced' if args.force else 'changed/new'})")
|
|
console.print(f" Unchanged: {skipped}")
|
|
|
|
if upload_count == 0:
|
|
console.print("[green]All files up to date - nothing to sync[/green]")
|
|
sys.exit(0)
|
|
|
|
console.print(f"\nSyncing to Google Drive (project: {args.project})...")
|
|
|
|
def cli_progress(completed, total_upload, successes):
|
|
"""Display CLI upload progress."""
|
|
console.print(f" Progress: {completed}/{total_upload} ({successes} OK)")
|
|
|
|
# Phase 2: Upload
|
|
result = sync.sync_backup_files(
|
|
backup_path, args.project, args.note, args.force,
|
|
prepared_files=files_to_upload,
|
|
skipped_count=skipped,
|
|
total_count=total,
|
|
progress_fn=cli_progress
|
|
)
|
|
|
|
console.print()
|
|
if result["success"]:
|
|
console.print(f"[green]Sync complete: {result['uploaded']} uploaded, {result['skipped']} unchanged[/green]")
|
|
sys.exit(0)
|
|
else:
|
|
warning(f"Sync finished: {result['uploaded']} uploaded, {result['failed']} failed, {result['skipped']} unchanged")
|
|
sys.exit(1)
|
|
|
|
elif args.command == 'sync-test':
|
|
# Create a minimal args namespace for the test function
|
|
if _run_sync_test():
|
|
sys.exit(0)
|
|
else:
|
|
sys.exit(1)
|
|
|
|
elif args.command == 'test':
|
|
if _test_drive_sync():
|
|
console.print("Google Drive sync test successful")
|
|
sys.exit(0)
|
|
else:
|
|
console.print("Google Drive sync test failed")
|
|
sys.exit(1)
|