469 lines
17 KiB
Python
469 lines
17 KiB
Python
"""Servers API routes for AnchorHost."""
|
|
|
|
|
|
from datetime import datetime
|
|
from flask import Blueprint, jsonify, request
|
|
import json
|
|
from kubernetes_service import k8s_manager
|
|
import requests
|
|
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
|
|
|
|
# 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)
|
|
|
|
if plugin_response.status_code != 200:
|
|
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}/{file_name}'
|
|
|
|
# Save plugin to temporary file first
|
|
import tempfile
|
|
import os
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.jar') as temp_file:
|
|
temp_file_path = temp_file.name
|
|
for chunk in plugin_response.iter_content(chunk_size=8192):
|
|
if chunk:
|
|
temp_file.write(chunk)
|
|
|
|
try:
|
|
# Upload to server
|
|
namespace = f"user-{user['username']}".lower()
|
|
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
|