2af04e1db3
- 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
224 lines
8.5 KiB
Python
224 lines
8.5 KiB
Python
"""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,
|
|
}
|