feat: add weblink playlists, SSH player deployment, Caddy HTTPS, build player page
- Content model: add url column + is_weblink() for web page content items - Player model: add deployment tracking fields (status, timestamps, message) - Content blueprint: add add_weblink and add_weblink_to_playlist routes; weblinks auto-deleted when removed from playlist - Players blueprint: add SSH deploy mode in add_player; weblink-aware playlist - API blueprint: weblink URL served directly in playlist response; add /api/deploy/test-ssh and /api/deploy/player endpoints - Admin blueprint: add build-player page (clone from Gitea, write base config); replace nginx status card with Caddy status on HTTPS config page - caddy_manager: rewritten to generate proper HTTPS/internal-CA Caddyfiles and reload Caddy via admin API (/load) for live config updates - ssh_deploy, background_tasks, player_build: new utils for SSH deployment - background_tasks: push Flask app context into background thread so DB updates after deployment complete correctly - ssh_deploy: robust install script detection with passwordless sudo injection (uses SSH credentials, cleaned up after install) - Dockerfile: add git, sshpass, openssh-client, rsync - docker-compose: switch nginx to Caddy on ports 80/443; add port 5000 for dev - Templates: add_player deploy mode UI, weblink form in playlist/upload pages, build_player admin page, Caddy status on HTTPS config page - Migrations: add_url_to_content, add_deployment_fields_to_player - app.py: call db.create_all() on startup for schema bootstrap - config.py: add PLAYER_CODE_DIR and PLAYER_REPO_URL settings
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"""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, with a Flask app context pushed."""
|
||||
from flask import current_app
|
||||
# Capture the app instance now (in the request context) so the thread can use it
|
||||
app = current_app._get_current_object()
|
||||
|
||||
def wrapper():
|
||||
with app.app_context():
|
||||
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,
|
||||
player_hostname: str = None,
|
||||
quickconnect_code: str = None,
|
||||
orientation: str = 'Landscape',
|
||||
verify_ssl: bool = False
|
||||
) -> 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
|
||||
player_hostname: Player screen identity used for auth (Player.hostname)
|
||||
quickconnect_code: Quick connect code used for auth
|
||||
orientation: Player orientation (Landscape/Portrait)
|
||||
verify_ssl: Whether the player should verify the server TLS certificate
|
||||
"""
|
||||
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,
|
||||
player_hostname=player_hostname,
|
||||
quickconnect_code=quickconnect_code,
|
||||
orientation=orientation,
|
||||
verify_ssl=verify_ssl
|
||||
)
|
||||
|
||||
# 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)}')
|
||||
+116
-2
@@ -3,12 +3,126 @@ import os
|
||||
from typing import Optional
|
||||
from app.models.https_config import HTTPSConfig
|
||||
|
||||
# Shared reverse-proxy snippet used in every Caddy site block
|
||||
_PROXY_SNIPPET = """\
|
||||
reverse_proxy digiserver-app:5000 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
transport http {
|
||||
read_timeout 300s
|
||||
write_timeout 300s
|
||||
}
|
||||
}
|
||||
|
||||
request_body {
|
||||
max_size 2GB
|
||||
}
|
||||
|
||||
encode gzip
|
||||
|
||||
header {
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-XSS-Protection "1; mode=block"
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/access.log
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class CaddyConfigGenerator:
|
||||
"""Generate Caddyfile configuration based on HTTPSConfig."""
|
||||
|
||||
|
||||
@staticmethod
|
||||
def generate_caddyfile(config: Optional[HTTPSConfig] = None) -> str:
|
||||
def generate_caddyfile(config: Optional['HTTPSConfig'] = None) -> str:
|
||||
"""Generate a complete Caddyfile.
|
||||
|
||||
Behaviour:
|
||||
- HTTPS disabled / no domain → HTTP-only on port 80 (initial deploy mode).
|
||||
- HTTPS enabled + real domain → Caddy auto-provisions a Let's Encrypt cert
|
||||
for that domain; HTTP redirects to HTTPS automatically.
|
||||
- HTTPS enabled + IP only (no domain) → TLS with Caddy's internal CA
|
||||
(self-signed, trusted within the Docker network).
|
||||
"""
|
||||
if config is None:
|
||||
config = HTTPSConfig.get_config()
|
||||
|
||||
email = (config.email or "admin@localhost") if config else "admin@localhost"
|
||||
https_enabled = config.https_enabled if config else False
|
||||
domain = (config.domain or "").strip() if config else ""
|
||||
ip_address = (config.ip_address or "").strip() if config else ""
|
||||
|
||||
global_block = f"""{{\n admin 0.0.0.0:2019\n email {email}\n}}\n\n"""
|
||||
|
||||
if https_enabled and domain:
|
||||
# Caddy handles Let's Encrypt + HTTP→HTTPS redirect automatically
|
||||
# when a plain hostname (no scheme) is used.
|
||||
caddyfile = global_block
|
||||
caddyfile += f"{domain} {{\n{_PROXY_SNIPPET}}}\n"
|
||||
# Also accept requests on the raw IP (HTTP only, no cert needed)
|
||||
if ip_address:
|
||||
caddyfile += f"\nhttp://{ip_address} {{\n{_PROXY_SNIPPET}}}\n"
|
||||
elif https_enabled and ip_address:
|
||||
# No public domain — use Caddy's internal CA (self-signed)
|
||||
caddyfile = global_block
|
||||
caddyfile += f"https://{ip_address} {{\n tls internal\n{_PROXY_SNIPPET}}}\n"
|
||||
caddyfile += f"\nhttp://{ip_address} {{\n redir https://{ip_address}{{uri}} 301\n}}\n"
|
||||
else:
|
||||
# HTTP-only fallback (first deploy, before HTTPS is configured)
|
||||
caddyfile = "{\n admin 0.0.0.0:2019\n}\n\n"
|
||||
caddyfile += f":80 {{\n{_PROXY_SNIPPET}}}\n"
|
||||
|
||||
return caddyfile
|
||||
|
||||
@staticmethod
|
||||
def write_caddyfile(caddyfile_content: str,
|
||||
path: str = '/etc/caddy/Caddyfile') -> bool:
|
||||
"""Write Caddyfile to disk.
|
||||
|
||||
The default path is /etc/caddy/Caddyfile — the standard location inside
|
||||
the caddy:2-alpine container when a volume is mounted there.
|
||||
"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, 'w') as f:
|
||||
f.write(caddyfile_content)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error writing Caddyfile: {str(e)}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def reload_caddy() -> bool:
|
||||
"""Push the current Caddyfile to Caddy via its admin API (/load).
|
||||
|
||||
Caddy applies the new config live without dropping connections.
|
||||
"""
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
caddyfile_path = '/etc/caddy/Caddyfile'
|
||||
if not os.path.exists(caddyfile_path):
|
||||
print(f"Caddyfile not found at {caddyfile_path}")
|
||||
return False
|
||||
|
||||
with open(caddyfile_path, 'rb') as f:
|
||||
caddyfile_bytes = f.read()
|
||||
|
||||
req = urllib.request.Request(
|
||||
'http://caddy:2019/load',
|
||||
data=caddyfile_bytes,
|
||||
headers={'Content-Type': 'text/caddyfile'},
|
||||
method='POST',
|
||||
)
|
||||
response = urllib.request.urlopen(req, timeout=10)
|
||||
return response.status == 200
|
||||
except Exception as e:
|
||||
print(f"Caddy reload error: {str(e)}")
|
||||
return False
|
||||
|
||||
"""Generate complete Caddyfile content.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Utilities for building/staging the player files on the server.
|
||||
|
||||
Admins use the "Build player files" admin page to:
|
||||
* clone/refresh the player source code from a git repository into a local
|
||||
staged directory (``PLAYER_CODE_DIR``), and
|
||||
* write a base ``config/app_config.json`` so the staged code already knows how
|
||||
to reach this server.
|
||||
|
||||
The SSH deployment flow then ships this staged directory to player devices, so
|
||||
the version admins build here is exactly what gets deployed.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from app.utils.ssh_deploy import generate_app_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Metadata file name stored in the Flask instance folder.
|
||||
BUILD_META_FILENAME = 'player_build.json'
|
||||
|
||||
|
||||
def _run_git(args, cwd=None, timeout=300) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
['git'] + args,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def get_short_head(player_code_dir: str) -> str:
|
||||
"""Return the short git commit of the staged code, or 'unknown'."""
|
||||
try:
|
||||
result = _run_git(['-C', player_code_dir, 'rev-parse', '--short', 'HEAD'], timeout=10)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def build_player_files(player_code_dir: str, repo_url: str, branch: str = 'main') -> Dict[str, Any]:
|
||||
"""Clone or refresh the player source into ``player_code_dir``.
|
||||
|
||||
If the directory is already a git checkout of ``repo_url`` it is updated in
|
||||
place (fetch + hard reset to the chosen branch). Otherwise it is cloned
|
||||
fresh (an existing non-git directory is replaced).
|
||||
|
||||
Returns a dict: ``success`` (bool), ``message`` (str), ``version`` (str),
|
||||
``branch`` (str).
|
||||
"""
|
||||
branch = (branch or 'main').strip()
|
||||
repo_url = (repo_url or '').strip()
|
||||
if not repo_url:
|
||||
return {'success': False, 'message': 'Repository URL is required.', 'version': None, 'branch': branch}
|
||||
|
||||
try:
|
||||
git_dir = os.path.join(player_code_dir, '.git')
|
||||
is_git_repo = os.path.isdir(git_dir)
|
||||
|
||||
if is_git_repo:
|
||||
# Update existing checkout in place.
|
||||
fetch = _run_git(['-C', player_code_dir, 'fetch', '--prune', 'origin'])
|
||||
if fetch.returncode != 0:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'git fetch failed: {fetch.stderr.strip() or fetch.stdout.strip()}',
|
||||
'version': get_short_head(player_code_dir),
|
||||
'branch': branch,
|
||||
}
|
||||
# Point origin at the requested URL in case it changed.
|
||||
_run_git(['-C', player_code_dir, 'remote', 'set-url', 'origin', repo_url])
|
||||
checkout = _run_git(['-C', player_code_dir, 'checkout', branch])
|
||||
if checkout.returncode != 0:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'git checkout {branch} failed: {checkout.stderr.strip()}',
|
||||
'version': get_short_head(player_code_dir),
|
||||
'branch': branch,
|
||||
}
|
||||
reset = _run_git(['-C', player_code_dir, 'reset', '--hard', f'origin/{branch}'])
|
||||
if reset.returncode != 0:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'git reset failed: {reset.stderr.strip()}',
|
||||
'version': get_short_head(player_code_dir),
|
||||
'branch': branch,
|
||||
}
|
||||
action = 'Updated'
|
||||
else:
|
||||
# Fresh clone. Replace any existing (non-git) directory.
|
||||
parent = os.path.dirname(player_code_dir.rstrip('/'))
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
if os.path.exists(player_code_dir):
|
||||
shutil.rmtree(player_code_dir)
|
||||
clone = _run_git(['clone', '--branch', branch, repo_url, player_code_dir])
|
||||
if clone.returncode != 0:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'git clone failed: {clone.stderr.strip() or clone.stdout.strip()}',
|
||||
'version': None,
|
||||
'branch': branch,
|
||||
}
|
||||
action = 'Cloned'
|
||||
|
||||
version = get_short_head(player_code_dir)
|
||||
logger.info('%s player code from %s (%s) -> %s', action, repo_url, branch, version)
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'{action} player code from {branch} (version {version}).',
|
||||
'version': version,
|
||||
'branch': branch,
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {'success': False, 'message': 'Git operation timed out.', 'version': None, 'branch': branch}
|
||||
except Exception as e:
|
||||
logger.exception('build_player_files failed')
|
||||
return {'success': False, 'message': f'Build failed: {str(e)}', 'version': None, 'branch': branch}
|
||||
|
||||
|
||||
def write_base_config(
|
||||
player_code_dir: str,
|
||||
server_ip: str,
|
||||
port: str,
|
||||
use_https: bool = True,
|
||||
verify_ssl: bool = False,
|
||||
orientation: str = 'Landscape',
|
||||
max_resolution: str = '1920x1080',
|
||||
) -> Dict[str, Any]:
|
||||
"""Write a base ``config/app_config.json`` into the staged player code.
|
||||
|
||||
``screen_name`` and ``quickconnect_key`` are left blank on purpose: they are
|
||||
per-player and get filled in by the SSH deploy step for each device.
|
||||
"""
|
||||
try:
|
||||
config_dir = os.path.join(player_code_dir, 'config')
|
||||
os.makedirs(config_dir, exist_ok=True)
|
||||
content = generate_app_config(
|
||||
server_ip=server_ip,
|
||||
port=str(port),
|
||||
screen_name='',
|
||||
quickconnect_code='',
|
||||
orientation=orientation,
|
||||
use_https=use_https,
|
||||
verify_ssl=verify_ssl,
|
||||
max_resolution=max_resolution,
|
||||
)
|
||||
config_path = os.path.join(config_dir, 'app_config.json')
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
logger.info('Wrote base player config -> %s', config_path)
|
||||
return {'success': True, 'message': 'Base player config written.', 'path': config_path}
|
||||
except Exception as e:
|
||||
logger.exception('write_base_config failed')
|
||||
return {'success': False, 'message': f'Failed to write config: {str(e)}'}
|
||||
|
||||
|
||||
def load_build_settings(meta_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""Load saved build settings from ``meta_path`` (or None if absent/invalid)."""
|
||||
try:
|
||||
if os.path.isfile(meta_path):
|
||||
with open(meta_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning('Could not read build settings: %s', e)
|
||||
return None
|
||||
|
||||
|
||||
def save_build_settings(meta_path: str, data: Dict[str, Any]) -> bool:
|
||||
"""Persist build settings to ``meta_path``."""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(meta_path), exist_ok=True)
|
||||
with open(meta_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning('Could not save build settings: %s', e)
|
||||
return False
|
||||
|
||||
|
||||
def get_player_server_settings(meta_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return the saved server address settings for deployment, if available.
|
||||
|
||||
Returns a dict with ``server_ip``, ``port`` (str), ``use_https`` (bool) and
|
||||
``verify_ssl`` (bool), or None when no usable build settings are saved.
|
||||
"""
|
||||
settings = load_build_settings(meta_path)
|
||||
if not settings:
|
||||
return None
|
||||
server_ip = (settings.get('server_ip') or '').strip()
|
||||
if not server_ip:
|
||||
return None
|
||||
return {
|
||||
'server_ip': server_ip,
|
||||
'port': str(settings.get('port') or ('443' if settings.get('use_https', True) else '80')),
|
||||
'use_https': bool(settings.get('use_https', True)),
|
||||
'verify_ssl': bool(settings.get('verify_ssl', False)),
|
||||
}
|
||||
|
||||
|
||||
def make_build_record(repo_url, branch, server_ip, port, use_https, verify_ssl,
|
||||
orientation, max_resolution, version, built_by) -> Dict[str, Any]:
|
||||
"""Assemble the metadata record to persist after a build."""
|
||||
return {
|
||||
'repo_url': repo_url,
|
||||
'branch': branch,
|
||||
'server_ip': server_ip,
|
||||
'port': str(port),
|
||||
'use_https': bool(use_https),
|
||||
'verify_ssl': bool(verify_ssl),
|
||||
'orientation': orientation,
|
||||
'max_resolution': max_resolution,
|
||||
'built_version': version,
|
||||
'built_at': datetime.utcnow().isoformat(timespec='seconds') + 'Z',
|
||||
'built_by': built_by,
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Portal SSO middleware for DigiServer v2.
|
||||
|
||||
When the umbrella nginx verifies the portal JWT it sets two headers:
|
||||
X-Auth-Username — the portal username
|
||||
X-Auth-Role — 'admin' or 'user'
|
||||
|
||||
This before_request handler reads those headers and auto-logs in the
|
||||
corresponding local DigiServer user, creating them on first access if
|
||||
needed. The local session is then maintained normally by Flask-Login.
|
||||
"""
|
||||
import secrets
|
||||
from flask import request
|
||||
from flask_login import login_user, current_user
|
||||
|
||||
|
||||
def init_portal_sso(app):
|
||||
"""Register the SSO before_request handler on the given Flask app."""
|
||||
|
||||
@app.before_request
|
||||
def _portal_sso():
|
||||
if current_user.is_authenticated:
|
||||
return
|
||||
|
||||
username = request.headers.get('X-Auth-Username', '').strip()
|
||||
if not username:
|
||||
return
|
||||
|
||||
role = request.headers.get('X-Auth-Role', 'user').strip()
|
||||
user = _get_or_create_user(username, role)
|
||||
if user:
|
||||
login_user(user, remember=False)
|
||||
|
||||
|
||||
def _get_or_create_user(username, role):
|
||||
from app.models.user import User
|
||||
from app.extensions import db, bcrypt
|
||||
|
||||
try:
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
hashed_pw = bcrypt.generate_password_hash(secrets.token_hex(32)).decode('utf-8')
|
||||
user = User(
|
||||
username=username,
|
||||
password=hashed_pw,
|
||||
role='admin' if role == 'admin' else 'user',
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
ScriptNameFix WSGI middleware.
|
||||
|
||||
When nginx strips the path prefix before forwarding to a Flask app it also
|
||||
sets the X-Script-Name header (e.g. /digiserver). This middleware reads
|
||||
that header and sets SCRIPT_NAME in the WSGI environ so that Flask's
|
||||
url_for() generates absolute URLs with the correct prefix.
|
||||
|
||||
Usage in the app factory:
|
||||
from app.utils.script_name_fix import ScriptNameFix
|
||||
app.wsgi_app = ScriptNameFix(app.wsgi_app)
|
||||
"""
|
||||
|
||||
|
||||
class ScriptNameFix:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
script_name = environ.get('HTTP_X_SCRIPT_NAME', '').rstrip('/')
|
||||
if script_name:
|
||||
environ['SCRIPT_NAME'] = script_name
|
||||
path_info = environ.get('PATH_INFO', '/')
|
||||
if path_info.startswith(script_name):
|
||||
environ['PATH_INFO'] = path_info[len(script_name):] or '/'
|
||||
return self.app(environ, start_response)
|
||||
@@ -0,0 +1,753 @@
|
||||
"""SSH deployment utilities for player provisioning."""
|
||||
import subprocess
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
from typing import Tuple, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Pre-staged player code location in container
|
||||
LOCAL_PLAYER_CODE_DIR = '/app/data/player'
|
||||
|
||||
|
||||
def get_local_player_code_status(player_code_dir: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Check status of pre-staged player code.
|
||||
|
||||
Args:
|
||||
player_code_dir: Optional override for the staged code path. Defaults to
|
||||
``LOCAL_PLAYER_CODE_DIR`` (the container location).
|
||||
|
||||
Returns:
|
||||
Dict with availability, version, and path info
|
||||
"""
|
||||
code_dir = player_code_dir or LOCAL_PLAYER_CODE_DIR
|
||||
try:
|
||||
if not os.path.isdir(code_dir):
|
||||
return {
|
||||
'available': False,
|
||||
'reason': 'Directory not found',
|
||||
'path': code_dir
|
||||
}
|
||||
|
||||
# Check if git repository
|
||||
git_dir = os.path.join(code_dir, '.git')
|
||||
if not os.path.isdir(git_dir):
|
||||
return {
|
||||
'available': False,
|
||||
'reason': 'Not a git repository',
|
||||
'path': code_dir
|
||||
}
|
||||
|
||||
# Get current git version
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['git', '-C', code_dir, 'rev-parse', '--short', 'HEAD'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
version = result.stdout.strip() if result.returncode == 0 else 'unknown'
|
||||
except:
|
||||
version = 'unknown'
|
||||
|
||||
# Get directory size
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['du', '-sh', code_dir],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
size = result.stdout.split()[0] if result.returncode == 0 else 'unknown'
|
||||
except:
|
||||
size = 'unknown'
|
||||
|
||||
return {
|
||||
'available': True,
|
||||
'path': code_dir,
|
||||
'version': version,
|
||||
'size': size,
|
||||
'updated': os.path.getmtime(git_dir),
|
||||
'reason': 'Pre-staged code ready for deployment'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f'Error checking player code status: {str(e)}')
|
||||
return {
|
||||
'available': False,
|
||||
'reason': f'Status check failed: {str(e)}',
|
||||
'path': code_dir
|
||||
}
|
||||
|
||||
|
||||
def test_ssh_connection(hostname: str, username: str, password: str, port: int = 22) -> Dict[str, Any]:
|
||||
"""
|
||||
Test SSH connection to a remote host.
|
||||
|
||||
Args:
|
||||
hostname: Target hostname or IP
|
||||
username: SSH username
|
||||
password: SSH password
|
||||
port: SSH port (default 22)
|
||||
|
||||
Returns:
|
||||
Dict with status, message, and timestamp
|
||||
"""
|
||||
try:
|
||||
# Use sshpass to test connection without interactive prompt
|
||||
cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'ConnectTimeout=10',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
'echo "SSH connection successful"'
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'SSH connection successful to {hostname}',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'output': result.stdout.strip()
|
||||
}
|
||||
else:
|
||||
error_msg = result.stderr.strip() or result.stdout.strip()
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'SSH connection failed: {error_msg}',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'error': error_msg
|
||||
}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'SSH connection timeout to {hostname}',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'error': 'Connection timeout (10s)'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'SSH test error: {str(e)}')
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'SSH connection error: {str(e)}',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
|
||||
def generate_player_config(
|
||||
player_name: str,
|
||||
server_url: str,
|
||||
api_key: str,
|
||||
player_id: str = None,
|
||||
location: str = None
|
||||
) -> str:
|
||||
"""
|
||||
Generate player configuration JSON for connecting to DigiServer.
|
||||
|
||||
Args:
|
||||
player_name: Name of the player
|
||||
server_url: DigiServer base URL (e.g., http://localhost/digiserver)
|
||||
api_key: API authentication key
|
||||
player_id: Optional player ID (defaults to player_name)
|
||||
location: Optional player location/description
|
||||
|
||||
Returns:
|
||||
JSON configuration string
|
||||
"""
|
||||
config = {
|
||||
"player": {
|
||||
"name": player_name,
|
||||
"id": player_id or player_name,
|
||||
"location": location or "",
|
||||
"version": "2.0"
|
||||
},
|
||||
"server": {
|
||||
"url": server_url,
|
||||
"api_endpoint": f"{server_url}/api",
|
||||
"authentication": {
|
||||
"type": "api_key",
|
||||
"key": api_key
|
||||
},
|
||||
"endpoints": {
|
||||
"playlists": f"{server_url}/api/playlists",
|
||||
"content": f"{server_url}/api/content",
|
||||
"schedule": f"{server_url}/api/schedule",
|
||||
"heartbeat": f"{server_url}/api/player/heartbeat",
|
||||
"logs": f"{server_url}/api/player/logs"
|
||||
}
|
||||
},
|
||||
"playback": {
|
||||
"audio_enabled": True,
|
||||
"video_enabled": True,
|
||||
"max_resolution": "4K",
|
||||
"refresh_interval": 60,
|
||||
"rotation": "0"
|
||||
},
|
||||
"networking": {
|
||||
"timeout": 30,
|
||||
"retry_count": 3,
|
||||
"retry_delay": 5
|
||||
}
|
||||
}
|
||||
|
||||
return json.dumps(config, indent=2)
|
||||
|
||||
|
||||
def detect_server_ip() -> Optional[str]:
|
||||
"""Best-effort detection of this server's primary LAN IP address.
|
||||
|
||||
Opens a UDP socket toward a public address (no packets are actually sent)
|
||||
and reads the local socket address, which resolves to the IP of the
|
||||
interface used for outbound traffic. Returns None on failure.
|
||||
"""
|
||||
import socket
|
||||
s = None
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(('8.8.8.8', 80))
|
||||
ip = s.getsockname()[0]
|
||||
if ip and not ip.startswith('127.'):
|
||||
return ip
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if s is not None:
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback via hostname resolution.
|
||||
try:
|
||||
ip = socket.gethostbyname(socket.gethostname())
|
||||
if ip and not ip.startswith('127.'):
|
||||
return ip
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def parse_server_address(server_url: str) -> Dict[str, Any]:
|
||||
"""Derive the values the player needs from a DigiServer URL.
|
||||
|
||||
The player's config/app_config.json stores server_ip + port + use_https and
|
||||
builds requests as ``{scheme}://{server_ip}:{port}/api/...`` (it does NOT use
|
||||
any URL path prefix such as ``/digiserver``). This helper extracts the host,
|
||||
port and scheme from a server URL and drops any path component.
|
||||
|
||||
Args:
|
||||
server_url: e.g. ``https://signage.example.com/digiserver`` or
|
||||
``http://192.168.0.50:8080``
|
||||
|
||||
Returns:
|
||||
Dict with ``server_ip`` (str), ``port`` (str) and ``use_https`` (bool).
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(server_url if '://' in (server_url or '') else f'//{server_url}')
|
||||
use_https = (parsed.scheme or 'https') == 'https'
|
||||
host = parsed.hostname or ''
|
||||
port = parsed.port
|
||||
if port is None:
|
||||
port = 443 if use_https else 80
|
||||
return {'server_ip': host, 'port': str(port), 'use_https': use_https}
|
||||
|
||||
|
||||
def generate_app_config(
|
||||
server_ip: str,
|
||||
port: str,
|
||||
screen_name: str,
|
||||
quickconnect_code: str,
|
||||
orientation: str = 'Landscape',
|
||||
use_https: bool = True,
|
||||
verify_ssl: bool = False,
|
||||
max_resolution: str = '1920x1080',
|
||||
) -> str:
|
||||
"""Generate the config/app_config.json the player actually reads.
|
||||
|
||||
This is what binds a deployed player to the real server and its assigned
|
||||
playlist: the player authenticates with ``screen_name`` + ``quickconnect_code``
|
||||
and the server returns the playlist assigned to that player.
|
||||
|
||||
Args:
|
||||
server_ip: Server IP or domain the player should contact.
|
||||
port: Server port as a string.
|
||||
screen_name: Player hostname / screen identity (matches Player.hostname).
|
||||
quickconnect_code: Quick connect code (matches Player.quickconnect_code).
|
||||
orientation: Landscape or Portrait.
|
||||
use_https: Whether the player should use HTTPS.
|
||||
verify_ssl: Whether the player should verify the TLS certificate.
|
||||
max_resolution: Maximum playback resolution.
|
||||
|
||||
Returns:
|
||||
JSON configuration string.
|
||||
"""
|
||||
config = {
|
||||
'server_ip': server_ip,
|
||||
'port': str(port),
|
||||
'screen_name': screen_name,
|
||||
'quickconnect_key': quickconnect_code,
|
||||
'orientation': orientation or 'Landscape',
|
||||
'touch': 'True',
|
||||
'max_resolution': max_resolution,
|
||||
'edit_feature_enabled': True,
|
||||
'use_https': bool(use_https),
|
||||
'verify_ssl': bool(verify_ssl),
|
||||
}
|
||||
return json.dumps(config, indent=2)
|
||||
|
||||
|
||||
def deploy_player_to_host(
|
||||
hostname: str,
|
||||
username: str,
|
||||
password: str,
|
||||
player_name: str,
|
||||
repo_url: str = 'https://gitea.moto-adv.com/ske087/Kiwy-Signage.git',
|
||||
deploy_path: str = None, # Default: /home/[user]/kiwy-signage
|
||||
port: int = 22,
|
||||
server_url: str = None, # DigiServer URL for player to connect to
|
||||
server_api_key: str = None, # API key for player authentication
|
||||
player_hostname: str = None, # Player screen identity (Player.hostname)
|
||||
quickconnect_code: str = None, # Player quick connect code
|
||||
orientation: str = 'Landscape', # Player orientation
|
||||
verify_ssl: bool = False, # Whether the player should verify TLS
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Deploy player code to remote host.
|
||||
|
||||
Args:
|
||||
hostname: Target hostname or IP
|
||||
username: SSH username
|
||||
password: SSH password
|
||||
player_name: Name for the player instance
|
||||
repo_url: Git repository URL
|
||||
deploy_path: Path where to deploy on remote host (default: /home/[user]/kiwy-signage)
|
||||
port: SSH port (default 22)
|
||||
server_url: DigiServer URL for player connection
|
||||
server_api_key: API key for player authentication
|
||||
player_hostname: Player screen identity used for auth (Player.hostname)
|
||||
quickconnect_code: Quick connect code used for auth (Player.quickconnect_code)
|
||||
orientation: Player orientation (Landscape/Portrait)
|
||||
verify_ssl: Whether the player should verify the server TLS certificate
|
||||
|
||||
Returns:
|
||||
Dict with deployment status and output
|
||||
"""
|
||||
# Set default deployment path to user's home directory
|
||||
if deploy_path is None:
|
||||
deploy_path = f'/home/{username}/kiwy-signage'
|
||||
try:
|
||||
# Step 1: Verify host accessibility
|
||||
test_result = test_ssh_connection(hostname, username, password, port)
|
||||
if not test_result['success']:
|
||||
return {
|
||||
'success': False,
|
||||
'message': 'Cannot deploy: SSH connection failed',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'error': test_result['message'],
|
||||
'steps': []
|
||||
}
|
||||
|
||||
steps = [
|
||||
{
|
||||
'step': 'SSH Connection Test',
|
||||
'status': 'completed',
|
||||
'message': 'SSH connection successful',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
]
|
||||
|
||||
# Step 2: Create deployment directory
|
||||
try:
|
||||
mkdir_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
f'mkdir -p {deploy_path}'
|
||||
]
|
||||
result = subprocess.run(mkdir_cmd, capture_output=True, text=True, timeout=30)
|
||||
steps.append({
|
||||
'step': 'Create Deploy Directory',
|
||||
'status': 'completed' if result.returncode == 0 else 'failed',
|
||||
'message': f'Directory {deploy_path} created',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
except Exception as e:
|
||||
steps.append({
|
||||
'step': 'Create Deploy Directory',
|
||||
'status': 'failed',
|
||||
'message': f'Failed: {str(e)}',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'Deployment failed at step: Create Deploy Directory',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'error': str(e),
|
||||
'steps': steps
|
||||
}
|
||||
|
||||
# Step 3: Deploy code (use local if available, otherwise clone from git)
|
||||
try:
|
||||
code_status = get_local_player_code_status()
|
||||
|
||||
if code_status['available']:
|
||||
# Use pre-staged player code via rsync
|
||||
logger.info(f'Using pre-staged player code (version: {code_status.get("version", "unknown")})')
|
||||
|
||||
rsync_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'rsync', '-avz',
|
||||
'--delete',
|
||||
'-e', f'ssh -o StrictHostKeyChecking=no -p {port}',
|
||||
f'{LOCAL_PLAYER_CODE_DIR}/',
|
||||
f'{username}@{hostname}:{deploy_path}/'
|
||||
]
|
||||
result = subprocess.run(rsync_cmd, capture_output=True, text=True, timeout=300)
|
||||
|
||||
steps.append({
|
||||
'step': 'Deploy Code',
|
||||
'status': 'completed' if result.returncode == 0 else 'failed',
|
||||
'message': f'Code deployed via rsync (version: {code_status.get("version", "local")})',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.warning(f'Rsync failed, falling back to git clone: {result.stderr}')
|
||||
# Fall back to git clone
|
||||
raise Exception('Rsync failed, retrying with git')
|
||||
else:
|
||||
# No local code, clone from repository
|
||||
logger.info(f'No pre-staged code available ({code_status.get("reason", "unknown")}), cloning from repository')
|
||||
raise Exception('Local code not available')
|
||||
|
||||
except Exception as rsync_error:
|
||||
# Fallback: Clone or pull repository
|
||||
try:
|
||||
logger.info(f'Deploying via git: {rsync_error}')
|
||||
|
||||
# Check if repo already exists
|
||||
check_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
f'[ -d {deploy_path}/.git ]'
|
||||
]
|
||||
result = subprocess.run(check_cmd, capture_output=True, text=True, timeout=10)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Repo exists, pull latest
|
||||
git_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
f'cd {deploy_path} && git pull origin main 2>&1'
|
||||
]
|
||||
git_msg = 'Pull latest code'
|
||||
else:
|
||||
# Clone repository
|
||||
git_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
f'git clone {repo_url} {deploy_path} 2>&1'
|
||||
]
|
||||
git_msg = 'Clone repository'
|
||||
|
||||
result = subprocess.run(git_cmd, capture_output=True, text=True, timeout=120)
|
||||
steps.append({
|
||||
'step': 'Deploy Code',
|
||||
'status': 'completed' if result.returncode == 0 else 'failed',
|
||||
'message': f'{git_msg}: {result.stdout.split(chr(10))[0][:100]}',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
if result.returncode != 0:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'Deployment failed at step: Deploy Code',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'error': result.stderr or result.stdout,
|
||||
'steps': steps
|
||||
}
|
||||
except Exception as e:
|
||||
steps.append({
|
||||
'step': 'Deploy Code',
|
||||
'status': 'failed',
|
||||
'message': f'Failed: {str(e)}',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'Deployment failed at step: Deploy Code',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'error': str(e),
|
||||
'steps': steps
|
||||
}
|
||||
|
||||
# Step 3.5: Generate player configuration (config/app_config.json)
|
||||
# This is the file the player actually reads to learn the server address
|
||||
# and its screen identity. Authenticating with that identity is what binds
|
||||
# the player to its assigned playlist on the real server.
|
||||
install_env_prefix = ''
|
||||
try:
|
||||
screen_name = player_hostname or player_name
|
||||
if server_url and screen_name and quickconnect_code:
|
||||
addr = parse_server_address(server_url)
|
||||
app_config_content = generate_app_config(
|
||||
server_ip=addr['server_ip'],
|
||||
port=addr['port'],
|
||||
screen_name=screen_name,
|
||||
quickconnect_code=quickconnect_code,
|
||||
orientation=orientation or 'Landscape',
|
||||
use_https=addr['use_https'],
|
||||
verify_ssl=verify_ssl,
|
||||
)
|
||||
|
||||
# Build an env prefix so install.sh's configure_player() also runs
|
||||
# (single, consistent configuration path on the player side).
|
||||
import shlex
|
||||
env_pairs = {
|
||||
'KIWY_SERVER_IP': addr['server_ip'],
|
||||
'KIWY_PORT': addr['port'],
|
||||
'KIWY_SCREEN_NAME': screen_name,
|
||||
'KIWY_QUICKCONNECT': quickconnect_code,
|
||||
'KIWY_ORIENTATION': orientation or 'Landscape',
|
||||
'KIWY_USE_HTTPS': 'true' if addr['use_https'] else 'false',
|
||||
'KIWY_VERIFY_SSL': 'true' if verify_ssl else 'false',
|
||||
}
|
||||
install_env_prefix = ' '.join(
|
||||
f'{k}={shlex.quote(str(v))}' for k, v in env_pairs.items()
|
||||
) + ' '
|
||||
|
||||
# Write config/app_config.json directly (robust even if install.sh
|
||||
# is missing or fails), and clear any stale baked-in auth.
|
||||
remote_cmd = (
|
||||
f'mkdir -p {deploy_path}/config && '
|
||||
f"cat > {deploy_path}/config/app_config.json << 'EOF'\n"
|
||||
f'{app_config_content}\n'
|
||||
f'EOF\n'
|
||||
f'rm -f {deploy_path}/player_auth.json {deploy_path}/src/player_auth.json 2>/dev/null || true'
|
||||
)
|
||||
write_config_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
remote_cmd
|
||||
]
|
||||
result = subprocess.run(write_config_cmd, capture_output=True, text=True, timeout=30)
|
||||
steps.append({
|
||||
'step': 'Configure Player',
|
||||
'status': 'completed' if result.returncode == 0 else 'warning',
|
||||
'message': (
|
||||
f'Wrote config/app_config.json '
|
||||
f'(server {addr["server_ip"]}:{addr["port"]}, screen {screen_name})'
|
||||
),
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
else:
|
||||
steps.append({
|
||||
'step': 'Configure Player',
|
||||
'status': 'skipped',
|
||||
'message': 'Missing server_url / player hostname / quickconnect; player not auto-configured',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to create player config: {str(e)}')
|
||||
steps.append({
|
||||
'step': 'Configure Player',
|
||||
'status': 'warning',
|
||||
'message': f'Failed to write player config: {str(e)}',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
# Step 4: Run installation script
|
||||
# Before running the install script, grant the SSH user temporary
|
||||
# passwordless sudo so that any 'sudo apt-get / pip install' calls inside
|
||||
# install.sh don't hang waiting for an interactive password prompt.
|
||||
# The sudoers entry is removed automatically after the script finishes.
|
||||
try:
|
||||
sudoers_file = f'/etc/sudoers.d/kiwy_deploy_{username}'
|
||||
setup_sudo_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
(
|
||||
f'echo {password!r} | sudo -S bash -c '
|
||||
f'"echo \\"{username} ALL=(ALL) NOPASSWD:ALL\\" '
|
||||
f'> {sudoers_file} && chmod 440 {sudoers_file}" 2>&1'
|
||||
)
|
||||
]
|
||||
sudo_result = subprocess.run(setup_sudo_cmd, capture_output=True, text=True, timeout=15)
|
||||
sudo_configured = sudo_result.returncode == 0
|
||||
if not sudo_configured:
|
||||
logger.warning(f'Could not configure passwordless sudo: {sudo_result.stderr[:200]}')
|
||||
except Exception as e:
|
||||
sudo_configured = False
|
||||
logger.warning(f'Passwordless sudo setup failed: {str(e)}')
|
||||
|
||||
try:
|
||||
# Build a shell one-liner: run the first install script found.
|
||||
# Priority: install.sh > setup.sh > install_player.sh > any *.sh except start.sh
|
||||
run_install_cmd = (
|
||||
f'cd {deploy_path} && '
|
||||
f'INSTALL_SCRIPT="" && '
|
||||
f'for s in install.sh setup.sh install_player.sh; do '
|
||||
f' if [ -f "$s" ]; then INSTALL_SCRIPT="$s"; break; fi; '
|
||||
f'done && '
|
||||
f'if [ -z "$INSTALL_SCRIPT" ]; then '
|
||||
f' INSTALL_SCRIPT=$(ls *.sh 2>/dev/null | grep -v "^start.sh$" | head -1); '
|
||||
f'fi && '
|
||||
f'if [ -n "$INSTALL_SCRIPT" ]; then '
|
||||
f' chmod +x "$INSTALL_SCRIPT" && '
|
||||
f' echo "Running $INSTALL_SCRIPT" && '
|
||||
f' {install_env_prefix}bash "$INSTALL_SCRIPT" 2>&1; '
|
||||
f' echo "Exit code: $?"; '
|
||||
f'else '
|
||||
f' echo "No install script found"; '
|
||||
f'fi'
|
||||
)
|
||||
install_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
run_install_cmd
|
||||
]
|
||||
result = subprocess.run(install_cmd, capture_output=True, text=True, timeout=600)
|
||||
output = (result.stdout or '').strip()
|
||||
|
||||
if 'No install script found' in output:
|
||||
steps.append({
|
||||
'step': 'Run Installation Script',
|
||||
'status': 'skipped',
|
||||
'message': 'No install script found in deploy directory',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
else:
|
||||
script_line = next((l for l in output.splitlines() if l.startswith('Running ')), '')
|
||||
script_name = script_line.replace('Running ', '').strip() or 'install script'
|
||||
steps.append({
|
||||
'step': 'Run Installation Script',
|
||||
'status': 'completed' if result.returncode == 0 else 'completed_with_warnings',
|
||||
'message': f'Executed {script_name} (exit {result.returncode})',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
if result.returncode != 0:
|
||||
logger.warning(f'Install script exited {result.returncode}: {result.stderr[:200]}')
|
||||
else:
|
||||
logger.info(f'Install script completed successfully on {hostname}')
|
||||
except subprocess.TimeoutExpired:
|
||||
steps.append({
|
||||
'step': 'Run Installation Script',
|
||||
'status': 'completed_with_warnings',
|
||||
'message': 'Install script timed out after 600s — it may still be running on the device',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
except Exception as e:
|
||||
steps.append({
|
||||
'step': 'Run Installation Script',
|
||||
'status': 'error',
|
||||
'message': f'Error running installation: {str(e)}',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
logger.error(f'Installation script error: {str(e)}')
|
||||
finally:
|
||||
# Always clean up the temporary sudoers entry
|
||||
if sudo_configured:
|
||||
try:
|
||||
cleanup_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
f'sudo rm -f {sudoers_file} 2>/dev/null || true'
|
||||
]
|
||||
subprocess.run(cleanup_cmd, capture_output=True, text=True, timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Step 5: Start player service (execute start.sh)
|
||||
try:
|
||||
# Check if start.sh exists
|
||||
check_start = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
f'[ -f {deploy_path}/start.sh ]'
|
||||
]
|
||||
start_check = subprocess.run(check_start, capture_output=True, text=True, timeout=10)
|
||||
|
||||
if start_check.returncode == 0:
|
||||
# Make sure start.sh is executable and run it
|
||||
start_cmd = [
|
||||
'sshpass', '-p', password,
|
||||
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||
'-p', str(port),
|
||||
f'{username}@{hostname}',
|
||||
f'cd {deploy_path} && chmod +x start.sh && bash start.sh 2>&1'
|
||||
]
|
||||
result = subprocess.run(start_cmd, capture_output=True, text=True, timeout=300)
|
||||
|
||||
# Capture first line of output for feedback
|
||||
output_msg = result.stdout.split('\n')[0][:100] if result.stdout else 'Started'
|
||||
|
||||
steps.append({
|
||||
'step': 'Start Player Service',
|
||||
'status': 'completed' if result.returncode == 0 else 'completed_with_warnings',
|
||||
'message': f'Player service started: {output_msg}',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
logger.info(f'Player service started on {hostname} at {deploy_path}')
|
||||
else:
|
||||
steps.append({
|
||||
'step': 'Start Player Service',
|
||||
'status': 'warning',
|
||||
'message': 'start.sh not found - player may require manual startup',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
logger.warning(f'start.sh not found at {deploy_path}/start.sh on {hostname}')
|
||||
except Exception as e:
|
||||
steps.append({
|
||||
'step': 'Start Player Service',
|
||||
'status': 'error',
|
||||
'message': f'Error starting player service: {str(e)}',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
logger.error(f'Failed to start player service: {str(e)}')
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'Player "{player_name}" deployed successfully to {hostname}',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'deploy_path': deploy_path,
|
||||
'steps': steps
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Deployment error: {str(e)}')
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'Unexpected deployment error: {str(e)}',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'error': str(e),
|
||||
'steps': []
|
||||
}
|
||||
Reference in New Issue
Block a user