# -*- coding: utf-8 -*- """ Jambo API — Production Deployment Runner Executes remote zero-downtime deployment protocol via SSH. Supports native OpenSSH client or Paramiko fallback. Usage: python3 deploy.py [vX.Y.Z] """ import os import sys import io import subprocess sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') try: from paramiko import SSHClient, AutoAddPolicy HAS_PARAMIKO = True except ImportError: HAS_PARAMIKO = False HOST = os.getenv('JAMBO_HOST', 'folina.o2switch.net') USER = os.getenv('JAMBO_USER', 'gupi7723') PASS = os.getenv('JAMBO_PASS') KEY_FILE = os.getenv('JAMBO_SSH_KEY', '/root/.ssh/id_ed25519' if os.path.exists('/root/.ssh/id_ed25519') else None) REMOTE = os.getenv('JAMBO_REMOTE_DIR', '/home/gupi7723/api.jambostack.site') def run_command_subprocess(cmd): print(f"\n>> Executing: {cmd}") if PASS and not (KEY_FILE and os.path.exists(KEY_FILE or '')): ssh_cmd = [ "sshpass", "-p", PASS, "ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=15", ] else: ssh_cmd = [ "ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15", ] if KEY_FILE and os.path.exists(KEY_FILE): ssh_cmd.extend(["-i", KEY_FILE]) ssh_cmd.append(f"{USER}@{HOST}") ssh_cmd.append(f"cd {REMOTE} && {cmd}") proc = subprocess.Popen(ssh_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding='utf-8', errors='replace') for line in proc.stdout: print(f" {line.rstrip()}") proc.wait() if proc.returncode != 0: print(f" FAILED with exit code {proc.returncode}") return proc.returncode def run_command_paramiko(ssh, cmd, timeout=300): print(f"\n>> Executing: {cmd}") _, stdout, stderr = ssh.exec_command(f"cd {REMOTE} && {cmd}", timeout=timeout) for line in iter(stdout.readline, ""): print(f" {line.rstrip()}") exit_code = stdout.channel.recv_exit_status() if exit_code != 0: err = stderr.read().decode('utf-8', errors='replace') print(f" FAILED with exit code {exit_code}") if err.strip(): print(f" STDERR: {err.strip()}") return exit_code return 0 def main(): target_tag = sys.argv[1] if len(sys.argv) > 1 else None print(f"> Deploying to {USER}@{HOST}:{REMOTE}...") ssh = None if HAS_PARAMIKO and PASS: try: ssh = SSHClient() ssh.set_missing_host_key_policy(AutoAddPolicy()) connect_kwargs = { 'hostname': HOST, 'username': USER, 'timeout': 15, } if KEY_FILE and os.path.exists(KEY_FILE): connect_kwargs['key_filename'] = KEY_FILE elif PASS: connect_kwargs['password'] = PASS else: connect_kwargs['allow_agent'] = True connect_kwargs['look_for_keys'] = True ssh.connect(**connect_kwargs) except Exception as e: print(f"Paramiko connection failed ({e}), falling back to OpenSSH subprocess...") ssh = None def execute_step(cmd): if ssh is not None: return run_command_paramiko(ssh, cmd) return run_command_subprocess(cmd) steps = [ ("Git sync", f"git fetch --all --tags --prune --force && git checkout {target_tag}" if target_tag else "git pull origin main"), ("Composer dependencies", "php -d memory_limit=1G $(command -v composer || echo /usr/local/bin/composer) install --no-dev --prefer-dist --optimize-autoloader --no-interaction --no-scripts"), ("Frontend build", "if command -v npm >/dev/null 2>&1; then npm ci --prefer-offline && npm run build; else echo 'Notice: npm not found in server environment, skipping frontend build.'; fi"), ("Doctrine migrations", "php -d memory_limit=1G bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing"), ("Cache warmup", "php -d memory_limit=1G bin/console cache:clear --no-warmup --env=prod && sleep 2 && php -d memory_limit=1G bin/console cache:warmup --env=prod"), ("File permissions hardening", "chmod 600 .env .env.local .env.backup_* 2>/dev/null || true; chmod 700 var var/cache var/log 2>/dev/null || true; chmod 644 public/uploads/.htaccess 2>/dev/null || true"), ] def sync_frontend_assets(): local_build = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'public', 'build') if not os.path.isdir(local_build): return 0 print("\n--- [Step: Frontend asset synchronization (rsync)] ---") ssh_opt = "ssh -o BatchMode=yes -o ConnectTimeout=15" if KEY_FILE and os.path.exists(KEY_FILE): ssh_opt += f" -i {KEY_FILE}" rsync_cmd = [ "rsync", "-avz", "--delete", "-e", ssh_opt, f"{local_build}/", f"{USER}@{HOST}:{REMOTE}/public/build/", ] proc = subprocess.run(rsync_cmd, capture_output=True, text=True) if proc.returncode == 0: print(" Frontend assets synchronized successfully.") else: print(f" Notice during asset rsync: {proc.stderr.strip() or proc.stdout.strip()}") return 0 for label, cmd in steps: print(f"\n--- [Step: {label}] ---") rc = execute_step(cmd) if rc != 0: print(f"\nABORTING: Step '{label}' failed.") if ssh: ssh.close() sys.exit(1) if label == "Frontend build": sync_frontend_assets() print("\n========================================================") print(" DEPLOYMENT SUCCEEDED!") print(" Live URL: https://api.jambostack.site") print("========================================================") if ssh: ssh.close() if __name__ == '__main__': main()