Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """ | |
| TARS Daemon Remote Update Script | |
| Updates the TARS daemon on the Raspberry Pi via SSH. | |
| Supports git-based updates, backup, health checks, and rollback. | |
| Usage: | |
| python scripts/update_daemon.py --check-only | |
| python scripts/update_daemon.py --method git | |
| python scripts/update_daemon.py --method git --version v0.2.1 | |
| python scripts/update_daemon.py --rollback /path/to/backup | |
| """ | |
| import argparse | |
| import subprocess | |
| import sys | |
| import json | |
| from datetime import datetime | |
| from pathlib import Path | |
| # SSH configuration | |
| PI_HOST = "tars-pi" | |
| PI_USER = "mac" | |
| DAEMON_DIR = "~/tars-daemon" | |
| BACKUP_DIR = "~/tars-daemon-backups" | |
| SERVICE_NAME = "tars" | |
| def run_ssh(cmd: str, check: bool = True) -> tuple[int, str, str]: | |
| """Run command on Pi via SSH.""" | |
| ssh_cmd = f'ssh {PI_HOST} "{cmd}"' | |
| result = subprocess.run( | |
| ssh_cmd, | |
| shell=True, | |
| capture_output=True, | |
| text=True | |
| ) | |
| if check and result.returncode != 0: | |
| print(f"Error: {result.stderr}") | |
| return result.returncode, result.stdout.strip(), result.stderr.strip() | |
| def get_current_version() -> dict: | |
| """Get current daemon version info.""" | |
| code, out, err = run_ssh( | |
| f"cd {DAEMON_DIR} && source venv/bin/activate && " | |
| "python -c 'from tars_sdk import __version__; import json; " | |
| "print(json.dumps({\"version\": __version__}))'", | |
| check=False | |
| ) | |
| if code == 0: | |
| try: | |
| return json.loads(out) | |
| except json.JSONDecodeError: | |
| pass | |
| # Fallback: try git | |
| code, out, _ = run_ssh(f"cd {DAEMON_DIR} && git describe --tags --always", check=False) | |
| return {"version": out if code == 0 else "unknown", "git": True} | |
| def get_git_status() -> dict: | |
| """Get git status on Pi.""" | |
| info = {} | |
| code, out, _ = run_ssh(f"cd {DAEMON_DIR} && git rev-parse --short HEAD", check=False) | |
| info["commit"] = out if code == 0 else "unknown" | |
| code, out, _ = run_ssh(f"cd {DAEMON_DIR} && git branch --show-current", check=False) | |
| info["branch"] = out if code == 0 else "main" | |
| code, out, _ = run_ssh(f"cd {DAEMON_DIR} && git status --porcelain", check=False) | |
| info["dirty"] = bool(out) if code == 0 else False | |
| code, out, _ = run_ssh(f"cd {DAEMON_DIR} && git describe --tags --always", check=False) | |
| info["tag"] = out if code == 0 else "" | |
| return info | |
| def check_daemon_health() -> bool: | |
| """Check if daemon is running and healthy.""" | |
| code, out, _ = run_ssh(f"systemctl is-active {SERVICE_NAME}", check=False) | |
| if code == 0 and out == "active": | |
| return True | |
| # Try curl health endpoint | |
| code, out, _ = run_ssh("curl -s http://localhost:8001/api/health", check=False) | |
| if code == 0 and "running" in out.lower(): | |
| return True | |
| return False | |
| def stop_daemon() -> bool: | |
| """Stop the daemon service.""" | |
| print("Stopping daemon...") | |
| code, _, _ = run_ssh(f"sudo systemctl stop {SERVICE_NAME}", check=False) | |
| if code != 0: | |
| code, _, _ = run_ssh("pkill -f tars_daemon.py", check=False) | |
| return True | |
| def start_daemon() -> bool: | |
| """Start the daemon service.""" | |
| print("Starting daemon...") | |
| code, _, err = run_ssh(f"sudo systemctl start {SERVICE_NAME}", check=False) | |
| if code != 0: | |
| print(f"Warning: systemctl start failed: {err}") | |
| # Try direct start | |
| code, _, _ = run_ssh( | |
| f"cd {DAEMON_DIR} && source venv/bin/activate && " | |
| "nohup python tars_daemon.py > /dev/null 2>&1 &", | |
| check=False | |
| ) | |
| return code == 0 | |
| def create_backup() -> str: | |
| """Create backup of current installation.""" | |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| backup_path = f"{BACKUP_DIR}/tars-daemon-{timestamp}" | |
| print(f"Creating backup at {backup_path}...") | |
| # Create backup directory | |
| run_ssh(f"mkdir -p {BACKUP_DIR}") | |
| # Copy current installation | |
| code, _, err = run_ssh(f"cp -r {DAEMON_DIR} {backup_path}") | |
| if code != 0: | |
| print(f"Error creating backup: {err}") | |
| return "" | |
| # Remove venv from backup to save space | |
| run_ssh(f"rm -rf {backup_path}/venv", check=False) | |
| print(f"Backup created: {backup_path}") | |
| return backup_path | |
| def restore_backup(backup_path: str) -> bool: | |
| """Restore from backup.""" | |
| print(f"Restoring from {backup_path}...") | |
| # Verify backup exists | |
| code, _, _ = run_ssh(f"test -d {backup_path}", check=False) | |
| if code != 0: | |
| print(f"Error: Backup not found at {backup_path}") | |
| return False | |
| stop_daemon() | |
| # Move current to temp | |
| run_ssh(f"mv {DAEMON_DIR} {DAEMON_DIR}.old", check=False) | |
| # Restore backup | |
| code, _, err = run_ssh(f"cp -r {backup_path} {DAEMON_DIR}") | |
| if code != 0: | |
| print(f"Error restoring backup: {err}") | |
| # Try to restore old | |
| run_ssh(f"mv {DAEMON_DIR}.old {DAEMON_DIR}", check=False) | |
| return False | |
| # Recreate venv | |
| print("Recreating virtual environment...") | |
| run_ssh( | |
| f"cd {DAEMON_DIR} && python3 -m venv venv && " | |
| "source venv/bin/activate && pip install -e .", | |
| check=False | |
| ) | |
| # Cleanup | |
| run_ssh(f"rm -rf {DAEMON_DIR}.old", check=False) | |
| start_daemon() | |
| return True | |
| def update_git(version: str = None) -> bool: | |
| """Update daemon using git.""" | |
| git_info = get_git_status() | |
| print(f"Current: {git_info['commit']} on {git_info['branch']}") | |
| if git_info["dirty"]: | |
| print("Warning: Working directory has uncommitted changes") | |
| # Create backup | |
| backup_path = create_backup() | |
| if not backup_path: | |
| print("Error: Failed to create backup") | |
| return False | |
| stop_daemon() | |
| # Fetch latest | |
| print("Fetching updates...") | |
| code, _, err = run_ssh(f"cd {DAEMON_DIR} && git fetch --all --tags") | |
| if code != 0: | |
| print(f"Error fetching: {err}") | |
| return False | |
| # Checkout version or pull latest | |
| if version: | |
| print(f"Checking out {version}...") | |
| code, _, err = run_ssh(f"cd {DAEMON_DIR} && git checkout {version}") | |
| else: | |
| print("Pulling latest...") | |
| code, _, err = run_ssh(f"cd {DAEMON_DIR} && git pull --ff-only") | |
| if code != 0: | |
| print(f"Error: {err}") | |
| print("Rolling back...") | |
| restore_backup(backup_path) | |
| return False | |
| # Update dependencies | |
| print("Updating dependencies...") | |
| code, _, err = run_ssh( | |
| f"cd {DAEMON_DIR} && source venv/bin/activate && pip install -e ." | |
| ) | |
| if code != 0: | |
| print(f"Error installing: {err}") | |
| print("Rolling back...") | |
| restore_backup(backup_path) | |
| return False | |
| # Regenerate proto files if needed | |
| print("Regenerating proto files...") | |
| run_ssh( | |
| f"cd {DAEMON_DIR} && source venv/bin/activate && " | |
| "python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. " | |
| "--pyi_out=. tars_sdk/proto/tars.proto", | |
| check=False | |
| ) | |
| # Start daemon | |
| start_daemon() | |
| # Health check | |
| import time | |
| print("Waiting for daemon to start...") | |
| time.sleep(3) | |
| if check_daemon_health(): | |
| print("Daemon is healthy") | |
| new_info = get_git_status() | |
| print(f"Updated to: {new_info['commit']}") | |
| return True | |
| else: | |
| print("Error: Daemon health check failed") | |
| print("Rolling back...") | |
| restore_backup(backup_path) | |
| return False | |
| def list_backups(): | |
| """List available backups.""" | |
| code, out, _ = run_ssh(f"ls -la {BACKUP_DIR}", check=False) | |
| if code == 0: | |
| print("Available backups:") | |
| print(out) | |
| else: | |
| print("No backups found") | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description="Update TARS daemon on Raspberry Pi", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=""" | |
| Examples: | |
| %(prog)s --check-only Show current version | |
| %(prog)s --method git Update via git pull | |
| %(prog)s --version v0.2.1 Checkout specific version | |
| %(prog)s --rollback ~/backup Restore from backup | |
| %(prog)s --list-backups List available backups | |
| """ | |
| ) | |
| parser.add_argument( | |
| "--check-only", | |
| action="store_true", | |
| help="Show current version and status only" | |
| ) | |
| parser.add_argument( | |
| "--method", | |
| choices=["git"], | |
| default="git", | |
| help="Update method (default: git)" | |
| ) | |
| parser.add_argument( | |
| "--version", | |
| help="Specific version/tag to checkout (e.g., v0.2.1)" | |
| ) | |
| parser.add_argument( | |
| "--rollback", | |
| metavar="PATH", | |
| help="Restore from backup path" | |
| ) | |
| parser.add_argument( | |
| "--list-backups", | |
| action="store_true", | |
| help="List available backups" | |
| ) | |
| parser.add_argument( | |
| "--force", | |
| action="store_true", | |
| help="Skip confirmation prompts" | |
| ) | |
| args = parser.parse_args() | |
| print("=" * 60) | |
| print("TARS Daemon Update Tool") | |
| print("=" * 60) | |
| # Test SSH connection | |
| code, _, _ = run_ssh("echo ok", check=False) | |
| if code != 0: | |
| print(f"Error: Cannot connect to {PI_HOST}") | |
| print("Check SSH configuration and try again.") | |
| sys.exit(1) | |
| print(f"Connected to {PI_HOST}") | |
| print() | |
| # Get current status | |
| version_info = get_current_version() | |
| git_info = get_git_status() | |
| healthy = check_daemon_health() | |
| print(f"Current version: {version_info.get('version', 'unknown')}") | |
| print(f"Git commit: {git_info['commit']} ({git_info['branch']})") | |
| print(f"Daemon status: {'healthy' if healthy else 'not running'}") | |
| print() | |
| if args.list_backups: | |
| list_backups() | |
| sys.exit(0) | |
| if args.check_only: | |
| sys.exit(0) | |
| if args.rollback: | |
| if not args.force: | |
| confirm = input(f"Restore from {args.rollback}? [y/N] ") | |
| if confirm.lower() != "y": | |
| print("Cancelled") | |
| sys.exit(0) | |
| success = restore_backup(args.rollback) | |
| sys.exit(0 if success else 1) | |
| # Update | |
| if not args.force: | |
| msg = f"Update to {args.version}" if args.version else "Update to latest" | |
| confirm = input(f"{msg}? [y/N] ") | |
| if confirm.lower() != "y": | |
| print("Cancelled") | |
| sys.exit(0) | |
| if args.method == "git": | |
| success = update_git(args.version) | |
| else: | |
| print(f"Unknown method: {args.method}") | |
| sys.exit(1) | |
| if success: | |
| print() | |
| print("=" * 60) | |
| print("Update completed successfully") | |
| print("=" * 60) | |
| # Show new version | |
| new_version = get_current_version() | |
| print(f"New version: {new_version.get('version', 'unknown')}") | |
| else: | |
| print() | |
| print("=" * 60) | |
| print("Update failed - system has been rolled back") | |
| print("=" * 60) | |
| sys.exit(1) | |
| if __name__ == "__main__": | |
| main() | |