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,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,
|
||||
}
|
||||
Reference in New Issue
Block a user