Files
ServerKit/backend/app/api/templates.py
T
Juan DenisandClaude Opus 4.8 abe0fbece9 feat(templates): deploy templates as a backend repo-kind catalog entry
Deploy templates were a hardcoded frontend constant (AgentSite, with a
hand-fabricated manifest and an EXAMPLE badge). They now live in the backend
catalog as `kind: repo` YAML entries that blend with the 105 compose templates.

- template schema gains `kind: compose | repo` (absent = compose, so every
  existing template is untouched); repo templates carry a `repo` block with a
  `url` instead of a compose stack
- backend/templates/agentsite.yaml (kind: repo) replaces the JS constant
- list + detail payloads serve `kind`/`repo`; category derivation picks up
  repo templates automatically
- GET /api/v1/templates/<id>/manifest inspects a PUBLIC repo via
  RepositoryManifestService.analyze_public_repo (raw host, no clone, no OAuth)
  and falls back to the template's declared hints, labeled `template-hints`
- index.json + catalog-cert test recognize repo-kind; ratchet floor 2475->2498

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 02:38:46 -04:00

529 lines
19 KiB
Python

# Bucket: PER-APP (plan 29 #9). The per-app template routes gate on the shared
# app-access seam; catalog browsing/installation stays panel-authed.
"""
Template API Endpoints
Provides REST endpoints for:
- Listing and browsing templates
- Template installation
- App updates from templates
- Template repository management
"""
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.middleware.rbac import admin_required
from app.models import User, Application
from app.services.deployment_job_service import DeploymentJobService
from app.services.template_service import TemplateService
from app.services.repository_manifest_service import RepositoryManifestService
from app.services.resource_grant_service import ResourceGrantService
from app.utils.slug import validate_app_name
templates_bp = Blueprint('templates', __name__)
# ==================== TEMPLATE BROWSING ====================
@templates_bp.route('/', methods=['GET'])
@jwt_required()
def list_templates():
"""List all available templates."""
category = request.args.get('category')
search = request.args.get('search')
templates = TemplateService.list_all_templates(category=category, search=search)
return jsonify({
'templates': templates,
'count': len(templates)
}), 200
@templates_bp.route('/categories', methods=['GET'])
@jwt_required()
def get_categories():
"""Get all template categories."""
categories = TemplateService.get_categories()
return jsonify({'categories': categories}), 200
@templates_bp.route('/<template_id>', methods=['GET'])
@jwt_required()
def get_template(template_id):
"""Get detailed template information."""
result = TemplateService.get_template(template_id)
if not result.get('success'):
return jsonify(result), 404
template = result['template']
# Add processed variable info - handle both list and dict formats
# Auto-generated types that users don't need to fill in
auto_generated_types = ['port', 'password', 'random', 'uuid']
variables = []
raw_vars = template.get('variables', [])
if isinstance(raw_vars, list):
# New list format: [{name: 'PORT', type: 'port', ...}, ...]
for var in raw_vars:
if isinstance(var, dict):
var_type = var.get('type', 'string')
variables.append({
'name': var.get('name', ''),
'description': var.get('description', ''),
'type': var_type,
'default': var.get('default', ''),
'required': var.get('required', False),
'options': var.get('options', None),
'auto_generated': var_type in auto_generated_types,
'hidden': var_type == 'port' # Ports are hidden from users
})
elif isinstance(raw_vars, dict):
# Old dict format: {PORT: {type: 'port', ...}, ...}
for var_name, var_config in raw_vars.items():
if isinstance(var_config, dict):
var_type = var_config.get('type', 'string')
variables.append({
'name': var_name,
'description': var_config.get('description', ''),
'type': var_type,
'default': var_config.get('default', ''),
'required': var_config.get('required', False),
'options': var_config.get('options', None),
'auto_generated': var_type in auto_generated_types,
'hidden': var_type == 'port'
})
return jsonify({
'template': {
'id': template_id,
'name': template.get('name'),
'version': template.get('version'),
'description': template.get('description'),
'icon': template.get('icon'),
'categories': template.get('categories', []),
'kind': template.get('kind', 'compose'),
'repo': template.get('repo') if template.get('kind') == 'repo' else None,
'source': template.get('source'),
'documentation': template.get('documentation'),
'website': template.get('website'),
'variables': variables,
'has_compose': 'compose' in template,
'has_dockerfile': 'dockerfile' in template,
'scripts': list(template.get('scripts', {}).keys()),
'requirements': template.get('requirements', {}),
'ports': template.get('ports', [])
}
}), 200
@templates_bp.route('/<template_id>/manifest', methods=['GET'])
@jwt_required()
def get_template_manifest(template_id):
"""Inspect a repo template's public repository for deploy manifests.
Fetches the supported manifest files from the repo's raw host (no clone, no
OAuth) and analyzes them. When live inspection turns up nothing (private
repo, host down, no manifests), falls back to the template's declared
``repo`` hints and tags ``source: 'template-hints'`` so the wizard can label
the confidence honestly.
"""
result = TemplateService.get_template(template_id)
if not result.get('success'):
return jsonify(result), 404
template = result['template']
if template.get('kind') != 'repo':
return jsonify({'error': 'Manifest inspection is only available for repo templates'}), 400
repo = template.get('repo') or {}
repo_url = repo.get('url')
branch = request.args.get('branch') or repo.get('branch') or 'main'
manifest = None
if repo_url:
try:
manifest = RepositoryManifestService.analyze_public_repo(repo_url, branch)
except Exception:
manifest = None
# A real inspection detected an actual strategy or at least one manifest file.
if manifest and (manifest.get('strategy') or manifest.get('manifests')):
manifest['source'] = 'repo'
return jsonify({'manifest': manifest, 'source': 'repo'}), 200
# Honest fallback: the declared hints, clearly labeled.
hints = TemplateService.build_template_hints(template)
hints['source'] = 'template-hints'
return jsonify({'manifest': hints, 'source': 'template-hints'}), 200
# ==================== CATALOG SCHEMA ====================
@templates_bp.route('/catalog/schema', methods=['GET'])
@jwt_required()
def catalog_schema():
"""Describe the declarative template catalog schema for the UI.
Returns the supported variable ``type`` values and the auto-resolved
"magic variable" tokens (``${SERVICE_*}``) so the template editor / docs can
surface them without hardcoding the list. Mirrors
``docs/TEMPLATE_CATALOG_SCHEMA.md``.
"""
schema = {
'schema_version': TemplateService.SCHEMA_VERSION,
'variable_types': [
{'type': 'string', 'auto_generated': False,
'description': 'Free-text value; uses default unless required and user-provided.'},
{'type': 'password', 'auto_generated': True,
'description': 'Generated strong secret. Honors length and special_chars.'},
{'type': 'port', 'auto_generated': True,
'description': 'Always auto-assigned to a free host port; never user-supplied.'},
{'type': 'uuid', 'auto_generated': True,
'description': 'Generated UUIDv4.'},
{'type': 'random', 'auto_generated': True,
'description': 'Generated random hex token. Honors length.'},
],
'magic_variables': [
{'token': '${SERVICE_PASSWORD_<NAME>}',
'description': 'Generated strong password, stable per <NAME> within an install.'},
{'token': '${SERVICE_USER_<NAME>}',
'description': 'Generated service username (svc_<name>_<rand>).'},
{'token': '${SERVICE_FQDN_<NAME>}',
'description': "Auto-assigned hostname (<slug>.<base_domain>) when auto_domain is set; "
"falls back to a localhost placeholder."},
{'token': '${SERVICE_URL_<NAME>}',
'description': 'Full URL derived from the FQDN and scheme.'},
{'token': '${SERVICE_BASE64_<NAME>}',
'description': 'Base64 of a freshly generated secret.'},
],
'notes': [
'Magic tokens need no variables: entry; they are resolved at install '
'and persisted to .env / surfaced post-install.',
'<NAME> groups related tokens: the same <NAME> resolves to a consistent value.',
],
}
return jsonify(schema), 200
# ==================== TEMPLATE INSTALLATION ====================
@templates_bp.route('/<template_id>/install', methods=['POST'])
@jwt_required()
@admin_required
def install_template(template_id):
"""Install a template as a new application."""
try:
current_user_id = get_jwt_identity()
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
app_name = data.get('app_name')
if not app_name:
return jsonify({'error': 'app_name is required'}), 400
# Validate app name
valid, error = validate_app_name(app_name, min_length=3)
if not valid:
return jsonify({'error': error}), 400
user_variables = data.get('variables', {})
server_id = data.get('server_id') or data.get('target_server_id')
wait = bool(data.get('wait', False))
result = DeploymentJobService.install_template(
template_id=template_id,
app_name=app_name,
user_variables=user_variables,
user_id=current_user_id,
server_id=server_id,
wait=wait,
)
if not result.get('success'):
return jsonify(result), 400
status = result.get('job', {}).get('status')
return jsonify(result), 201 if status == 'succeeded' else 202
except Exception as e:
import traceback
error_trace = traceback.format_exc()
print(f"Template install error: {error_trace}")
return jsonify({'error': str(e), 'trace': error_trace}), 500
@templates_bp.route('/validate-install', methods=['POST'])
@jwt_required()
def validate_installation():
"""Validate template installation parameters before installing."""
try:
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
template_id = data.get('template_id')
app_name = data.get('app_name')
user_variables = data.get('variables', {})
errors = []
# Validate app name
if not app_name:
errors.append('App name is required')
else:
valid, error = validate_app_name(app_name, min_length=2)
if not valid:
errors.append(error)
# Check if app name is taken
if app_name:
existing = Application.query.filter_by(name=app_name).first()
if existing:
errors.append(f'An application named "{app_name}" already exists')
# Validate template exists and check required variables
if template_id:
result = TemplateService.get_template(template_id)
if not result.get('success'):
errors.append('Template not found')
else:
template = result['template']
raw_vars = template.get('variables', [])
# Handle both list and dict formats
if isinstance(raw_vars, list):
for var in raw_vars:
if isinstance(var, dict) and var.get('required', False):
var_name = var.get('name', '')
if var_name and var_name not in user_variables:
errors.append(f'Required variable "{var_name}" is not provided')
elif isinstance(raw_vars, dict):
for var_name, var_config in raw_vars.items():
if var_config.get('required', False) and var_name not in user_variables:
errors.append(f'Required variable "{var_name}" is not provided')
else:
errors.append('Template ID is required')
if errors:
return jsonify({'valid': False, 'errors': errors}), 400
return jsonify({'valid': True}), 200
except Exception as e:
import traceback
error_trace = traceback.format_exc()
print(f"Validate install error: {error_trace}")
return jsonify({'error': str(e), 'trace': error_trace}), 500
@templates_bp.route('/test-db-connection', methods=['POST'])
@jwt_required()
def test_db_connection():
"""Test database connection before template installation.
Used to validate external database connections for templates
like wordpress-external-db before attempting installation.
Request body:
host: Database host (required)
port: Database port (default: 3306)
user: Database username (required)
password: Database password (required)
database: Database name (required)
Returns:
200: Connection successful
400: Connection failed or missing parameters
"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
required = ['host', 'user', 'password', 'database']
for field in required:
if field not in data:
return jsonify({'error': f'Missing required field: {field}'}), 400
result = TemplateService.validate_mysql_connection(
host=data.get('host'),
port=data.get('port', 3306),
user=data.get('user'),
password=data.get('password'),
database=data.get('database')
)
if result.get('success'):
response = {'success': True, 'message': 'Connection successful'}
if result.get('warning'):
response['warning'] = result.get('warning')
return jsonify(response), 200
else:
return jsonify({
'success': False,
'error': result.get('error')
}), 400
# ==================== APP UPDATES ====================
@templates_bp.route('/apps/<int:app_id>/check-update', methods=['GET'])
@jwt_required()
def check_app_update(app_id):
"""Check if an installed app has updates available."""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query.get(app_id)
if not app:
return jsonify({'error': 'Application not found'}), 404
if not ResourceGrantService.can_access_app(user, app):
return jsonify({'error': 'Access denied'}), 403
result = TemplateService.check_updates(app_id)
return jsonify(result), 200 if result.get('success') else 400
@templates_bp.route('/apps/<int:app_id>/update', methods=['POST'])
@jwt_required()
@admin_required
def update_app(app_id):
"""Update an app to the latest template version."""
current_user_id = get_jwt_identity()
app = Application.query.get(app_id)
if not app:
return jsonify({'error': 'Application not found'}), 404
result = TemplateService.update_app(app_id, user_id=current_user_id)
return jsonify(result), 200 if result.get('success') else 400
@templates_bp.route('/apps/<int:app_id>/template-info', methods=['GET'])
@jwt_required()
def get_app_template_info(app_id):
"""Get template installation info for an app."""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query.get(app_id)
if not app:
return jsonify({'error': 'Application not found'}), 404
if not ResourceGrantService.can_access_app(user, app):
return jsonify({'error': 'Access denied'}), 403
info = TemplateService.get_installed_info(app_id)
if info:
return jsonify({'installed_from_template': True, 'info': info}), 200
return jsonify({'installed_from_template': False}), 200
# ==================== REPOSITORY MANAGEMENT ====================
@templates_bp.route('/repos', methods=['GET'])
@jwt_required()
def list_repositories():
"""List configured template repositories."""
repos = TemplateService.list_repositories()
return jsonify({'repositories': repos}), 200
@templates_bp.route('/repos', methods=['POST'])
@jwt_required()
@admin_required
def add_repository():
"""Add a template repository."""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
name = data.get('name')
url = data.get('url')
if not name or not url:
return jsonify({'error': 'name and url are required'}), 400
result = TemplateService.add_repository(name, url)
return jsonify(result), 201 if result.get('success') else 400
@templates_bp.route('/repos', methods=['DELETE'])
@jwt_required()
@admin_required
def remove_repository():
"""Remove a template repository."""
data = request.get_json()
url = data.get('url') if data else None
if not url:
return jsonify({'error': 'url is required'}), 400
result = TemplateService.remove_repository(url)
return jsonify(result), 200 if result.get('success') else 400
@templates_bp.route('/sync', methods=['POST'])
@jwt_required()
@admin_required
def sync_templates():
"""Sync templates from all repositories."""
result = TemplateService.sync_templates()
return jsonify(result), 200
@templates_bp.route('/repos/index', methods=['GET'])
@jwt_required()
def repo_index():
"""Return the index.json describing the locally-bundled templates.
This is the document a template repository serves at ``<repo>/index.json``;
exposing it lets this instance act as (or seed) a community template repo.
"""
return jsonify(TemplateService.build_repo_index()), 200
@templates_bp.route('/repos/index/export', methods=['POST'])
@jwt_required()
@admin_required
def export_repo_index():
"""Write index.json next to the bundled templates so the directory can be
published as a repository."""
result = TemplateService.export_repo_index()
return jsonify(result), 200 if result.get('success') else 400
# ==================== LOCAL TEMPLATE MANAGEMENT ====================
@templates_bp.route('/local', methods=['POST'])
@jwt_required()
@admin_required
def create_local_template():
"""Create a local template."""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
result = TemplateService.create_local_template(data)
return jsonify(result), 201 if result.get('success') else 400
@templates_bp.route('/local/<template_id>', methods=['DELETE'])
@jwt_required()
@admin_required
def delete_local_template(template_id):
"""Delete a local template."""
result = TemplateService.delete_local_template(template_id)
return jsonify(result), 200 if result.get('success') else 404