Files
enterprise_digital-platform/digiserver-v2/app/utils/ssh_deploy.py
T
ske087 4f4e017ad2 fix: player connectivity and media download pipeline
- nginx: add /api/ shortcut block (no portal auth, X-Script-Name /digiserver,
  Host $http_host) so players can reach DigiServer API without /digiserver prefix
- nginx: use $http_host in /api/ block so Flask host_url includes port — fixes
  media download URLs missing :8080 (was http://ip/digiserver/... not http://ip:8080/...)
- player main.py: fix double-port bug when server_ip already contains a port
  (e.g. 192.168.0.230:8080 was producing http://192.168.0.230:8080:80)
- get_playlists_v2.py: force re-sync when server version differs OR local media
  files are missing on disk — fixes stale playlist after server reset
- digiserver api.py: playlist endpoint builds full media URLs using script_root
  from X-Script-Name header set by nginx
- weblink support, player build/deploy improvements, manage-playlist AJAX prefix fix
2026-06-29 20:04:11 +03:00

711 lines
28 KiB
Python

"""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
try:
# First check if install script exists
check_script = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
f'[ -f {deploy_path}/install.sh ] || [ -f {deploy_path}/setup.sh ] || [ -f {deploy_path}/install_player.sh ]'
]
script_check = subprocess.run(check_script, capture_output=True, text=True, timeout=10)
if script_check.returncode == 0:
# Find the install script
find_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
f'ls {deploy_path}/install*.sh {deploy_path}/setup.sh {deploy_path}/*.sh 2>/dev/null | head -1'
]
find_result = subprocess.run(find_cmd, capture_output=True, text=True, timeout=10)
install_script = find_result.stdout.strip().split('\n')[0]
if install_script:
# Run the install script (passing KIWY_* so its
# configure_player() section writes config/app_config.json too)
install_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
f'cd {deploy_path} && {install_env_prefix}bash {install_script} 2>&1'
]
result = subprocess.run(install_cmd, capture_output=True, text=True, timeout=300)
steps.append({
'step': 'Run Installation Script',
'status': 'completed' if result.returncode == 0 else 'completed_with_warnings',
'message': f'Installation script executed: {install_script}',
'timestamp': datetime.now().isoformat()
})
else:
steps.append({
'step': 'Run Installation Script',
'status': 'skipped',
'message': 'No installation script found (manual setup may be required)',
'timestamp': datetime.now().isoformat()
})
else:
steps.append({
'step': 'Run Installation Script',
'status': 'skipped',
'message': 'No installation script found',
'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)}')
# 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': []
}