tars-conversation-app / scripts /update_daemon.py
latishab's picture
Update TARS Conversation App with TarsApp framework
e8ed0e1 verified
#!/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()