Files
wfalkowski 6732ee9d47
Build and publish Docker image / build-and-publish (push) Successful in 8s
Fix Kubernetes namespaces for dotted usernames
2026-10-20 19:38:31 +00:00

498 lines
19 KiB
Python

"""Servers API routes for AnchorHost."""
from datetime import datetime
from flask import Blueprint, jsonify, request
import json
from urllib.parse import urlsplit
from kubernetes_service import k8s_manager
from namespace_utils import user_namespace
import requests
from werkzeug.utils import secure_filename
from runtime_config import (
MODRINTH_API_BASE,
logger,
)
from server_access import expose_port, remove_port
from server_capacity import get_cluster_capacity, validate_server_capacity
from server_lifecycle import adjust_server_parameters
from server_provisioning import reinstall_server, sync_server_ports_from_k8s, sync_server_status_from_k8s
from server_records import get_server_by_id, get_user_servers
from user_service import add_credits, authenticate_user, get_bearer_token, get_user_credits, require_admin
__all__ = ['add_credits_api', 'adjust_server_parameters_api', 'check_capacity_api', 'download_plugin_to_server', 'expose_port_api', 'get_cluster_capacity_api', 'get_modrinth_project', 'get_modrinth_tags', 'get_modrinth_version', 'get_modrinth_versions', 'reinstall_server_api', 'remove_port_api', 'search_modrinth_plugins', 'sync_server_status_api']
bp = Blueprint("servers", __name__)
@bp.route('/api/capacity/check', methods=['POST'])
def check_capacity_api():
"""API endpoint to check capacity for a server configuration"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'success': False, 'error': 'Unauthorized'}), 401
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'No configuration provided'}), 400
# Validate required fields
required_fields = ['cpu', 'ram', 'storage', 'serverType']
for field in required_fields:
if field not in data:
return jsonify({'success': False, 'error': f'Missing field: {field}'}), 400
# Run capacity validation
capacity_result = validate_server_capacity(data)
# Also get cluster overview
cluster_capacity = get_cluster_capacity()
# Extract useful information for frontend
response_data = {
'success': True,
'capacity_check': {
'success': capacity_result.get('success', False),
'message': capacity_result.get('message', ''),
'suitable_nodes': capacity_result.get('suitable_nodes', []),
'node_details': capacity_result.get('node_details', []),
'details': capacity_result.get('details', []),
'error_details': capacity_result.get('error_details', []),
'summary': capacity_result.get('summary', {})
},
'cluster_capacity': cluster_capacity,
'request': {
'cpu': data['cpu'],
'ram': data['ram'],
'storage': data['storage'],
'server_type': data['serverType']
}
}
return jsonify(response_data), 200
except Exception as e:
logger.error(f"Error in capacity check API: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/capacity/cluster', methods=['GET'])
def get_cluster_capacity_api():
"""API endpoint to get cluster capacity overview"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'success': False, 'error': 'Unauthorized'}), 401
capacity = get_cluster_capacity()
return jsonify(capacity), 200 if capacity['success'] else 500
except Exception as e:
logger.error(f"Error getting cluster capacity: {e}")
return jsonify({
'success': False,
'error': str(e),
'timestamp': datetime.now().isoformat()
}), 500
@bp.route('/api/servers/<server_id>/sync', methods=['POST'])
def sync_server_status_api(server_id):
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
sync_server_status_from_k8s(server_id, user['id'])
sync_server_ports_from_k8s(server_id, user['id'])
server = get_server_by_id(server_id)
if not server:
return jsonify({'error': 'Server not found'}), 404
return jsonify({
'success': True,
'server': server
}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/servers/<server_id>/reinstall', methods=['POST'])
def reinstall_server_api(server_id):
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
success = reinstall_server(server_id, user['id'])
if success:
return jsonify({
'success': True,
'message': 'Server reinstall started'
}), 200
else:
return jsonify({'error': 'Failed to reinstall server'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/users/<user_id>/credits', methods=['POST'])
def add_credits_api(user_id):
try:
if not require_admin():
return jsonify({'error': 'Unauthorized'}), 403
data = request.get_json(silent=True) or {}
amount = data.get('amount')
if isinstance(amount, bool) or not isinstance(amount, int) or amount <= 0:
return jsonify({'error': 'Invalid amount'}), 400
success = add_credits(user_id, amount)
if success:
return jsonify({
'success': True,
'message': f'Added {amount} credits',
'new_balance': get_user_credits(user_id)
}), 200
else:
return jsonify({'error': 'Failed to add credits'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/modrinth/search', methods=['GET'])
def search_modrinth_plugins():
"""Search for plugins on Modrinth"""
try:
query = request.args.get('query', '')
limit = int(request.args.get('limit', 20))
offset = int(request.args.get('offset', 0))
project_type = request.args.get('project_type', 'plugin') # plugin, mod, modpack, etc.
loaders = request.args.get('loaders', '') # paper, spigot, bukkit, etc.
game_versions = request.args.get('game_versions', '')
# Build facets array for Modrinth API
# Facets format: [['category:value1', 'category:value2'], ['category2:value3']]
# Each inner array represents OR conditions, outer array represents AND conditions
facets = [['project_type:' + project_type]]
if loaders:
loader_list = [loader.strip() for loader in loaders.split(',') if loader.strip()]
if loader_list:
# Create a single array with all loader filters (OR condition)
loader_facets = ['loaders:' + loader for loader in loader_list]
facets.append(loader_facets)
if game_versions:
version_list = [version.strip() for version in game_versions.split(',') if version.strip()]
if version_list:
# Create a single array with all version filters (OR condition)
version_facets = ['versions:' + version for version in version_list]
facets.append(version_facets)
# Build query parameters
params = {
'query': query,
'limit': limit,
'offset': offset,
'facets': json.dumps(facets)
}
response = requests.get(f'{MODRINTH_API_BASE}/search', params=params, headers={
'User-Agent': 'AnchorHost/1.0 (contact@anchorhost.eu)'
})
if response.status_code == 200:
return jsonify(response.json()), 200
else:
return jsonify({'error': 'Failed to search Modrinth', 'status': response.status_code}), response.status_code
except Exception as e:
logger.error(f"Error searching Modrinth: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/modrinth/project/<project_id>', methods=['GET'])
def get_modrinth_project(project_id):
"""Get project details from Modrinth"""
try:
response = requests.get(
f'{MODRINTH_API_BASE}/project/{project_id}',
headers={'User-Agent': 'AnchorHost/1.0 (contact@anchorhost.eu)'}
)
if response.status_code == 200:
return jsonify(response.json()), 200
else:
return jsonify({'error': 'Project not found', 'status': response.status_code}), response.status_code
except Exception as e:
logger.error(f"Error fetching Modrinth project: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/modrinth/project/<project_id>/versions', methods=['GET'])
def get_modrinth_versions(project_id):
"""Get versions for a Modrinth project"""
try:
game_versions = request.args.get('game_versions', '')
loaders = request.args.get('loaders', '')
params = {}
if game_versions:
params['game_versions'] = game_versions.split(',')
if loaders:
params['loaders'] = loaders.split(',')
response = requests.get(
f'{MODRINTH_API_BASE}/project/{project_id}/version',
params=params,
headers={'User-Agent': 'AnchorHost/1.0 (contact@anchorhost.eu)'}
)
if response.status_code == 200:
return jsonify(response.json()), 200
else:
return jsonify({'error': 'Failed to fetch versions', 'status': response.status_code}), response.status_code
except Exception as e:
logger.error(f"Error fetching Modrinth versions: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/modrinth/project/<project_id>/version/<version_id>', methods=['GET'])
def get_modrinth_version(project_id, version_id):
"""Get specific version details"""
try:
response = requests.get(
f'{MODRINTH_API_BASE}/version/{version_id}',
headers={'User-Agent': 'AnchorHost/1.0 (contact@anchorhost.eu)'}
)
if response.status_code == 200:
return jsonify(response.json()), 200
else:
return jsonify({'error': 'Version not found', 'status': response.status_code}), response.status_code
except Exception as e:
logger.error(f"Error fetching Modrinth version: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/modrinth/tags', methods=['GET'])
def get_modrinth_tags():
"""Get available tags/categories from Modrinth"""
try:
response = requests.get(
f'{MODRINTH_API_BASE}/tag',
headers={'User-Agent': 'AnchorHost/1.0 (contact@anchorhost.eu)'}
)
if response.status_code == 200:
return jsonify(response.json()), 200
else:
return jsonify({'error': 'Failed to fetch tags', 'status': response.status_code}), response.status_code
except Exception as e:
logger.error(f"Error fetching Modrinth tags: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/plugins/download-to-server', methods=['POST'])
def download_plugin_to_server():
"""Download a plugin from URL and upload it to a server's plugins directory"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Not authenticated'}), 401
data = request.get_json()
server_id = data.get('server_id')
plugin_url = data.get('plugin_url')
plugin_name = data.get('plugin_name', 'plugin')
file_name = data.get('file_name', 'plugin.jar')
if not server_id or not plugin_url:
return jsonify({'error': 'Server ID and plugin URL are required'}), 400
try:
parsed_plugin_url = urlsplit(plugin_url)
plugin_port = parsed_plugin_url.port
except (TypeError, ValueError):
return jsonify({'error': 'Plugin URL is invalid'}), 400
if (parsed_plugin_url.scheme != 'https'
or parsed_plugin_url.hostname != 'cdn.modrinth.com'
or parsed_plugin_url.username or parsed_plugin_url.password
or plugin_port not in (None, 443)):
return jsonify({'error': 'Plugin files must come from the Modrinth CDN'}), 400
safe_file_name = secure_filename(str(file_name or 'plugin.jar'))
if not safe_file_name.lower().endswith('.jar'):
return jsonify({'error': 'Plugin file name must end in .jar'}), 400
# Verify server belongs to user
user_servers = get_user_servers(user['id'])
server = next((s for s in user_servers if s['id'] == server_id), None)
if not server:
return jsonify({'error': 'Server not found or access denied'}), 404
# Check if server is Minecraft server
if server.get('image') != 'minecraft' or server.get('type') != 'game':
return jsonify({'error': 'Plugin downloads are only available for Minecraft game servers'}), 400
# Download plugin file
logger.info(f"Downloading plugin from {plugin_url} for server {server_id}")
plugin_response = requests.get(
plugin_url, stream=True, timeout=30, allow_redirects=False
)
if plugin_response.status_code != 200:
plugin_response.close()
return jsonify({'error': f'Failed to download plugin: HTTP {plugin_response.status_code}'}), 500
# Determine plugins directory based on server type
# For Minecraft servers, plugins go in /data/plugins directory
plugins_dir = '/data/plugins'
file_path = f'{plugins_dir}/{safe_file_name}'
# Save plugin to temporary file first
import tempfile
import os
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.jar')
temp_file_path = temp_file.name
try:
with temp_file:
try:
total_bytes = 0
for chunk in plugin_response.iter_content(chunk_size=8192):
if chunk:
total_bytes += len(chunk)
if total_bytes > 100 * 1024 * 1024:
raise ValueError('Plugin file exceeds the 100 MB limit')
temp_file.write(chunk)
finally:
plugin_response.close()
# Upload to server
namespace = user_namespace(user['username'])
pods = k8s_manager.get_pods_for_server(namespace, server_id)
if not pods:
return jsonify({'error': 'Server pod not found'}), 404
# Ensure plugins directory exists
k8s_manager.exec_command(namespace, pods[0], ['mkdir', '-p', plugins_dir])
# Upload file using write_file_binary_from_disk
success = k8s_manager.write_file_binary_from_disk(namespace, pods[0], file_path, temp_file_path)
if success:
logger.info(f"Plugin {file_name} successfully uploaded to server {server_id}")
return jsonify({
'success': True,
'message': f'Plugin {plugin_name} downloaded and installed successfully',
'file_path': file_path
}), 200
else:
return jsonify({'error': 'Failed to upload plugin to server'}), 500
finally:
# Clean up temporary file
try:
if os.path.exists(temp_file_path):
os.remove(temp_file_path)
except Exception as e:
logger.warning(f"Failed to remove temporary file: {e}")
except requests.exceptions.RequestException as e:
logger.error(f"Error downloading plugin: {e}")
return jsonify({'error': f'Failed to download plugin: {str(e)}'}), 500
except Exception as e:
logger.error(f"Error downloading plugin to server: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/servers/<server_id>/ports', methods=['POST'])
def expose_port_api(server_id):
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
internal_port = data.get('internalPort')
protocol = data.get('protocol', 'TCP')
if not internal_port:
return jsonify({'error': 'Internal port required'}), 400
success = expose_port(server_id, user['id'], internal_port, protocol)
if success:
return jsonify({
'success': True,
'message': f'Port {internal_port} exposed successfully'
}), 200
else:
return jsonify({'error': 'Failed to expose port'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/servers/<server_id>/ports/<int:internal_port>', methods=['DELETE'])
def remove_port_api(server_id, internal_port):
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json() or {}
protocol = data.get('protocol', 'TCP')
success = remove_port(server_id, user['id'], internal_port, protocol)
if success:
return jsonify({
'success': True,
'message': f'Port {internal_port}/{protocol} removed successfully'
}), 200
else:
return jsonify({'error': 'Failed to remove port'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/servers/<server_id>/adjust-parameters', methods=['POST'])
def adjust_server_parameters_api(server_id):
"""API endpoint to adjust server parameters (upgrade/downgrade)"""
try:
token = get_bearer_token()
user = authenticate_user(token)
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
if not data:
return jsonify({'error': 'Request body required'}), 400
new_cpu = data.get('cpu')
new_ram = data.get('ram')
new_storage = data.get('storage')
if not new_cpu or not new_ram or not new_storage:
return jsonify({'error': 'cpu, ram, and storage are required'}), 400
result = adjust_server_parameters(server_id, user['id'], new_cpu, new_ram, new_storage)
return jsonify(result), 200
except Exception as e:
return jsonify({'error': str(e)}), 500