updated player deployment for digiserver

This commit is contained in:
ske087
2026-06-07 23:40:50 +03:00
parent b97372f74d
commit f674330b93
30 changed files with 3459 additions and 201 deletions
+128
View File
@@ -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."""
+46 -3
View File
@@ -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'))
+6
View File
@@ -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)}')
+543
View File
@@ -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': []
}