Refactor backend and add registry build workflow
Build and publish Docker image / build-and-publish (push) Successful in 2m45s
Build and publish Docker image / build-and-publish (push) Successful in 2m45s
This commit is contained in:
commit
c177d7981d
43 files changed
+18124
No files matched your search
@@ -0,0 +1,468 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user