"""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. Performance note ---------------- The player repository is large (~200 MB) and a full clone takes ~90 s. Because the build runs inside an HTTP request, that would exceed gunicorn's worker timeout and the worker would be killed mid-clone, leaving a broken checkout. Two mitigations are used together: * **Shallow clones** (``--depth 1``) — only the tip of the requested branch is fetched, which is all a deployment needs. Drastically reduces transfer size. * **Background execution** — the admin route starts the build in a daemon thread and the page polls for progress, so no worker ever blocks on git. """ import os import json import shutil import subprocess import logging import threading 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' # Only the tip of the branch is needed to deploy a player, so history is not # fetched. Keeps the transfer small enough to avoid worker timeouts. CLONE_DEPTH = '1' # Never let git wait for a human. Without this, a private/renamed repository # makes git block on a username prompt until the worker is killed. GIT_ENV = { 'GIT_TERMINAL_PROMPT': '0', # never prompt for credentials 'GIT_ASKPASS': 'true', # answer any credential request immediately 'GIT_SSH_COMMAND': 'ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new', } def _git_env() -> Dict[str, str]: """Environment for git subprocesses: inherit the process env plus our flags.""" env = dict(os.environ) env.update(GIT_ENV) return env def _run_git(args, cwd=None, timeout=120) -> subprocess.CompletedProcess: """Run a git command, never prompting for input. Args: args: git arguments (without the leading 'git'). cwd: working directory for the command. timeout: hard cap in seconds. Defaults to 120 to stay within a reasonable window even when running in the foreground. Returns: The completed process. ``returncode`` is 124 on timeout so callers can distinguish a timeout from a normal failure. """ try: return subprocess.run( ['git'] + args, cwd=cwd, capture_output=True, text=True, timeout=timeout, env=_git_env(), ) except subprocess.TimeoutExpired as e: # Surface timeouts as a normal result so callers do not need try/except. out = e.stdout.decode() if isinstance(e.stdout, bytes) else (e.stdout or '') err = e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or '') return subprocess.CompletedProcess( args=['git'] + list(args), returncode=124, stdout=out, stderr=(err + f'\ngit {" ".join(args)} timed out after {timeout}s').strip(), ) def get_short_head(player_code_dir: str) -> str: """Return the short git commit of the staged code, or 'unknown'.""" result = _run_git(['-C', player_code_dir, 'rev-parse', '--short', 'HEAD'], timeout=10) if result.returncode == 0: return result.stdout.strip() return 'unknown' def is_valid_checkout(path: str) -> bool: """True when *path* is a usable git checkout with a resolvable HEAD.""" if not os.path.isdir(os.path.join(path, '.git')): return False return get_short_head(path) != 'unknown' def _clone(path: str, repo_url: str, branch: str) -> subprocess.CompletedProcess: """Shallow-clone a single branch into *path*.""" return _run_git([ 'clone', '--depth', CLONE_DEPTH, '--single-branch', '--branch', branch, repo_url, path, ], timeout=600) 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``. Uses a **shallow single-branch clone/update** so only the tip of the wanted branch is transferred. If the directory is a usable checkout it is updated (fetch + hard reset to the branch). A directory that exists but is NOT a usable checkout — e.g. left behind by an interrupted clone — is removed and re-cloned, since updating it can never work. Args: player_code_dir: Destination directory for the staged player code. repo_url: Git repository to pull from. branch: Branch to stage. Returns: ``{'success': bool, 'message': str, 'version': str|None, 'branch': str}`` """ branch = (branch or 'main').strip() repo_url = (repo_url or '').strip() def fail(message: str) -> Dict[str, Any]: return {'success': False, 'message': message, 'version': get_short_head(player_code_dir), 'branch': branch} if not repo_url: return {'success': False, 'message': 'Repository URL is required.', 'version': None, 'branch': branch} try: usable = is_valid_checkout(player_code_dir) if usable: # Update in place. Depth 1 keeps the update cheap; fetch by ref so # it works on a shallow clone. fetch = _run_git( ['-C', player_code_dir, 'fetch', '--depth', CLONE_DEPTH, '--prune', 'origin', branch]) if fetch.returncode != 0: return fail(f'git fetch failed: {fetch.stderr.strip() or fetch.stdout.strip()}') # 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 fail(f'git checkout {branch} failed: {checkout.stderr.strip()}') reset = _run_git(['-C', player_code_dir, 'reset', '--hard', f'origin/{branch}']) if reset.returncode != 0: return fail(f'git reset failed: {reset.stderr.strip()}') action = 'Updated' else: # Fresh clone. A previous attempt may have left a partial directory # (e.g. killed mid-clone) — it must go, or the clone will fail with # "destination path already exists and is not an empty directory". parent = os.path.dirname(player_code_dir.rstrip('/')) if parent: os.makedirs(parent, exist_ok=True) if os.path.exists(player_code_dir): logger.info('Removing unusable directory before clone: %s', player_code_dir) shutil.rmtree(player_code_dir, ignore_errors=True) clone = _clone(player_code_dir, repo_url, branch) if clone.returncode != 0: # Do not leave a half-written directory behind. shutil.rmtree(player_code_dir, ignore_errors=True) detail = clone.stderr.strip() or clone.stdout.strip() if clone.returncode == 124 or 'timed out' in detail: return fail(f'git clone timed out. The repository may be very ' f'large or unreachable: {detail}') return fail(f'git clone failed: {detail}') 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 Exception as e: # noqa: BLE001 - surface any failure logger.exception('build_player_files failed') # Never leave a broken checkout behind for the next attempt. try: if not is_valid_checkout(player_code_dir): shutil.rmtree(player_code_dir, ignore_errors=True) except Exception: pass return fail(f'Build failed: {str(e)}') 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, } # --------------------------------------------------------------------------- # Background builds # # A full clone/refresh takes far longer than gunicorn's worker timeout, so the # build must not run inside the request. The admin route starts it here and the # page polls `build_state()` for progress. # --------------------------------------------------------------------------- # Serialises writes to _build_state between the request thread and the worker. _build_lock = threading.Lock() # Coarse progress for the admin UI. 'state' is one of: # idle | running | success | error _build_state: Dict[str, Any] = {'state': 'idle'} def get_build_state() -> Dict[str, Any]: """Return a snapshot of the current/last build for the admin UI.""" with _build_lock: return dict(_build_state) def is_build_running() -> bool: """True while a build is in progress.""" with _build_lock: return _build_state.get('state') == 'running' def _set_build_state(**fields: Any) -> None: with _build_lock: _build_state.update(fields) def _run_build_job(app, player_code_dir: str, repo_url: str, branch: str, config_payload: Optional[Dict[str, Any]], meta_path: str, built_by: str) -> None: """Worker body: build files, optionally write config, then persist settings. Runs in a daemon thread with its own Flask app context so it is independent of the request/response cycle that triggered it. """ started = datetime.utcnow() try: _set_build_state(state='running', step='Fetching player source…', started_at=started.isoformat(timespec='seconds') + 'Z', message='', version=None) result = build_player_files(player_code_dir, repo_url, branch) version = result.get('version') if not result['success']: _set_build_state(state='error', step='', message=result['message'], version=version, finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z') logger.error('Background player build failed: %s', result['message']) return # Optional step 2: write the base config. if config_payload: _set_build_state(step='Writing player config…') cfg = write_base_config(player_code_dir=player_code_dir, **config_payload) if not cfg['success']: _set_build_state(state='error', step='', message=cfg['message'], version=version, finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z') logger.error('Background player config write failed: %s', cfg['message']) return result = {**result, 'message': f"{result['message']} {cfg['message']}"} if version is None: version = get_short_head(player_code_dir) save_build_settings( meta_path, make_build_record( repo_url=repo_url, branch=branch, server_ip=(config_payload or {}).get('server_ip', ''), port=(config_payload or {}).get('port', ''), use_https=(config_payload or {}).get('use_https', False), verify_ssl=(config_payload or {}).get('verify_ssl', False), orientation=(config_payload or {}).get('orientation', 'Landscape'), max_resolution=(config_payload or {}).get('max_resolution', '1920x1080'), version=version, built_by=built_by, ), ) _set_build_state(state='success', step='', message=result['message'], version=version, finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z') logger.info('Background player build complete (version %s)', version) except Exception as e: # noqa: BLE001 - never kill the thread silently logger.exception('Background player build crashed') _set_build_state(state='error', step='', message=f'Build failed: {e}', finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z') def start_background_build(player_code_dir: str, repo_url: str, branch: str, config_payload: Optional[Dict[str, Any]], meta_path: str, built_by: str) -> bool: """Start a player build in a daemon thread. Args: player_code_dir: Where to stage the player source. repo_url: Git repository URL. branch: Branch to stage. config_payload: Keyword args for :func:`write_base_config`, or None to skip writing the config. meta_path: Where to persist the build record. built_by: Username shown in the UI/logs. Returns: False if a build is already running (callers should tell the user), True if a new build was started. Raises: RuntimeError: if called with no Flask application context — the worker thread needs a real app object to push its own context. """ from flask import current_app if is_build_running(): return False # Capture the real app object now. `current_app` resolves inside either a # request or a plain application context; the worker thread pushes its own # context later, since the caller's context is gone by then. app = current_app._get_current_object() _set_build_state(state='running', step='Starting…', message='', version=None, started_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z', finished_at=None, built_by=built_by, repo_url=repo_url, branch=branch) thread = threading.Thread( target=_run_build_job, args=(app, player_code_dir, repo_url, branch, config_payload, meta_path, built_by), name='player-build', daemon=True, ) thread.start() return True