4f4e017ad2
- 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
711 lines
28 KiB
Python
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': []
|
|
}
|