"""SSH deployment utilities for player provisioning.""" import subprocess import logging import os import json from typing import Tuple, Dict, Any, Optional from datetime import datetime logger = logging.getLogger(__name__) # Pre-staged player code location in container LOCAL_PLAYER_CODE_DIR = '/app/data/player' def get_local_player_code_status(player_code_dir: Optional[str] = None) -> Dict[str, Any]: """ Check status of pre-staged player code. Args: player_code_dir: Optional override for the staged code path. Defaults to ``LOCAL_PLAYER_CODE_DIR`` (the container location). Returns: Dict with availability, version, and path info """ code_dir = player_code_dir or LOCAL_PLAYER_CODE_DIR try: if not os.path.isdir(code_dir): return { 'available': False, 'reason': 'Directory not found', 'path': code_dir } # Check if git repository git_dir = os.path.join(code_dir, '.git') if not os.path.isdir(git_dir): return { 'available': False, 'reason': 'Not a git repository', 'path': code_dir } # Get current git version try: result = subprocess.run( ['git', '-C', code_dir, 'rev-parse', '--short', 'HEAD'], capture_output=True, text=True, timeout=5 ) version = result.stdout.strip() if result.returncode == 0 else 'unknown' except: version = 'unknown' # Get directory size try: result = subprocess.run( ['du', '-sh', code_dir], capture_output=True, text=True, timeout=5 ) size = result.stdout.split()[0] if result.returncode == 0 else 'unknown' except: size = 'unknown' return { 'available': True, 'path': code_dir, 'version': version, 'size': size, 'updated': os.path.getmtime(git_dir), 'reason': 'Pre-staged code ready for deployment' } except Exception as e: logger.warning(f'Error checking player code status: {str(e)}') return { 'available': False, 'reason': f'Status check failed: {str(e)}', 'path': code_dir } def test_ssh_connection(hostname: str, username: str, password: str, port: int = 22) -> Dict[str, Any]: """ Test SSH connection to a remote host. Args: hostname: Target hostname or IP username: SSH username password: SSH password port: SSH port (default 22) Returns: Dict with status, message, and timestamp """ try: # Use sshpass to test connection without interactive prompt cmd = [ 'sshpass', '-p', password, 'ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'ConnectTimeout=10', '-p', str(port), f'{username}@{hostname}', 'echo "SSH connection successful"' ] result = subprocess.run( cmd, capture_output=True, text=True, timeout=15 ) if result.returncode == 0: return { 'success': True, 'message': f'SSH connection successful to {hostname}', 'timestamp': datetime.now().isoformat(), 'output': result.stdout.strip() } else: error_msg = result.stderr.strip() or result.stdout.strip() return { 'success': False, 'message': f'SSH connection failed: {error_msg}', 'timestamp': datetime.now().isoformat(), 'error': error_msg } except subprocess.TimeoutExpired: return { 'success': False, 'message': f'SSH connection timeout to {hostname}', 'timestamp': datetime.now().isoformat(), 'error': 'Connection timeout (10s)' } except Exception as e: logger.error(f'SSH test error: {str(e)}') return { 'success': False, 'message': f'SSH connection error: {str(e)}', 'timestamp': datetime.now().isoformat(), 'error': str(e) } def generate_player_config( player_name: str, server_url: str, api_key: str, player_id: str = None, location: str = None ) -> str: """ Generate player configuration JSON for connecting to DigiServer. Args: player_name: Name of the player server_url: DigiServer base URL (e.g., http://localhost/digiserver) api_key: API authentication key player_id: Optional player ID (defaults to player_name) location: Optional player location/description Returns: JSON configuration string """ config = { "player": { "name": player_name, "id": player_id or player_name, "location": location or "", "version": "2.0" }, "server": { "url": server_url, "api_endpoint": f"{server_url}/api", "authentication": { "type": "api_key", "key": api_key }, "endpoints": { "playlists": f"{server_url}/api/playlists", "content": f"{server_url}/api/content", "schedule": f"{server_url}/api/schedule", "heartbeat": f"{server_url}/api/player/heartbeat", "logs": f"{server_url}/api/player/logs" } }, "playback": { "audio_enabled": True, "video_enabled": True, "max_resolution": "4K", "refresh_interval": 60, "rotation": "0" }, "networking": { "timeout": 30, "retry_count": 3, "retry_delay": 5 } } return json.dumps(config, indent=2) def detect_server_ip() -> Optional[str]: """Best-effort detection of this server's primary LAN IP address. Opens a UDP socket toward a public address (no packets are actually sent) and reads the local socket address, which resolves to the IP of the interface used for outbound traffic. Returns None on failure. """ import socket s = None try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(('8.8.8.8', 80)) ip = s.getsockname()[0] if ip and not ip.startswith('127.'): return ip except Exception: pass finally: if s is not None: try: s.close() except Exception: pass # Fallback via hostname resolution. try: ip = socket.gethostbyname(socket.gethostname()) if ip and not ip.startswith('127.'): return ip except Exception: pass return None def parse_server_address(server_url: str) -> Dict[str, Any]: """Derive the values the player needs from a DigiServer URL. The player's config/app_config.json stores server_ip + port + use_https and builds requests as ``{scheme}://{server_ip}:{port}/api/...`` (it does NOT use any URL path prefix such as ``/digiserver``). This helper extracts the host, port and scheme from a server URL and drops any path component. Args: server_url: e.g. ``https://signage.example.com/digiserver`` or ``http://192.168.0.50:8080`` Returns: Dict with ``server_ip`` (str), ``port`` (str) and ``use_https`` (bool). """ from urllib.parse import urlparse parsed = urlparse(server_url if '://' in (server_url or '') else f'//{server_url}') use_https = (parsed.scheme or 'https') == 'https' host = parsed.hostname or '' port = parsed.port if port is None: port = 443 if use_https else 80 return {'server_ip': host, 'port': str(port), 'use_https': use_https} def generate_app_config( server_ip: str, port: str, screen_name: str, quickconnect_code: str, orientation: str = 'Landscape', use_https: bool = True, verify_ssl: bool = False, max_resolution: str = '1920x1080', ) -> str: """Generate the config/app_config.json the player actually reads. This is what binds a deployed player to the real server and its assigned playlist: the player authenticates with ``screen_name`` + ``quickconnect_code`` and the server returns the playlist assigned to that player. Args: server_ip: Server IP or domain the player should contact. port: Server port as a string. screen_name: Player hostname / screen identity (matches Player.hostname). quickconnect_code: Quick connect code (matches Player.quickconnect_code). orientation: Landscape or Portrait. use_https: Whether the player should use HTTPS. verify_ssl: Whether the player should verify the TLS certificate. max_resolution: Maximum playback resolution. Returns: JSON configuration string. """ config = { 'server_ip': server_ip, 'port': str(port), 'screen_name': screen_name, 'quickconnect_key': quickconnect_code, 'orientation': orientation or 'Landscape', 'touch': 'True', 'max_resolution': max_resolution, 'edit_feature_enabled': True, 'use_https': bool(use_https), 'verify_ssl': bool(verify_ssl), } return json.dumps(config, indent=2) def deploy_player_to_host( hostname: str, username: str, password: str, player_name: str, repo_url: str = 'https://gitea.moto-adv.com/ske087/Kiwy-Signage.git', deploy_path: str = None, # Default: /home/[user]/kiwy-signage port: int = 22, server_url: str = None, # DigiServer URL for player to connect to server_api_key: str = None, # API key for player authentication player_hostname: str = None, # Player screen identity (Player.hostname) quickconnect_code: str = None, # Player quick connect code orientation: str = 'Landscape', # Player orientation verify_ssl: bool = False, # Whether the player should verify TLS ) -> Dict[str, Any]: """ Deploy player code to remote host. Args: hostname: Target hostname or IP username: SSH username password: SSH password player_name: Name for the player instance repo_url: Git repository URL deploy_path: Path where to deploy on remote host (default: /home/[user]/kiwy-signage) port: SSH port (default 22) server_url: DigiServer URL for player connection server_api_key: API key for player authentication player_hostname: Player screen identity used for auth (Player.hostname) quickconnect_code: Quick connect code used for auth (Player.quickconnect_code) orientation: Player orientation (Landscape/Portrait) verify_ssl: Whether the player should verify the server TLS certificate Returns: Dict with deployment status and output """ # Set default deployment path to user's home directory if deploy_path is None: deploy_path = f'/home/{username}/kiwy-signage' try: # Step 1: Verify host accessibility test_result = test_ssh_connection(hostname, username, password, port) if not test_result['success']: return { 'success': False, 'message': 'Cannot deploy: SSH connection failed', 'timestamp': datetime.now().isoformat(), 'error': test_result['message'], 'steps': [] } steps = [ { 'step': 'SSH Connection Test', 'status': 'completed', 'message': 'SSH connection successful', 'timestamp': datetime.now().isoformat() } ] # Step 2: Create deployment directory try: mkdir_cmd = [ 'sshpass', '-p', password, 'ssh', '-o', 'StrictHostKeyChecking=no', '-p', str(port), f'{username}@{hostname}', f'mkdir -p {deploy_path}' ] result = subprocess.run(mkdir_cmd, capture_output=True, text=True, timeout=30) steps.append({ 'step': 'Create Deploy Directory', 'status': 'completed' if result.returncode == 0 else 'failed', 'message': f'Directory {deploy_path} created', 'timestamp': datetime.now().isoformat() }) except Exception as e: steps.append({ 'step': 'Create Deploy Directory', 'status': 'failed', 'message': f'Failed: {str(e)}', 'timestamp': datetime.now().isoformat() }) return { 'success': False, 'message': f'Deployment failed at step: Create Deploy Directory', 'timestamp': datetime.now().isoformat(), 'error': str(e), 'steps': steps } # Step 3: Deploy code (use local if available, otherwise clone from git) try: code_status = get_local_player_code_status() if code_status['available']: # Use pre-staged player code via rsync logger.info(f'Using pre-staged player code (version: {code_status.get("version", "unknown")})') rsync_cmd = [ 'sshpass', '-p', password, 'rsync', '-avz', '--delete', '-e', f'ssh -o StrictHostKeyChecking=no -p {port}', f'{LOCAL_PLAYER_CODE_DIR}/', f'{username}@{hostname}:{deploy_path}/' ] result = subprocess.run(rsync_cmd, capture_output=True, text=True, timeout=300) steps.append({ 'step': 'Deploy Code', 'status': 'completed' if result.returncode == 0 else 'failed', 'message': f'Code deployed via rsync (version: {code_status.get("version", "local")})', 'timestamp': datetime.now().isoformat() }) if result.returncode != 0: logger.warning(f'Rsync failed, falling back to git clone: {result.stderr}') # Fall back to git clone raise Exception('Rsync failed, retrying with git') else: # No local code, clone from repository logger.info(f'No pre-staged code available ({code_status.get("reason", "unknown")}), cloning from repository') raise Exception('Local code not available') except Exception as rsync_error: # Fallback: Clone or pull repository try: logger.info(f'Deploying via git: {rsync_error}') # Check if repo already exists check_cmd = [ 'sshpass', '-p', password, 'ssh', '-o', 'StrictHostKeyChecking=no', '-p', str(port), f'{username}@{hostname}', f'[ -d {deploy_path}/.git ]' ] result = subprocess.run(check_cmd, capture_output=True, text=True, timeout=10) if result.returncode == 0: # Repo exists, pull latest git_cmd = [ 'sshpass', '-p', password, 'ssh', '-o', 'StrictHostKeyChecking=no', '-p', str(port), f'{username}@{hostname}', f'cd {deploy_path} && git pull origin main 2>&1' ] git_msg = 'Pull latest code' else: # Clone repository git_cmd = [ 'sshpass', '-p', password, 'ssh', '-o', 'StrictHostKeyChecking=no', '-p', str(port), f'{username}@{hostname}', f'git clone {repo_url} {deploy_path} 2>&1' ] git_msg = 'Clone repository' result = subprocess.run(git_cmd, capture_output=True, text=True, timeout=120) steps.append({ 'step': 'Deploy Code', 'status': 'completed' if result.returncode == 0 else 'failed', 'message': f'{git_msg}: {result.stdout.split(chr(10))[0][:100]}', 'timestamp': datetime.now().isoformat() }) if result.returncode != 0: return { 'success': False, 'message': f'Deployment failed at step: Deploy Code', 'timestamp': datetime.now().isoformat(), 'error': result.stderr or result.stdout, 'steps': steps } except Exception as e: steps.append({ 'step': 'Deploy Code', 'status': 'failed', 'message': f'Failed: {str(e)}', 'timestamp': datetime.now().isoformat() }) return { 'success': False, 'message': f'Deployment failed at step: Deploy Code', 'timestamp': datetime.now().isoformat(), 'error': str(e), 'steps': steps } # Step 3.5: Generate player configuration (config/app_config.json) # This is the file the player actually reads to learn the server address # and its screen identity. Authenticating with that identity is what binds # the player to its assigned playlist on the real server. install_env_prefix = '' try: screen_name = player_hostname or player_name if server_url and screen_name and quickconnect_code: addr = parse_server_address(server_url) app_config_content = generate_app_config( server_ip=addr['server_ip'], port=addr['port'], screen_name=screen_name, quickconnect_code=quickconnect_code, orientation=orientation or 'Landscape', use_https=addr['use_https'], verify_ssl=verify_ssl, ) # Build an env prefix so install.sh's configure_player() also runs # (single, consistent configuration path on the player side). import shlex env_pairs = { 'KIWY_SERVER_IP': addr['server_ip'], 'KIWY_PORT': addr['port'], 'KIWY_SCREEN_NAME': screen_name, 'KIWY_QUICKCONNECT': quickconnect_code, 'KIWY_ORIENTATION': orientation or 'Landscape', 'KIWY_USE_HTTPS': 'true' if addr['use_https'] else 'false', 'KIWY_VERIFY_SSL': 'true' if verify_ssl else 'false', } install_env_prefix = ' '.join( f'{k}={shlex.quote(str(v))}' for k, v in env_pairs.items() ) + ' ' # Write config/app_config.json directly (robust even if install.sh # is missing or fails), and clear any stale baked-in auth. remote_cmd = ( f'mkdir -p {deploy_path}/config && ' f"cat > {deploy_path}/config/app_config.json << 'EOF'\n" f'{app_config_content}\n' f'EOF\n' f'rm -f {deploy_path}/player_auth.json {deploy_path}/src/player_auth.json 2>/dev/null || true' ) write_config_cmd = [ 'sshpass', '-p', password, 'ssh', '-o', 'StrictHostKeyChecking=no', '-p', str(port), f'{username}@{hostname}', remote_cmd ] result = subprocess.run(write_config_cmd, capture_output=True, text=True, timeout=30) steps.append({ 'step': 'Configure Player', 'status': 'completed' if result.returncode == 0 else 'warning', 'message': ( f'Wrote config/app_config.json ' f'(server {addr["server_ip"]}:{addr["port"]}, screen {screen_name})' ), 'timestamp': datetime.now().isoformat() }) else: steps.append({ 'step': 'Configure Player', 'status': 'skipped', 'message': 'Missing server_url / player hostname / quickconnect; player not auto-configured', 'timestamp': datetime.now().isoformat() }) except Exception as e: logger.warning(f'Failed to create player config: {str(e)}') steps.append({ 'step': 'Configure Player', 'status': 'warning', 'message': f'Failed to write player config: {str(e)}', 'timestamp': datetime.now().isoformat() }) # Step 4: Run installation script # Before running the install script, grant the SSH user temporary # passwordless sudo so that any 'sudo apt-get / pip install' calls inside # install.sh don't hang waiting for an interactive password prompt. # The sudoers entry is removed automatically after the script finishes. try: sudoers_file = f'/etc/sudoers.d/kiwy_deploy_{username}' setup_sudo_cmd = [ 'sshpass', '-p', password, 'ssh', '-o', 'StrictHostKeyChecking=no', '-p', str(port), f'{username}@{hostname}', ( f'echo {password!r} | sudo -S bash -c ' f'"echo \\"{username} ALL=(ALL) NOPASSWD:ALL\\" ' f'> {sudoers_file} && chmod 440 {sudoers_file}" 2>&1' ) ] sudo_result = subprocess.run(setup_sudo_cmd, capture_output=True, text=True, timeout=15) sudo_configured = sudo_result.returncode == 0 if not sudo_configured: logger.warning(f'Could not configure passwordless sudo: {sudo_result.stderr[:200]}') except Exception as e: sudo_configured = False logger.warning(f'Passwordless sudo setup failed: {str(e)}') try: # Build a shell one-liner: run the first install script found. # Priority: install.sh > setup.sh > install_player.sh > any *.sh except start.sh run_install_cmd = ( f'cd {deploy_path} && ' f'INSTALL_SCRIPT="" && ' f'for s in install.sh setup.sh install_player.sh; do ' f' if [ -f "$s" ]; then INSTALL_SCRIPT="$s"; break; fi; ' f'done && ' f'if [ -z "$INSTALL_SCRIPT" ]; then ' f' INSTALL_SCRIPT=$(ls *.sh 2>/dev/null | grep -v "^start.sh$" | head -1); ' f'fi && ' f'if [ -n "$INSTALL_SCRIPT" ]; then ' f' chmod +x "$INSTALL_SCRIPT" && ' f' echo "Running $INSTALL_SCRIPT" && ' f' {install_env_prefix}bash "$INSTALL_SCRIPT" 2>&1; ' f' echo "Exit code: $?"; ' f'else ' f' echo "No install script found"; ' f'fi' ) install_cmd = [ 'sshpass', '-p', password, 'ssh', '-o', 'StrictHostKeyChecking=no', '-p', str(port), f'{username}@{hostname}', run_install_cmd ] result = subprocess.run(install_cmd, capture_output=True, text=True, timeout=600) output = (result.stdout or '').strip() if 'No install script found' in output: steps.append({ 'step': 'Run Installation Script', 'status': 'skipped', 'message': 'No install script found in deploy directory', 'timestamp': datetime.now().isoformat() }) else: script_line = next((l for l in output.splitlines() if l.startswith('Running ')), '') script_name = script_line.replace('Running ', '').strip() or 'install script' steps.append({ 'step': 'Run Installation Script', 'status': 'completed' if result.returncode == 0 else 'completed_with_warnings', 'message': f'Executed {script_name} (exit {result.returncode})', 'timestamp': datetime.now().isoformat() }) if result.returncode != 0: logger.warning(f'Install script exited {result.returncode}: {result.stderr[:200]}') else: logger.info(f'Install script completed successfully on {hostname}') except subprocess.TimeoutExpired: steps.append({ 'step': 'Run Installation Script', 'status': 'completed_with_warnings', 'message': 'Install script timed out after 600s — it may still be running on the device', 'timestamp': datetime.now().isoformat() }) except Exception as e: steps.append({ 'step': 'Run Installation Script', 'status': 'error', 'message': f'Error running installation: {str(e)}', 'timestamp': datetime.now().isoformat() }) logger.error(f'Installation script error: {str(e)}') finally: # Always clean up the temporary sudoers entry if sudo_configured: try: cleanup_cmd = [ 'sshpass', '-p', password, 'ssh', '-o', 'StrictHostKeyChecking=no', '-p', str(port), f'{username}@{hostname}', f'sudo rm -f {sudoers_file} 2>/dev/null || true' ] subprocess.run(cleanup_cmd, capture_output=True, text=True, timeout=10) except Exception: pass # Step 5: Start player service (execute start.sh) try: # Check if start.sh exists check_start = [ 'sshpass', '-p', password, 'ssh', '-o', 'StrictHostKeyChecking=no', '-p', str(port), f'{username}@{hostname}', f'[ -f {deploy_path}/start.sh ]' ] start_check = subprocess.run(check_start, capture_output=True, text=True, timeout=10) if start_check.returncode == 0: # Make sure start.sh is executable and run it start_cmd = [ 'sshpass', '-p', password, 'ssh', '-o', 'StrictHostKeyChecking=no', '-p', str(port), f'{username}@{hostname}', f'cd {deploy_path} && chmod +x start.sh && bash start.sh 2>&1' ] result = subprocess.run(start_cmd, capture_output=True, text=True, timeout=300) # Capture first line of output for feedback output_msg = result.stdout.split('\n')[0][:100] if result.stdout else 'Started' steps.append({ 'step': 'Start Player Service', 'status': 'completed' if result.returncode == 0 else 'completed_with_warnings', 'message': f'Player service started: {output_msg}', 'timestamp': datetime.now().isoformat() }) logger.info(f'Player service started on {hostname} at {deploy_path}') else: steps.append({ 'step': 'Start Player Service', 'status': 'warning', 'message': 'start.sh not found - player may require manual startup', 'timestamp': datetime.now().isoformat() }) logger.warning(f'start.sh not found at {deploy_path}/start.sh on {hostname}') except Exception as e: steps.append({ 'step': 'Start Player Service', 'status': 'error', 'message': f'Error starting player service: {str(e)}', 'timestamp': datetime.now().isoformat() }) logger.error(f'Failed to start player service: {str(e)}') return { 'success': True, 'message': f'Player "{player_name}" deployed successfully to {hostname}', 'timestamp': datetime.now().isoformat(), 'deploy_path': deploy_path, 'steps': steps } except Exception as e: logger.error(f'Deployment error: {str(e)}') return { 'success': False, 'message': f'Unexpected deployment error: {str(e)}', 'timestamp': datetime.now().isoformat(), 'error': str(e), 'steps': [] }