updated player deployment for digiserver
This commit is contained in:
@@ -3,6 +3,7 @@ from flask import Blueprint, request, jsonify, current_app
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
import hashlib
|
||||
import bcrypt
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
@@ -860,6 +861,133 @@ def receive_edited_media():
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# SSH/Deployment Endpoints - For player provisioning and code deployment
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@api_bp.route('/deploy/test-ssh', methods=['POST'])
|
||||
@rate_limit(max_requests=30, window=60)
|
||||
def test_ssh_connection():
|
||||
"""Test SSH connection to a remote host.
|
||||
|
||||
Request JSON:
|
||||
hostname: Target hostname or IP (required)
|
||||
username: SSH username (required)
|
||||
password: SSH password (required)
|
||||
port: SSH port (default: 22)
|
||||
|
||||
Returns:
|
||||
JSON with connection test result
|
||||
"""
|
||||
try:
|
||||
from app.utils.ssh_deploy import test_ssh_connection as test_ssh
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
hostname = data.get('hostname', '').strip()
|
||||
username = data.get('username', '').strip()
|
||||
password = data.get('password', '').strip()
|
||||
port = data.get('port', 22)
|
||||
|
||||
# Validation
|
||||
if not hostname or not username or not password:
|
||||
return jsonify({'error': 'hostname, username, and password are required'}), 400
|
||||
|
||||
# Test connection
|
||||
result = test_ssh(hostname, username, password, port)
|
||||
|
||||
log_action('info', f'SSH test for {username}@{hostname}: {result["message"]}')
|
||||
|
||||
return jsonify(result), 200 if result['success'] else 400
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error testing SSH connection: {str(e)}')
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'message': f'SSH test error: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@api_bp.route('/deploy/player', methods=['POST'])
|
||||
@rate_limit(max_requests=20, window=60)
|
||||
def deploy_player():
|
||||
"""Deploy player code to a remote host via SSH.
|
||||
|
||||
Request JSON:
|
||||
hostname: Target hostname or IP (required)
|
||||
username: SSH username (required)
|
||||
password: SSH password (required)
|
||||
player_name: Name for the player instance (required)
|
||||
port: SSH port (default: 22)
|
||||
deploy_path: Deployment path on remote host (default: /home/[user]/kiwy-signage)
|
||||
repo_url: Git repository URL (default: official Kiwy-Signage repo)
|
||||
|
||||
Returns:
|
||||
JSON with deployment status and step details
|
||||
"""
|
||||
try:
|
||||
from app.utils.ssh_deploy import deploy_player_to_host
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
hostname = data.get('hostname', '').strip()
|
||||
username = data.get('username', '').strip()
|
||||
password = data.get('password', '').strip()
|
||||
player_name = data.get('player_name', '').strip()
|
||||
port = data.get('port', 22)
|
||||
deploy_path = data.get('deploy_path', None) # Will default to /home/[user]/kiwy-signage
|
||||
repo_url = data.get('repo_url', 'https://gitea.moto-adv.com/ske087/Kiwy-Signage.git').strip()
|
||||
|
||||
# Validation
|
||||
if not hostname or not username or not password:
|
||||
return jsonify({'error': 'hostname, username, and password are required'}), 400
|
||||
|
||||
if not player_name:
|
||||
return jsonify({'error': 'player_name is required'}), 400
|
||||
|
||||
# Get server URL for player configuration
|
||||
# Use X-Forwarded-Proto and X-Forwarded-Host for proxy, fall back to request host
|
||||
scheme = request.headers.get('X-Forwarded-Proto', request.scheme)
|
||||
host = request.headers.get('X-Forwarded-Host', request.host)
|
||||
server_url = f"{scheme}://{host}/digiserver"
|
||||
|
||||
# Get or generate API key for the player
|
||||
# For now, use a hash of player_name and hostname as a simple key
|
||||
import hashlib
|
||||
api_key = hashlib.sha256(f'{player_name}:{hostname}'.encode()).hexdigest()[:32]
|
||||
|
||||
# Execute deployment
|
||||
result = deploy_player_to_host(
|
||||
hostname=hostname,
|
||||
username=username,
|
||||
password=password,
|
||||
player_name=player_name,
|
||||
repo_url=repo_url,
|
||||
deploy_path=deploy_path, # Will use /home/[user]/kiwy-signage if None
|
||||
port=port,
|
||||
server_url=server_url,
|
||||
server_api_key=api_key
|
||||
)
|
||||
|
||||
log_action('info', f'Player deployment for {player_name} on {hostname}: success={result["success"]}')
|
||||
|
||||
return jsonify(result), 200 if result['success'] else 400
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error deploying player: {str(e)}')
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'message': f'Deployment error: {str(e)}',
|
||||
'steps': []
|
||||
}), 500
|
||||
|
||||
|
||||
@api_bp.errorhandler(404)
|
||||
def api_not_found(error):
|
||||
"""Handle 404 errors in API."""
|
||||
|
||||
@@ -41,7 +41,7 @@ def list():
|
||||
@players_bp.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_player():
|
||||
"""Add a new player."""
|
||||
"""Add a new player with optional SSH deployment."""
|
||||
if request.method == 'GET':
|
||||
playlists = Playlist.query.filter_by(is_active=True).order_by(Playlist.name).all()
|
||||
return render_template('players/add_player.html', playlists=playlists)
|
||||
@@ -55,6 +55,13 @@ def add_player():
|
||||
orientation = request.form.get('orientation', 'Landscape')
|
||||
playlist_id = request.form.get('playlist_id', '').strip()
|
||||
|
||||
# Get SSH deployment info if provided
|
||||
ssh_hostname = request.form.get('ssh_hostname', '').strip()
|
||||
ssh_username = request.form.get('ssh_username', '').strip()
|
||||
ssh_password = request.form.get('ssh_password', '').strip()
|
||||
ssh_port = int(request.form.get('ssh_port', '22')) if request.form.get('ssh_port') else 22
|
||||
deploy_player = request.form.get('deploy_player', '').strip()
|
||||
|
||||
# Validation
|
||||
if not name or len(name) < 3:
|
||||
flash('Player name must be at least 3 characters long.', 'warning')
|
||||
@@ -102,14 +109,50 @@ def add_player():
|
||||
|
||||
log_action('info', f'Player "{name}" (hostname: {hostname}) created')
|
||||
|
||||
# If deployment requested and SSH credentials provided, trigger background deployment
|
||||
deployment_initiated = False
|
||||
if deploy_player and ssh_hostname and ssh_username and ssh_password:
|
||||
try:
|
||||
from app.utils.background_tasks import background_player_deployment, run_background_task
|
||||
|
||||
# Get server URL for player configuration
|
||||
from flask import request as flask_request
|
||||
server_url = f"{flask_request.scheme}://{flask_request.host}/digiserver"
|
||||
|
||||
# Generate API key for player authentication
|
||||
import hashlib
|
||||
api_key = hashlib.sha256(f'{name}:{hostname}'.encode()).hexdigest()[:32]
|
||||
|
||||
# Start deployment in background thread
|
||||
run_background_task(
|
||||
background_player_deployment,
|
||||
hostname=ssh_hostname,
|
||||
username=ssh_username,
|
||||
password=ssh_password,
|
||||
player_name=name,
|
||||
player_id=new_player.id,
|
||||
port=ssh_port,
|
||||
server_url=server_url,
|
||||
server_api_key=api_key
|
||||
)
|
||||
deployment_initiated = True
|
||||
log_action('info', f'Background deployment initiated for player "{name}" on {ssh_hostname}')
|
||||
except Exception as deploy_err:
|
||||
log_action('error', f'Failed to initiate background deployment for player "{name}": {str(deploy_err)}')
|
||||
|
||||
# Flash detailed success message
|
||||
success_msg = f'''
|
||||
Player "{name}" created successfully!<br>
|
||||
<strong>Auth Code:</strong> {auth_code}<br>
|
||||
<strong>Auth Code:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{auth_code}</code><br>
|
||||
<strong>Hostname:</strong> {hostname}<br>
|
||||
<strong>Quick Connect:</strong> {quickconnect_code}<br>
|
||||
<small>Configure the player with these credentials in app_config.json</small>
|
||||
'''
|
||||
|
||||
if deployment_initiated:
|
||||
success_msg += f'<strong style="color: #0275d8;">⏳ Deployment in Progress</strong> Deploying to {ssh_hostname} in background...<br>'
|
||||
success_msg += '<small>Check player status to see deployment completion</small><br>'
|
||||
|
||||
success_msg += '<small>Configure the player with these credentials in app_config.json</small>'
|
||||
flash(success_msg, 'success')
|
||||
|
||||
return redirect(url_for('players.list'))
|
||||
|
||||
@@ -41,6 +41,12 @@ class Player(db.Model):
|
||||
playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='SET NULL'),
|
||||
nullable=True, index=True)
|
||||
|
||||
# Deployment tracking
|
||||
deployment_status = db.Column(db.String(50), default='pending', nullable=True) # pending, deployed, failed
|
||||
last_deployment_at = db.Column(db.DateTime, nullable=True)
|
||||
last_deployment_status = db.Column(db.String(50), nullable=True) # success, failed
|
||||
last_deployment_message = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
playlist = db.relationship('Playlist', back_populates='players')
|
||||
feedback = db.relationship('PlayerFeedback', back_populates='player',
|
||||
|
||||
@@ -4,232 +4,326 @@
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
body.dark-mode .form-group label {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
.form-group label { font-weight: bold; display: block; margin-bottom: 0.5rem; }
|
||||
body.dark-mode .form-group label { color: #e2e8f0; }
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
background: #1a202c; border-color: #4a5568; color: #e2e8f0;
|
||||
}
|
||||
body.dark-mode .form-control:focus { border-color: #7c3aed; outline: none; }
|
||||
|
||||
body.dark-mode .form-control:focus {
|
||||
border-color: #7c3aed;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
color: #6c757d;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
body.dark-mode .form-help {
|
||||
color: #718096;
|
||||
}
|
||||
.form-help { color: #6c757d; font-size: 0.875rem; }
|
||||
body.dark-mode .form-help { color: #718096; }
|
||||
|
||||
.section-header {
|
||||
margin-top: 2rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid;
|
||||
margin-top: 2rem; padding-bottom: 0.5rem; border-bottom: 2px solid;
|
||||
}
|
||||
.section-header.blue { border-color: #007bff; }
|
||||
body.dark-mode .section-header.blue { border-color: #667eea; }
|
||||
.section-header.green { border-color: #28a745; }
|
||||
body.dark-mode .section-header.green { border-color: #48bb78; }
|
||||
.section-header.yellow { border-color: #ffc107; }
|
||||
body.dark-mode .section-header.yellow { border-color: #ecc94b; }
|
||||
.section-header.purple { border-color: #9b59b6; }
|
||||
body.dark-mode .section-header.purple { border-color: #b794f6; }
|
||||
|
||||
.section-header.blue {
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
body.dark-mode .section-header.blue {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.section-header.green {
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
body.dark-mode .section-header.green {
|
||||
border-color: #48bb78;
|
||||
}
|
||||
|
||||
.section-header.yellow {
|
||||
border-color: #ffc107;
|
||||
}
|
||||
|
||||
body.dark-mode .section-header.yellow {
|
||||
border-color: #ecc94b;
|
||||
}
|
||||
|
||||
body.dark-mode h1,
|
||||
body.dark-mode h3,
|
||||
body.dark-mode h4 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode p {
|
||||
color: #a0aec0;
|
||||
}
|
||||
body.dark-mode h1, body.dark-mode h3, body.dark-mode h4 { color: #e2e8f0; }
|
||||
body.dark-mode p { color: #a0aec0; }
|
||||
|
||||
.info-box {
|
||||
background-color: #e7f3ff;
|
||||
border-left: 4px solid #007bff;
|
||||
padding: 1rem;
|
||||
margin: 2rem 0;
|
||||
background-color: #e7f3ff; border-left: 4px solid #007bff; padding: 1rem; margin: 2rem 0;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box {
|
||||
background-color: #1a365d;
|
||||
border-left-color: #667eea;
|
||||
background-color: #1a365d; border-left-color: #667eea;
|
||||
}
|
||||
|
||||
.info-box h4 {
|
||||
margin-top: 0;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box h4 {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.info-box h4 { margin-top: 0; color: #007bff; }
|
||||
body.dark-mode .info-box h4 { color: #667eea; }
|
||||
.info-box code {
|
||||
background: #f4f4f4;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: #f4f4f4; padding: 2px 6px; border-radius: 3px;
|
||||
}
|
||||
|
||||
body.dark-mode .info-box code {
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
background: #2d3748; color: #e2e8f0;
|
||||
}
|
||||
body.dark-mode small { color: #718096; }
|
||||
|
||||
.ssh-section {
|
||||
background-color: #f8f9fa; padding: 1.5rem; border-radius: 4px; margin-bottom: 2rem;
|
||||
}
|
||||
body.dark-mode .ssh-section { background-color: #2d3748; }
|
||||
|
||||
.connection-status {
|
||||
padding: 1rem; border-radius: 4px; margin-top: 1rem; display: none;
|
||||
}
|
||||
.connection-status.success {
|
||||
background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724; display: block;
|
||||
}
|
||||
.connection-status.error {
|
||||
background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; display: block;
|
||||
}
|
||||
body.dark-mode .connection-status.success {
|
||||
background-color: #22543d; border-color: #2f855a; color: #9ae6b4;
|
||||
}
|
||||
body.dark-mode .connection-status.error {
|
||||
background-color: #742a2a; border-color: #c53030; color: #fc8181;
|
||||
}
|
||||
|
||||
body.dark-mode small {
|
||||
color: #718096;
|
||||
.player-form-section { display: none; }
|
||||
.player-form-section.active { display: block; }
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem; margin-right: 0.5rem; margin-bottom: 0.5rem;
|
||||
border: none; border-radius: 4px; cursor: pointer; font-size: 0.9rem;
|
||||
}
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-primary { background-color: #007bff; color: white; }
|
||||
.btn-primary:hover:not(:disabled) { background-color: #0056b3; }
|
||||
.btn-success { background-color: #28a745; color: white; }
|
||||
.btn-success:hover:not(:disabled) { background-color: #218838; }
|
||||
.btn-secondary { background-color: #6c757d; color: white; }
|
||||
.btn-secondary:hover:not(:disabled) { background-color: #5a6268; }
|
||||
|
||||
.loading-spinner {
|
||||
display: inline-block; width: 1rem; height: 1rem;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3); border-radius: 50%;
|
||||
border-top-color: white; animation: spin 1s linear infinite; margin-right: 0.5rem;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
.row.full { grid-template-columns: 1fr; }
|
||||
@media (max-width: 768px) { .row { grid-template-columns: 1fr; } }
|
||||
|
||||
.hidden-field { display: none; }
|
||||
</style>
|
||||
<div class="container" style="max-width: 800px; margin-top: 2rem;">
|
||||
<h1>Add New Player</h1>
|
||||
|
||||
<div class="container" style="max-width: 900px; margin-top: 2rem;">
|
||||
<h1>Add New Player with SSH Deployment</h1>
|
||||
<p style="color: #6c757d; margin-bottom: 2rem;">
|
||||
Create a new digital signage player with authentication credentials
|
||||
Create a new digital signage player with automatic code deployment via SSH
|
||||
</p>
|
||||
|
||||
<div class="card">
|
||||
<form method="POST">
|
||||
<h3 class="section-header blue" style="margin-top: 0;">
|
||||
Basic Information
|
||||
<!-- SSH Connection Test Section -->
|
||||
<div class="ssh-section">
|
||||
<h3 class="section-header purple" style="margin-top: 0;">
|
||||
🔌 SSH Connection Setup
|
||||
</h3>
|
||||
<p class="form-help">First, test SSH connection to the target host for player deployment</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Display Name *</label>
|
||||
<input type="text" name="name" required class="form-control"
|
||||
placeholder="e.g., Office Reception Player">
|
||||
<small class="form-help">Friendly name for the player</small>
|
||||
<div class="row">
|
||||
<div class="form-group">
|
||||
<label>Target Hostname/IP *</label>
|
||||
<input type="text" id="ssh_hostname" class="form-control"
|
||||
placeholder="e.g., 192.168.1.100 or player.example.com">
|
||||
<small class="form-help">IP address or hostname of the target machine</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SSH Port</label>
|
||||
<input type="number" id="ssh_port" class="form-control" value="22" min="1" max="65535">
|
||||
<small class="form-help">SSH port (default: 22)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Hostname *</label>
|
||||
<input type="text" name="hostname" required class="form-control"
|
||||
placeholder="e.g., office-player-001">
|
||||
<small class="form-help">
|
||||
Unique identifier for this player (must match screen_name in player config)
|
||||
</small>
|
||||
|
||||
<div class="row">
|
||||
<div class="form-group">
|
||||
<label>SSH Username *</label>
|
||||
<input type="text" id="ssh_username" class="form-control" placeholder="e.g., pi or ubuntu">
|
||||
<small class="form-help">SSH login username</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SSH Password *</label>
|
||||
<input type="password" id="ssh_password" class="form-control" placeholder="SSH password">
|
||||
<small class="form-help">SSH login password</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" id="test_ssh_btn" class="btn btn-primary" onclick="testSSHConnection()">
|
||||
✓ Test SSH Connection
|
||||
</button>
|
||||
<button type="button" id="clear_ssh_btn" class="btn btn-secondary" onclick="clearSSHForm()" style="display: none;">
|
||||
🔄 Clear
|
||||
</button>
|
||||
<div id="connection_status" class="connection-status"></div>
|
||||
</div>
|
||||
|
||||
<!-- Player Information Form -->
|
||||
<div id="player_form_section" class="player-form-section">
|
||||
<form method="POST" id="add_player_form">
|
||||
<!-- Hidden SSH fields to store credentials for deployment -->
|
||||
<input type="hidden" id="form_ssh_hostname" name="ssh_hostname" value="">
|
||||
<input type="hidden" id="form_ssh_username" name="ssh_username" value="">
|
||||
<input type="hidden" id="form_ssh_password" name="ssh_password" value="">
|
||||
<input type="hidden" id="form_ssh_port" name="ssh_port" value="">
|
||||
<input type="hidden" id="form_deploy_player" name="deploy_player" value="1">
|
||||
|
||||
<h3 class="section-header blue">Basic Information</h3>
|
||||
<div class="form-group">
|
||||
<label>Display Name *</label>
|
||||
<input type="text" name="name" required class="form-control"
|
||||
placeholder="e.g., Office Reception Player">
|
||||
<small class="form-help">Friendly name for the player</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Hostname *</label>
|
||||
<input type="text" name="hostname" required class="form-control"
|
||||
placeholder="e.g., office-player-001">
|
||||
<small class="form-help">Unique identifier for this player</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Location</label>
|
||||
<input type="text" name="location" class="form-control"
|
||||
placeholder="e.g., Main Office - Reception Area">
|
||||
<small class="form-help">Physical location of the player (optional)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Location</label>
|
||||
<input type="text" name="location" class="form-control"
|
||||
placeholder="e.g., Main Office - Reception Area">
|
||||
<small class="form-help">Physical location of the player (optional)</small>
|
||||
</div>
|
||||
<h3 class="section-header green">Authentication</h3>
|
||||
<p class="form-help" style="margin-bottom: 1rem;">
|
||||
Quick Connect recommended for easy setup
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" id="password" class="form-control"
|
||||
placeholder="Leave empty to use Quick Connect only">
|
||||
<small class="form-help">Secure password (optional if using Quick Connect)</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Quick Connect Code *</label>
|
||||
<input type="text" name="quickconnect_code" required class="form-control"
|
||||
placeholder="e.g., OFFICE123">
|
||||
<small class="form-help">Easy pairing code for quick setup</small>
|
||||
</div>
|
||||
|
||||
<h3 class="section-header green">
|
||||
Authentication
|
||||
</h3>
|
||||
<p class="form-help" style="margin-bottom: 1rem;">
|
||||
Choose one authentication method (Quick Connect recommended for easy setup)
|
||||
</p>
|
||||
<h3 class="section-header yellow">Display Settings</h3>
|
||||
<div class="form-group">
|
||||
<label>Orientation</label>
|
||||
<select name="orientation" class="form-control">
|
||||
<option value="Landscape" selected>Landscape</option>
|
||||
<option value="Portrait">Portrait</option>
|
||||
</select>
|
||||
<small class="form-help">Display orientation for the player</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Assign Playlist</label>
|
||||
<select name="playlist_id" class="form-control">
|
||||
<option value="">No Playlist (Unassigned)</option>
|
||||
{% for playlist in playlists %}
|
||||
<option value="{{ playlist.id }}">{{ playlist.name }} ({{ playlist.orientation }}) - {{ playlist.content_count }} items</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-help">Assign player to a playlist (optional)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" id="password" class="form-control"
|
||||
placeholder="Leave empty to use Quick Connect only">
|
||||
<small class="form-help">
|
||||
Secure password for player authentication (optional if using Quick Connect)
|
||||
</small>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<h4>📋 What Happens Next</h4>
|
||||
<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">
|
||||
<li><strong>Player Creation:</strong> Player record created with Auth Code</li>
|
||||
<li><strong>Code Deployment:</strong> Player code from Kiwy-Signage repository deployed to <span id="deploy_host_info">target host</span></li>
|
||||
<li><strong>Installation:</strong> Installation scripts executed on remote host</li>
|
||||
<li><strong>Configuration:</strong> Configure <code>app_config.json</code> with Auth Code provided</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Quick Connect Code *</label>
|
||||
<input type="text" name="quickconnect_code" required class="form-control"
|
||||
placeholder="e.g., OFFICE123">
|
||||
<small class="form-help">
|
||||
Easy pairing code for quick setup (must match quickconnect_key in player config)
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<h3 class="section-header yellow">
|
||||
Display Settings
|
||||
</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Orientation</label>
|
||||
<select name="orientation" class="form-control">
|
||||
<option value="Landscape" selected>Landscape</option>
|
||||
<option value="Portrait">Portrait</option>
|
||||
</select>
|
||||
<small class="form-help">Display orientation for the player</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Assign Playlist</label>
|
||||
<select name="playlist_id" class="form-control">
|
||||
<option value="">No Playlist (Unassigned)</option>
|
||||
{% for playlist in playlists %}
|
||||
<option value="{{ playlist.id }}">{{ playlist.name }} ({{ playlist.orientation }}) - {{ playlist.content_count }} items</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-help">Assign player to a playlist (optional)</small>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h4>📋 Setup Instructions</h4>
|
||||
<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">
|
||||
<li>Create the player with the form above</li>
|
||||
<li>Note the generated <strong>Auth Code</strong> (shown after creation)</li>
|
||||
<li>Configure the player's <code>app_config.json</code> with:
|
||||
<ul style="margin-top: 0.5rem;">
|
||||
<li><code>server_ip</code>: Your server address</li>
|
||||
<li><code>screen_name</code>: Same as <strong>Hostname</strong> above</li>
|
||||
<li><code>quickconnect_key</code>: Same as <strong>Quick Connect Code</strong> above</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Start the player - it will authenticate automatically</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd;">
|
||||
<button type="submit" class="btn btn-success" style="padding: 0.75rem 2rem;">
|
||||
✓ Create Player
|
||||
</button>
|
||||
<a href="{{ url_for('players.list') }}" class="btn" style="padding: 0.75rem 2rem; margin-left: 1rem;">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
<div style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd;">
|
||||
<button type="submit" id="create_deploy_btn" class="btn btn-success" style="padding: 0.75rem 2rem;">
|
||||
⚙️ Create & Deploy Player
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="cancelForm()" style="padding: 0.75rem 2rem; margin-left: 1rem;">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let sshConnectionVerified = false;
|
||||
|
||||
function testSSHConnection() {
|
||||
const hostname = document.getElementById('ssh_hostname').value.trim();
|
||||
const username = document.getElementById('ssh_username').value.trim();
|
||||
const password = document.getElementById('ssh_password').value.trim();
|
||||
const port = parseInt(document.getElementById('ssh_port').value) || 22;
|
||||
|
||||
if (!hostname || !username || !password) {
|
||||
alert('Please fill in all SSH connection fields');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('test_ssh_btn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="loading-spinner"></span>Testing connection...';
|
||||
const statusDiv = document.getElementById('connection_status');
|
||||
|
||||
fetch('{{ url_for("api.test_ssh_connection") }}', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({hostname, username, password, port})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
sshConnectionVerified = data.success;
|
||||
statusDiv.className = 'connection-status ' + (data.success ? 'success' : 'error');
|
||||
statusDiv.innerHTML = `<strong>${data.success ? '✓ Connected!' : '✗ Connection Failed'}</strong><br>${data.message}`;
|
||||
|
||||
if (data.success) {
|
||||
// Disable SSH fields and store credentials
|
||||
document.getElementById('ssh_hostname').disabled = true;
|
||||
document.getElementById('ssh_username').disabled = true;
|
||||
document.getElementById('ssh_password').disabled = true;
|
||||
document.getElementById('ssh_port').disabled = true;
|
||||
document.getElementById('test_ssh_btn').style.display = 'none';
|
||||
document.getElementById('clear_ssh_btn').style.display = 'inline-block';
|
||||
|
||||
// Store credentials in hidden form fields
|
||||
document.getElementById('form_ssh_hostname').value = hostname;
|
||||
document.getElementById('form_ssh_username').value = username;
|
||||
document.getElementById('form_ssh_password').value = password;
|
||||
document.getElementById('form_ssh_port').value = port;
|
||||
|
||||
// Show player form
|
||||
document.getElementById('player_form_section').classList.add('active');
|
||||
document.getElementById('deploy_host_info').textContent = hostname;
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '✓ Test SSH Connection';
|
||||
})
|
||||
.catch(err => {
|
||||
statusDiv.className = 'connection-status error';
|
||||
statusDiv.innerHTML = `<strong>✗ Error:</strong> ${err.message}`;
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '✓ Test SSH Connection';
|
||||
});
|
||||
}
|
||||
|
||||
function clearSSHForm() {
|
||||
document.getElementById('ssh_hostname').value = '';
|
||||
document.getElementById('ssh_username').value = '';
|
||||
document.getElementById('ssh_password').value = '';
|
||||
document.getElementById('ssh_port').value = '22';
|
||||
document.getElementById('ssh_hostname').disabled = false;
|
||||
document.getElementById('ssh_username').disabled = false;
|
||||
document.getElementById('ssh_password').disabled = false;
|
||||
document.getElementById('ssh_port').disabled = false;
|
||||
document.getElementById('test_ssh_btn').style.display = 'inline-block';
|
||||
document.getElementById('clear_ssh_btn').style.display = 'none';
|
||||
document.getElementById('connection_status').className = 'connection-status';
|
||||
document.getElementById('player_form_section').classList.remove('active');
|
||||
document.getElementById('add_player_form').reset();
|
||||
sshConnectionVerified = false;
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
if (confirm('Are you sure? All changes will be lost.')) {
|
||||
window.location.href = '{{ url_for("players.list") }}';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Background task execution for long-running operations."""
|
||||
import threading
|
||||
import logging
|
||||
from typing import Callable, Any, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_background_task(task_func: Callable, *args, **kwargs) -> threading.Thread:
|
||||
"""
|
||||
Run a function in a background thread.
|
||||
|
||||
Args:
|
||||
task_func: Function to execute
|
||||
*args: Positional arguments for the function
|
||||
**kwargs: Keyword arguments for the function
|
||||
|
||||
Returns:
|
||||
Thread object
|
||||
"""
|
||||
def wrapper():
|
||||
try:
|
||||
logger.info(f"Starting background task: {task_func.__name__}")
|
||||
task_func(*args, **kwargs)
|
||||
logger.info(f"Completed background task: {task_func.__name__}")
|
||||
except Exception as e:
|
||||
logger.error(f"Background task failed ({task_func.__name__}): {str(e)}", exc_info=True)
|
||||
|
||||
thread = threading.Thread(target=wrapper, daemon=True)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
def background_player_deployment(
|
||||
hostname: str,
|
||||
username: str,
|
||||
password: str,
|
||||
player_name: str,
|
||||
player_id: int,
|
||||
port: int = 22,
|
||||
server_url: str = None,
|
||||
server_api_key: str = None
|
||||
) -> None:
|
||||
"""
|
||||
Deploy player code to host in background.
|
||||
|
||||
Args:
|
||||
hostname: SSH hostname/IP
|
||||
username: SSH username
|
||||
password: SSH password
|
||||
player_name: Player name
|
||||
player_id: Player database ID
|
||||
port: SSH port
|
||||
server_url: DigiServer URL for player
|
||||
server_api_key: API key for player
|
||||
"""
|
||||
from app.utils.ssh_deploy import deploy_player_to_host
|
||||
from app.models import Player
|
||||
from app.extensions import db
|
||||
from app.utils.logger import log_action
|
||||
|
||||
try:
|
||||
# Execute deployment
|
||||
result = deploy_player_to_host(
|
||||
hostname=hostname,
|
||||
username=username,
|
||||
password=password,
|
||||
player_name=player_name,
|
||||
port=port,
|
||||
server_url=server_url,
|
||||
server_api_key=server_api_key
|
||||
)
|
||||
|
||||
# Update player with deployment status
|
||||
from datetime import datetime
|
||||
player = Player.query.get(player_id)
|
||||
if player:
|
||||
player.last_deployment_at = datetime.utcnow()
|
||||
if result.get('success'):
|
||||
player.deployment_status = 'deployed'
|
||||
player.last_deployment_status = 'success'
|
||||
player.last_deployment_message = result.get('message', 'Deployment successful')
|
||||
log_action('info', f'Background deployment completed for player "{player_name}": {result["message"]}')
|
||||
else:
|
||||
player.deployment_status = 'failed'
|
||||
player.last_deployment_status = 'failed'
|
||||
player.last_deployment_message = result.get('error', result.get('message', 'Deployment failed'))
|
||||
log_action('error', f'Background deployment failed for player "{player_name}": {result.get("error", result.get("message"))}')
|
||||
|
||||
db.session.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Background deployment error for player '{player_name}': {str(e)}", exc_info=True)
|
||||
log_action('error', f'Background deployment error for player "{player_name}": {str(e)}')
|
||||
@@ -0,0 +1,543 @@
|
||||
"""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() -> Dict[str, Any]:
|
||||
"""
|
||||
Check status of pre-staged player code.
|
||||
|
||||
Returns:
|
||||
Dict with availability, version, and path info
|
||||
"""
|
||||
try:
|
||||
if not os.path.isdir(LOCAL_PLAYER_CODE_DIR):
|
||||
return {
|
||||
'available': False,
|
||||
'reason': 'Directory not found',
|
||||
'path': LOCAL_PLAYER_CODE_DIR
|
||||
}
|
||||
|
||||
# Check if git repository
|
||||
git_dir = os.path.join(LOCAL_PLAYER_CODE_DIR, '.git')
|
||||
if not os.path.isdir(git_dir):
|
||||
return {
|
||||
'available': False,
|
||||
'reason': 'Not a git repository',
|
||||
'path': LOCAL_PLAYER_CODE_DIR
|
||||
}
|
||||
|
||||
# Get current git version
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['git', '-C', LOCAL_PLAYER_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', LOCAL_PLAYER_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': LOCAL_PLAYER_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': LOCAL_PLAYER_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 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
|
||||
) -> 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
|
||||
|
||||
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
|
||||
try:
|
||||
if server_url and server_api_key:
|
||||
config_content = generate_player_config(
|
||||
player_name=player_name,
|
||||
server_url=server_url,
|
||||
api_key=server_api_key
|
||||
)
|
||||
|
||||
# Write config file to remote host
|
||||
write_config_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
f'cat > {deploy_path}/config.json << \'EOF\'\n{config_content}\nEOF'
|
||||
]
|
||||
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'Player configuration created',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to create player config: {str(e)}')
|
||||
|
||||
# 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
|
||||
install_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
f'cd {deploy_path} && 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': []
|
||||
}
|
||||
Reference in New Issue
Block a user