"""Server provisioning operations.""" from database_service import database_connection from datetime import datetime, timedelta, timezone import json from kubernetes import client from kubernetes_service import apps_v1, custom_objects_api, k8s_manager, v1 import random import re from runtime_config import ( NODE_IP, logger, ) from server_capacity import calculate_server_cost, validate_server_capacity from server_demo import check_demo_eligibility, mark_demo_used from server_records import create_server_record, get_server_access_and_namespace, get_server_by_id, update_server_status import threading import time from typing import Dict, List, Optional, Tuple import urllib.parse from user_service import add_credit_transaction, get_user_by_id, update_user_credits import uuid __all__ = ['change_server_version', 'convert_storage_to_k8s_format', 'create_database_server', 'create_game_server', 'create_server', 'create_vps_server', 'create_website_server', 'create_windows_vps', 'extract_server_from_url', 'extract_token_from_url', 'generate_sftp_credentials', 'get_db_mount_path', 'get_default_ports', 'get_minecraft_image', 'get_sftp_sidecar_container', 'get_storage_size', 'reinstall_server', 'save_server_creation_date', 'sync_server_ports_from_k8s', 'sync_server_status_from_k8s', 'validate_server_name', 'wait_and_sync_pod_status', 'wait_and_sync_vm_status'] def change_server_version(server_id: str, user_id: str, new_version: str, java_version: str = None, loader: str = None) -> bool: """Change server version by reinstalling the server with new version (free of charge)""" try: # Verify user exists user = get_user_by_id(user_id) if not user: raise Exception("User not found") # Verify server ownership server = get_server_by_id(server_id) if not server: raise Exception("Server not found") server_owner_id = str(server.get('owner_id') or server.get('ownerId', '')) req_id = str(user_id) if server_owner_id != req_id and user.get('role') != 'admin': raise Exception("Unauthorized: You don't own this server") # Get owner for namespace determination if server_owner_id == req_id: owner = user else: owner = get_user_by_id(server_owner_id) if not owner: raise Exception("Server owner not found") # Prohibit version changes for demo servers if server.get('is_demo') == True or server.get('is_demo') == 1: raise Exception("Version changes are not allowed for demo servers") # Use provided values or fallback to current ones, or defaults if not set final_java_version = java_version if java_version else server.get('java_version') final_loader = loader if loader else server.get('loader') # Only reinstall game servers (especially Minecraft) if server['type'] != 'game' or server.get('image') != 'minecraft': # For non-game servers or non-Minecraft servers, just update the database with database_connection() as conn: cursor = conn.cursor() cursor.execute( 'UPDATE servers SET version = ?, java_version = ?, loader = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (new_version, final_java_version, final_loader, server_id) ) conn.commit() logger.info(f"✓ Changed version for server {server_id} to {new_version} (billing date unchanged)") return cursor.rowcount > 0 namespace = f"user-{owner['username']}".lower() if not k8s_manager.connected: # If K8s not connected, just update database with database_connection() as conn: cursor = conn.cursor() cursor.execute( 'UPDATE servers SET version = ?, java_version = ?, loader = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (new_version, final_java_version, final_loader, server_id) ) conn.commit() logger.info(f"✓ Changed version for server {server_id} to {new_version} (K8s not connected, DB only)") return cursor.rowcount > 0 # Get ports from server config (parse from JSON string if needed) ports = server.get('ports', []) if isinstance(ports, str): try: ports = json.loads(ports) except: ports = [] if not ports: ports = get_default_ports('game', server.get('image', 'minecraft')) # Delete old deployment try: apps_v1.delete_namespaced_deployment(name=server_id, namespace=namespace) logger.info(f"✓ Deleted old deployment for server {server_id}") # Wait a bit for deletion to complete time.sleep(2) except Exception as e: logger.warning(f"Could not delete old deployment (may not exist): {e}") # Recreate deployment with new version if server.get('image') == 'minecraft': container_image = get_minecraft_image(new_version, final_java_version) else: image_map = { 'fivem': 'fivem/fivem:latest', 'cs2': 'cm2network/cs2:latest', 'rust': 'cm2network/rust:latest', 'ark': 'cm2network/arkserver:latest', 'valheim': 'lloesche/valheim-server:latest' } container_image = image_map.get(server.get('image', 'minecraft'), 'itzg/minecraft-server:latest') # Game-specific environment variables with new version env_vars = [] if server.get('image') == 'minecraft': # Use the allocated RAM for Minecraft JVM memory_val = server.get('ram', '3GB').replace('GB', 'G') # Determine server type (loader) server_type_env = 'PAPER' if final_loader: server_type_env = final_loader.upper() version_val = new_version if server_type_env == 'PAPER' and version_val == '26.1': version_val = '26.1.2' env_vars = [ client.V1EnvVar(name='EULA', value='TRUE'), client.V1EnvVar(name='TYPE', value=server_type_env), client.V1EnvVar(name='VERSION', value=version_val), client.V1EnvVar(name='MEMORY', value=memory_val) ] elif server.get('image') == 'fivem': env_vars = [ client.V1EnvVar(name='FIVEM_LICENSE_KEY', value='your-license-key-here') ] # Get PVC name (should already exist) pvc_name = f"{server_id}-data" # Calculate resources try: cpu_cores = int(server['cpu'].split()[0]) ram_gb = int(server['ram'].replace('GB', '')) memory_limit = f"{ram_gb}Gi" memory_request = f"{ram_gb * 0.5}Gi" except: cpu_cores = 1 memory_limit = "2Gi" memory_request = "1Gi" # Create new deployment with updated version deployment = client.V1Deployment( metadata=client.V1ObjectMeta(name=server_id, namespace=namespace), spec=client.V1DeploymentSpec( replicas=1, selector=client.V1LabelSelector(match_labels={'app': server_id}), template=client.V1PodTemplateSpec( metadata=client.V1ObjectMeta(labels={'app': server_id}), spec=client.V1PodSpec( containers=[ client.V1Container( name=server_id, image=container_image, ports=[client.V1ContainerPort(container_port=p['internal']) for p in ports], env=env_vars, volume_mounts=[client.V1VolumeMount(name='data', mount_path='/data')], resources=client.V1ResourceRequirements( requests={ 'cpu': f'{cpu_cores * 50}m', 'memory': memory_request }, limits={ 'cpu': f'{cpu_cores * 1000}m', 'memory': memory_limit } ) ) ], volumes=[client.V1Volume(name='data', persistent_volume_claim=client.V1PersistentVolumeClaimVolumeSource(claim_name=pvc_name))] ) ) ) ) apps_v1.create_namespaced_deployment(namespace=namespace, body=deployment) logger.info(f"✓ Created new deployment for server {server_id} with version {new_version}, type {final_loader}, java {final_java_version}") # Update database version with database_connection() as conn: cursor = conn.cursor() cursor.execute( 'UPDATE servers SET version = ?, java_version = ?, loader = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (new_version, final_java_version, final_loader, server_id) ) conn.commit() logger.info(f"✓ Changed version for server {server_id} to {new_version} (server reinstalled, billing date unchanged)") return cursor.rowcount > 0 except Exception as e: logger.error(f"Error changing server version: {e}", exc_info=True) raise def sync_server_status_from_k8s(server_id: str, user_id: str) -> bool: """Sync server status from Kubernetes""" try: namespace, server = get_server_access_and_namespace(server_id, user_id) if not k8s_manager.connected: return True if server['type'] == 'vps': vm_status = k8s_manager.get_vm_status(namespace, server['k8s_name']) phase = vm_status.get('phase', 'Unknown') ready = vm_status.get('ready', False) run_strategy = vm_status.get('runStrategy', 'Always') # Check if VM is stopped (Halted) if run_strategy == 'Halted' or phase == 'Stopped': new_status = 'Stopped' message = 'VM is stopped' elif phase == 'Running' and ready: new_status = 'Running' message = 'VM is running and ready' elif phase == 'Scheduling': new_status = 'Starting' message = 'VM is being scheduled' elif phase == 'Pending': new_status = 'Creating' message = 'VM is pending creation' else: new_status = 'Error' message = f'VM in unexpected state: {phase}' else: pods = k8s_manager.get_pods_for_server(namespace, server_id) if pods: # Default to Running, but check if any pod is still creating new_status = 'Running' message = 'Pod is running' # If we have multiple pods (less common for game servers but possible), # check if all of them are actually Running and Ready for pod_name in pods: try: pod = v1.read_namespaced_pod(name=pod_name, namespace=namespace) if pod.status.phase != 'Running': new_status = 'Creating' message = f'Pod is {pod.status.phase}' break # Check container statuses for ContainerCreating if pod.status.container_statuses: for container_status in pod.status.container_statuses: if not container_status.ready: new_status = 'Creating' if container_status.state.waiting: message = f'Container: {container_status.state.waiting.reason}' else: message = 'Container is not ready' break if new_status == 'Creating': break except Exception as e: logger.warning(f"Error checking pod status for {pod_name}: {e}") new_status = 'Creating' message = 'Checking pod status...' break else: new_status = 'Creating' message = 'Waiting for pod to start' if server['status'] != new_status: update_server_status(server_id, new_status, message) return True except Exception as e: return False def sync_server_ports_from_k8s(server_id: str, user_id: str) -> bool: """Sync server ports from Kubernetes - FIXED VERSION""" try: namespace, server = get_server_access_and_namespace(server_id, user_id) if not k8s_manager.connected: return True service_name = f"{server_id}-service" try: service_ports = k8s_manager.get_service_ports(namespace, service_name) logger.info(f"Retrieved service ports for {service_name}: {service_ports}") # Update ports with actual node_port values from Kubernetes updated_ports = [] for port_config in server['ports']: # Find matching port from Kubernetes service matching_port = next((p for p in service_ports if p['port'] == port_config['internal']), None) if matching_port and matching_port.get('node_port'): # Use the actual node_port assigned by Kubernetes port_config['external'] = matching_port['node_port'] logger.info(f"Updated port {port_config['internal']} -> external: {port_config['external']}") else: # Keep existing external port if no match found logger.warning(f"No matching port found for internal port {port_config['internal']}") updated_ports.append(port_config) # Update database with corrected ports with database_connection() as conn: cursor = conn.cursor() cursor.execute( 'UPDATE servers SET ports = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (json.dumps(updated_ports), server_id) ) conn.commit() logger.info(f"Successfully synced ports for server {server_id}: {updated_ports}") return True except Exception as e: logger.error(f"Error syncing ports for server {server_id}: {e}") return False except Exception as e: logger.error(f"Error in sync_server_ports_from_k8s: {e}") return False def reinstall_server(server_id: str, user_id: str) -> bool: """Reinstall server (redeploys container/VM)""" try: # Step 1: Verify server exists server = get_server_by_id(server_id) if not server: raise Exception('Server not found') # Step 2: Get user user = get_user_by_id(user_id) if not user: raise Exception('User not found') # Verify server ownership or admin access server_owner_id = str(server.get('owner_id') or server.get('ownerId', '')) req_id = str(user_id) if server_owner_id != req_id and user.get('role') != 'admin': raise Exception("Unauthorized: You don't own this server") # Get owner for namespace determination if server_owner_id == req_id: owner = user else: owner = get_user_by_id(server_owner_id) if not owner: raise Exception("Server owner not found") namespace = f"user-{owner['username']}".lower() if not k8s_manager.connected: update_server_status(server_id, 'Running') logger.info(f"✓ Reinstalled server {server_id} (K8s not connected, just status update)") return True # Use change_server_version logic if possible (it handles deployment recreation) if server['type'] == 'game' and server.get('image'): # Reuse current version return change_server_version(server_id, user_id, server['version']) # For other types, force pod deletion (soft reinstall) try: # Delete pods pods = k8s_manager.get_pods_for_server(namespace, server_id) if pods: for pod in pods: v1.delete_namespaced_pod(name=pod, namespace=namespace) # Ensure it starts up again update_server_status(server_id, 'Restarting') # Wait a bit then check threading.Thread( target=wait_and_sync_vm_status if server['type'] == 'vps' else wait_and_sync_pod_status, args=(server_id, owner['id'], namespace), daemon=True ).start() return True except Exception as e: logger.error(f"Error during soft reinstall: {e}") return False except Exception as e: logger.error(f"Error reinstalling server: {e}") raise Exception(f'Failed to reinstall server: {str(e)}') def extract_token_from_url(path: str) -> Optional[str]: try: if '?' in path: query_string = path.split('?', 1)[1] query_params = urllib.parse.parse_qs(query_string) token_list = query_params.get('token') if token_list and len(token_list) > 0: return token_list[0] return None except Exception: return None def extract_server_from_url(path: str) -> Optional[str]: try: if '?' in path: query_string = path.split('?', 1)[1] query_params = urllib.parse.parse_qs(query_string) server_list = query_params.get('server') if server_list and len(server_list) > 0: return server_list[0] return None except Exception: return None def convert_storage_to_k8s_format(storage: str) -> str: """Convert storage format""" if storage.endswith('GB'): return storage.replace('GB', 'Gi') elif storage.endswith('G'): return storage.replace('G', 'Gi') elif storage.endswith('Gi'): return storage return storage def get_default_ports(server_type: str, image: str) -> List[Dict]: """Get default ports with actual external port numbers""" external_port = random.randint(30000, 32768) if server_type == 'website': return [{'internal': 80, 'external': external_port, 'protocol': 'TCP', 'name': 'http'}] elif server_type == 'game': if image == 'minecraft': return [{'internal': 25565, 'external': external_port, 'protocol': 'TCP', 'name': 'minecraft'}] if image == 'fivem': return [{'internal': 30120, 'external': external_port, 'protocol': 'TCP', 'name': 'fivem'}] if image == 'cs2': return [{'internal': 27015, 'external': external_port, 'protocol': 'TCP', 'name': 'cs2'}] if image == 'rust': return [ {'internal': 28015, 'external': external_port, 'protocol': 'UDP', 'name': 'rust-game'}, {'internal': 28016, 'external': external_port + 1, 'protocol': 'TCP', 'name': 'rust-rcon'} ] if image == 'hytale': return [{'internal': 5520, 'external': external_port, 'protocol': 'UDP', 'name': 'hytale'}] if image == 'unturned': return [ {'internal': 27015, 'external': external_port, 'protocol': 'UDP', 'name': 'unturned-game'}, {'internal': 27016, 'external': external_port + 1, 'protocol': 'UDP', 'name': 'unturned-query'} ] if image == 'zomboid': return [ {'internal': 16261, 'external': external_port, 'protocol': 'UDP', 'name': 'zomboid-game'}, {'internal': 8766, 'external': external_port + 1, 'protocol': 'UDP', 'name': 'zomboid-steam1'}, {'internal': 8767, 'external': external_port + 2, 'protocol': 'UDP', 'name': 'zomboid-steam2'} ] if image == 'factorio': return [ {'internal': 34197, 'external': external_port, 'protocol': 'UDP', 'name': 'factorio-game'}, {'internal': 27015, 'external': external_port + 1, 'protocol': 'TCP', 'name': 'factorio-rcon'} ] if image == 'ark': return [{'internal': 7777, 'external': external_port, 'protocol': 'TCP', 'name': 'ark'}] if image == 'valheim': return [{'internal': 2456, 'external': external_port, 'protocol': 'TCP', 'name': 'valheim'}] return [{'internal': 27015, 'external': external_port, 'protocol': 'TCP', 'name': 'game'}] elif server_type == 'vps': if image == 'windows': return [{'internal': 3389, 'external': external_port, 'protocol': 'TCP', 'name': 'rdp'}] return [{'internal': 22, 'external': external_port, 'protocol': 'TCP', 'name': 'ssh'}] elif server_type == 'database': if image == 'mysql': return [{'internal': 3306, 'external': external_port, 'protocol': 'TCP', 'name': 'mysql'}] if image == 'postgresql': return [{'internal': 5432, 'external': external_port, 'protocol': 'TCP', 'name': 'postgresql'}] if image == 'mongodb': return [{'internal': 27017, 'external': external_port, 'protocol': 'TCP', 'name': 'mongodb'}] if image == 'redis': return [{'internal': 6379, 'external': external_port, 'protocol': 'TCP', 'name': 'redis'}] return [{'internal': 3306, 'external': external_port, 'protocol': 'TCP', 'name': 'database'}] return [] def get_storage_size(server_type: str, image: str) -> str: if server_type == 'vps': if image == 'windows': return '60Gi' if image == 'alpine': return '10Gi' return '20Gi' elif server_type == 'game': if image == 'minecraft': return '10Gi' if image == 'fivem': return '15Gi' if image == 'cs2': return '20Gi' if image == 'rust': return '25Gi' if image == 'hytale': return '10Gi' if image == 'unturned': return '15Gi' if image == 'zomboid': return '20Gi' if image == 'factorio': return '10Gi' if image == 'ark': return '30Gi' if image == 'valheim': return '15Gi' return '10Gi' elif server_type == 'website': if image == 'wordpress-sqlite': return '10Gi' return '5Gi' return '10Gi' def generate_sftp_credentials() -> Tuple[str, str, int]: """Generate random credentials and port for SFTP""" username = "ftp-user" # Generate random 12-character alphanumeric password to avoid shell/format issues import string chars = string.ascii_letters + string.digits password = ''.join(random.choice(chars) for _ in range(12)) # Generate random node port for SFTP (in standard range) sftp_port = random.randint(30000, 32768) return username, password, sftp_port def get_sftp_sidecar_container(server_id: str, username: str, password: str, mount_path: str) -> client.V1Container: """Create SFTP sidecar container specification using Alpine and non-root UID""" # atmoz/sftp format: user:pass:uid:gid:dir # Using UID 1000 as it is the standard non-root user in most containers # and allows SSH login (UID 0 is often blocked by sshd) sftp_config = f"{username}:{password}:1000:1000:data" return client.V1Container( name="sftp", image="atmoz/sftp:alpine", ports=[client.V1ContainerPort(container_port=22)], env=[ client.V1EnvVar(name='SFTP_USERS', value=sftp_config) ], volume_mounts=[ client.V1VolumeMount( name='data', mount_path=f'/home/{username}/data' ) ], security_context=client.V1SecurityContext( capabilities=client.V1Capabilities( add=["SYS_CHROOT", "CHOWN", "SETGID", "SETUID"] ), run_as_user=0, # Entrypoint must run as root to setup chroot privileged=False ), resources=client.V1ResourceRequirements( requests={'cpu': '10m', 'memory': '32Mi'}, limits={'cpu': '100m', 'memory': '128Mi'} ) ) def create_database_server(config: Dict, user_id: str, server_id: str, namespace: str) -> Dict: """Create a Database Server with FIXED port handling""" storage_size = convert_storage_to_k8s_format(get_storage_size('database', config['image'])) pvc_name = f"{server_id}-data" if not k8s_manager.create_pvc(namespace, pvc_name, storage_size): raise Exception("Failed to create PVC") image_map = { 'mysql': 'mysql:8.0', 'postgresql': 'postgres:15', 'mongodb': 'mongo:7.0', 'redis': 'redis:7.2' } container_image = image_map.get(config['image'], image_map['mysql']) ports = config.get('ports') or get_default_ports('database', config['image']) # Database-specific environment variables env_vars = [] if config['image'] == 'mysql': env_vars = [ client.V1EnvVar(name='MYSQL_ROOT_PASSWORD', value=config.get('databaseConfig', {}).get('password', 'anchorhost123')), client.V1EnvVar(name='MYSQL_DATABASE', value=config.get('databaseConfig', {}).get('databaseName', 'anchorhost_db')), client.V1EnvVar(name='MYSQL_USER', value=config.get('databaseConfig', {}).get('username', 'admin')), client.V1EnvVar(name='MYSQL_PASSWORD', value=config.get('databaseConfig', {}).get('password', 'anchorhost123')) ] elif config['image'] == 'postgresql': env_vars = [ client.V1EnvVar(name='POSTGRES_DB', value=config.get('databaseConfig', {}).get('databaseName', 'anchorhost_db')), client.V1EnvVar(name='POSTGRES_USER', value=config.get('databaseConfig', {}).get('username', 'admin')), client.V1EnvVar(name='POSTGRES_PASSWORD', value=config.get('databaseConfig', {}).get('password', 'anchorhost123')) ] elif config['image'] == 'mongodb': env_vars = [ client.V1EnvVar(name='MONGO_INITDB_ROOT_USERNAME', value=config.get('databaseConfig', {}).get('username', 'admin')), client.V1EnvVar(name='MONGO_INITDB_ROOT_PASSWORD', value=config.get('databaseConfig', {}).get('password', 'anchorhost123')) ] elif config['image'] == 'redis': env_vars = [ client.V1EnvVar(name='REDIS_PASSWORD', value=config.get('databaseConfig', {}).get('password', 'anchorhost123')) ] # Calculate resources cpu_cores = int(config['cpu'].split()[0]) ram_gb = int(config['ram'].replace('GB', '')) memory_limit = f"{ram_gb}Gi" memory_request = f"{ram_gb * 0.5}Gi" # Generate SFTP credentials and port sftp_user, sftp_pass, sftp_ext_port = generate_sftp_credentials() sftp_container = get_sftp_sidecar_container(server_id, sftp_user, sftp_pass, get_db_mount_path(config['image'])) deployment = client.V1Deployment( metadata=client.V1ObjectMeta(name=server_id, namespace=namespace), spec=client.V1DeploymentSpec( replicas=1, selector=client.V1LabelSelector(match_labels={'app': server_id}), template=client.V1PodTemplateSpec( metadata=client.V1ObjectMeta(labels={'app': server_id}), spec=client.V1PodSpec( containers=[ client.V1Container( name=server_id, image=container_image, ports=[client.V1ContainerPort(container_port=p['internal']) for p in ports], env=env_vars, volume_mounts=[client.V1VolumeMount(name='data', mount_path=get_db_mount_path(config['image']))], resources=client.V1ResourceRequirements( requests={ 'cpu': f'{cpu_cores * 50}m', 'memory': memory_request }, limits={ 'cpu': f'{cpu_cores * 1000}m', 'memory': memory_limit } ) ), sftp_container ], volumes=[client.V1Volume(name='data', persistent_volume_claim=client.V1PersistentVolumeClaimVolumeSource(claim_name=pvc_name))] ) ) ) ) apps_v1.create_namespaced_deployment(namespace=namespace, body=deployment) # Create Service with FIXED port handling if ports: service_ports = [] for port in ports: external_port = port['external'] service_ports.append( client.V1ServicePort( name=f"port-{port['internal']}-{port['protocol']}".lower(), port=port['internal'], target_port=port['internal'], node_port=external_port, protocol=port['protocol'] ) ) # Add SFTP port to service service_ports.append( client.V1ServicePort( name="sftp", port=22, target_port=22, node_port=sftp_ext_port, protocol='TCP' ) ) service = client.V1Service( metadata=client.V1ObjectMeta( name=f"{server_id}-service", namespace=namespace, labels={'app': server_id} ), spec=client.V1ServiceSpec( selector={'app': server_id}, type='NodePort', ports=service_ports ) ) try: created_service = v1.create_namespaced_service(namespace=namespace, body=service) # Get the actual assigned node ports actual_ports = [] sftp_actual_port = sftp_ext_port for i, service_port in enumerate(created_service.spec.ports): if service_port.name == "sftp": sftp_actual_port = service_port.node_port continue actual_ports.append({ 'internal': service_port.port, 'external': service_port.node_port, 'protocol': service_port.protocol, 'name': service_port.name }) logger.info(f"✅ Created Service for database server with actual ports: {actual_ports}, SFTP: {sftp_actual_port}") # Update the ports with actual node_port values ports.clear() ports.extend(actual_ports) sftp_ext_port = sftp_actual_port except Exception as e: logger.error(f"⚠ Failed to create service: {e}") server_data = { 'id': server_id, 'name': config['name'], 'type': 'database', 'image': config['image'], 'version': config['version'], 'status': 'Creating', 'cpu': config['cpu'], 'ram': config['ram'], 'storage': config['storage'], 'ip': NODE_IP, 'ports': ports, 'ownerId': user_id, 'namespace': namespace, 'k8s_name': server_id, 'databaseConfig': config.get('databaseConfig', {}), 'sftp_username': sftp_user, 'sftp_password': sftp_pass, 'sftp_port': sftp_ext_port } return create_server_record(server_data) def get_db_mount_path(db_type: str) -> str: """Get the mount path for different database types""" mount_paths = { 'mysql': '/var/lib/mysql', 'postgresql': '/var/lib/postgresql/data', 'mongodb': '/data/db', 'redis': '/data' } return mount_paths.get(db_type, '/data') def create_vps_server(config: Dict, user_id: str, server_id: str, namespace: str, progress_callback=None) -> Dict: """Create a VPS using KubeVirt - FIXED PORT HANDLING Args: config: Server configuration user_id: User ID server_id: Server ID namespace: Kubernetes namespace progress_callback: Optional callback function(phase, progress, message) for progress updates """ user = get_user_by_id(user_id) ports = config.get('ports') or get_default_ports('vps', config['image']) storage_size = convert_storage_to_k8s_format(config['storage']) # Image mapping image_map = { 'ubuntu': { 'url': 'https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img', 'user': 'ubuntu', 'default_password': 'ubuntu' }, 'debian': { 'url': 'https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2', 'user': 'debian', 'default_password': 'debian' }, 'alpine': { 'url': 'https://dl-cdn.alpinelinux.org/alpine/v3.18/releases/x86_64/alpine-standard-3.18.4-x86_64.iso', 'user': 'root', 'default_password': 'alpine' }, 'centos': { 'url': 'https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2', 'user': 'centos', 'default_password': 'centos' }, 'windows': { 'url': '', 'user': 'Administrator', 'default_password': 'Password123' } } image_config = image_map.get(config['image'], image_map['ubuntu']) cpu_cores = int(config['cpu'].split()[0]) ram_gb = int(config['ram'].replace('GB', '')) memory_limit = f"{ram_gb}Gi" memory_request = f"{ram_gb * 0.5}Gi" if config['image'] == 'windows': return create_windows_vps(config, user_id, server_id, namespace, image_config, ports, storage_size, cpu_cores, memory_limit, memory_request) # Create DataVolume data_volume_name = f"{server_id}-rootdisk" if progress_callback: progress_callback('Initializing', 0, 'Preparing VPS creation...') data_volume_body = { 'apiVersion': 'cdi.kubevirt.io/v1beta1', 'kind': 'DataVolume', 'metadata': { 'name': data_volume_name, 'namespace': namespace, 'labels': { 'app': server_id, 'server-type': 'vps', 'managed-by': 'anchorhost' } }, 'spec': { 'source': { 'http': { 'url': image_config['url'] } }, 'pvc': { 'accessModes': ['ReadWriteOnce'], 'resources': { 'requests': { 'storage': storage_size } }, 'storageClassName': 'longhorn' } } } dv_created = False try: custom_objects_api.create_namespaced_custom_object( group="cdi.kubevirt.io", version="v1beta1", namespace=namespace, plural="datavolumes", body=data_volume_body ) logger.info(f"✓ Created DataVolume {data_volume_name}") dv_created = True except Exception as e: logger.error(f"✗ Failed to create DataVolume: {e}") if not k8s_manager.create_pvc(namespace, data_volume_name, storage_size): raise Exception(f"Failed to create storage: {e}") # Cloud-init configuration cloud_init_user_data = f"""#cloud-config user: {image_config['user']} password: {image_config['default_password']} chpasswd: expire: false list: | {image_config['user']}:{image_config['default_password']} ssh_pwauth: true disable_root: false package_update: true package_upgrade: true packages: - qemu-guest-agent - openssh-server - curl - wget - sudo runcmd: - systemctl enable qemu-guest-agent - systemctl start qemu-guest-agent - systemctl enable ssh - systemctl start ssh - [sh, -c, 'sed -i "s/#PermitRootLogin.*/PermitRootLogin yes/" /etc/ssh/sshd_config'] - [sh, -c, 'sed -i "s/PasswordAuthentication no/PasswordAuthentication yes/" /etc/ssh/sshd_config'] - systemctl restart ssh - [sh, -c, 'growpart /dev/vda 1 || true'] - [sh, -c, 'resize2fs /dev/vda1 || true'] final_message: "AnchorHost VPS ready! User: {image_config['user']}, Password: {image_config['default_password']}" """ # Cloud-init network configuration (Netplan) # We use enp1s0 as it is the default for KubeVirt with q35 machine type cloud_init_network_data = """network: version: 2 ethernets: enp1s0: dhcp4: true """ # Create VirtualMachine vm_body = { 'apiVersion': 'kubevirt.io/v1', 'kind': 'VirtualMachine', 'metadata': { 'name': server_id, 'namespace': namespace, 'labels': { 'app': server_id, 'server-type': 'vps', 'managed-by': 'anchorhost' } }, 'spec': { 'runStrategy': 'Always', 'template': { 'metadata': { 'labels': { 'kubevirt.io/domain': server_id, 'app': server_id } }, 'spec': { 'domain': { 'cpu': { 'cores': cpu_cores, 'sockets': 1, 'threads': 1 }, 'resources': { 'requests': { 'memory': memory_request, 'cpu': f'{cpu_cores * 50}m' }, 'limits': { 'memory': memory_limit, 'cpu': f'{cpu_cores * 1000}m' } }, 'devices': { 'disks': [ { 'name': 'rootdisk', 'bootOrder': 1, 'disk': { 'bus': 'virtio' } }, { 'name': 'cloudinitdisk', 'disk': { 'bus': 'virtio' } } ], 'interfaces': [ { 'name': 'podnet', 'masquerade': {}, 'model': 'virtio' } ], 'rng': {} }, 'machine': { 'type': 'q35' } }, 'volumes': [ { 'name': 'rootdisk', **({'dataVolume': {'name': data_volume_name}} if dv_created else {'persistentVolumeClaim': {'claimName': data_volume_name}}) }, { 'name': 'cloudinitdisk', 'cloudInitNoCloud': { 'userData': cloud_init_user_data, 'networkData': cloud_init_network_data } } ], 'networks': [ { 'name': 'podnet', 'pod': {} } ], 'terminationGracePeriodSeconds': 180, 'hostname': server_id } } } } # Wait for DataVolume if created (non-blocking with progress updates) if dv_created and k8s_manager.connected: logger.info("⏳ Waiting for DataVolume to be ready...") if progress_callback: progress_callback('Pending', 0, 'Creating DataVolume...') max_wait = 600 wait_interval = 5 # Check more frequently for better progress updates elapsed = 0 while elapsed < max_wait: try: dv = custom_objects_api.get_namespaced_custom_object( group="cdi.kubevirt.io", version="v1beta1", namespace=namespace, plural="datavolumes", name=data_volume_name ) status = dv.get('status', {}) phase = status.get('phase', '') progress = status.get('progress', '') # Extract progress percentage if available progress_percent = 0 if progress: # Progress format is usually "XX.X%" or "XX.XX%" try: progress_percent = float(progress.replace('%', '')) except (ValueError, AttributeError): pass # Map phase to progress percentage if not available if progress_percent == 0: phase_progress_map = { 'Pending': 5, 'PVCBound': 10, 'ImportScheduled': 15, 'ImportInProgress': 50, 'Succeeded': 100 } progress_percent = phase_progress_map.get(phase, 0) # Create progress message phase_messages = { 'Pending': 'Waiting for storage to be allocated...', 'PVCBound': 'Storage allocated, preparing import...', 'ImportScheduled': 'Import scheduled, starting download...', 'ImportInProgress': f'Downloading image... ({progress if progress else "in progress"})', 'Succeeded': 'Image download completed!', 'Failed': 'Image download failed' } message = phase_messages.get(phase, f'Processing... ({phase})') # Send progress update if progress_callback: progress_callback(phase, progress_percent, message) if phase == 'Succeeded': logger.info("✅ DataVolume ready!") break elif phase == 'Failed': error_message = status.get('conditions', [{}])[0].get('message', 'Unknown error') if status.get('conditions') else 'Unknown error' logger.error(f"❌ DataVolume failed: {error_message}") if progress_callback: progress_callback('Failed', 0, f'Image download failed: {error_message}') break except Exception as e: logger.error(f"⚠ Error checking DataVolume: {e}") if progress_callback: progress_callback('Error', 0, f'Error checking progress: {str(e)}') time.sleep(wait_interval) elapsed += wait_interval if elapsed >= max_wait: logger.warning(f"⚠ DataVolume wait timeout after {max_wait}s") if progress_callback: progress_callback('Timeout', 0, 'Image download timed out, proceeding anyway...') # Create VM if progress_callback: progress_callback('CreatingVM', 90, 'Creating virtual machine...') try: custom_objects_api.create_namespaced_custom_object( group="kubevirt.io", version="v1", namespace=namespace, plural="virtualmachines", body=vm_body ) logger.info(f"✅ Created VirtualMachine {server_id}") if progress_callback: progress_callback('Completed', 100, 'VPS created successfully!') except Exception as e: logger.error(f"❌ Failed to create VM: {e}") if progress_callback: progress_callback('Error', 0, f'Failed to create VM: {str(e)}') raise Exception(f"Failed to create VM: {str(e)}") # Create Service with FIXED port handling if ports: service_ports = [] for port in ports: # Use the actual external port from configuration external_port = port['external'] service_ports.append( client.V1ServicePort( name=f"port-{port['internal']}-{port['protocol']}".lower(), port=port['internal'], target_port=port['internal'], node_port=external_port, # Use the actual external port protocol=port['protocol'] ) ) service = client.V1Service( metadata=client.V1ObjectMeta( name=f"{server_id}-service", namespace=namespace, labels={'app': server_id} ), spec=client.V1ServiceSpec( selector={'kubevirt.io/domain': server_id}, type='NodePort', ports=service_ports ) ) try: created_service = v1.create_namespaced_service(namespace=namespace, body=service) # Get the actual assigned node ports from the created service actual_ports = [] for i, service_port in enumerate(created_service.spec.ports): actual_ports.append({ 'internal': service_port.port, 'external': service_port.node_port, 'protocol': service_port.protocol, 'name': service_port.name }) logger.info(f"✅ Created Service with actual ports: {actual_ports}") # Update the ports with actual node_port values ports.clear() ports.extend(actual_ports) except Exception as e: logger.error(f"⚠ Failed to create service: {e}") # Background status sync if k8s_manager.connected: threading.Thread( target=wait_and_sync_vm_status, args=(server_id, user_id, namespace), daemon=True ).start() # Create server record server_data = { 'id': server_id, 'name': config['name'], 'type': 'vps', 'image': config['image'], 'version': config['version'], 'status': 'Creating', 'cpu': config['cpu'], 'ram': config['ram'], 'storage': config['storage'], 'ip': NODE_IP, 'ports': ports, 'ownerId': user_id, 'namespace': namespace, 'k8s_name': server_id } return create_server_record(server_data) def wait_and_sync_vm_status(server_id: str, user_id: str, namespace: str): """Background VM status sync""" try: if k8s_manager.wait_for_vm_ready(namespace, server_id): update_server_status(server_id, 'Running', 'VM is ready') sync_server_ports_from_k8s(server_id, user_id) else: update_server_status(server_id, 'Error', 'VM failed to start') except Exception as e: update_server_status(server_id, 'Error', f'Error: {str(e)}') def wait_and_sync_pod_status(server_id: str, user_id: str, namespace: str): """Background pod status sync - wait for pods to be ready after restart""" try: if not k8s_manager.connected or v1 is None: # Fallback to sync if k8s not connected sync_server_status_from_k8s(server_id, user_id) return timeout = 600 # 10 minutes timeout start_time = time.time() logger.info(f"⏳ Waiting for pods to be ready for server {server_id} (timeout: {timeout}s)") while time.time() - start_time < timeout: try: pods = k8s_manager.get_pods_for_server(namespace, server_id) if pods: # Check if pods are running and containers are ready all_running = True latest_message = "Pods are running" for pod_name in pods: try: pod = v1.read_namespaced_pod(name=pod_name, namespace=namespace) if pod.status.phase != 'Running': all_running = False latest_message = f"Pod status: {pod.status.phase}" break # Check container statuses for ContainerCreating or non-ready states if pod.status.container_statuses: for container_status in pod.status.container_statuses: if not container_status.ready: all_running = False if container_status.state.waiting: latest_message = f"Container: {container_status.state.waiting.reason}" else: latest_message = "Container is not ready" break if not all_running: break except Exception as e: logger.warning(f"Error reading pod {pod_name}: {e}") all_running = False break if all_running: logger.info(f"✅ Pods for server {server_id} are ready and running") update_server_status(server_id, 'Running', 'Pods are running') sync_server_ports_from_k8s(server_id, user_id) return else: # Update status with intermediate message (e.g., ContainerCreating) update_server_status(server_id, 'Creating', latest_message) else: update_server_status(server_id, 'Creating', 'Waiting for pod to start') # Log progress every 30 seconds elapsed = int(time.time() - start_time) if elapsed % 30 == 0 and elapsed > 0: logger.info(f"⏳ Waiting for pods for server {server_id}... (elapsed: {elapsed}s)") time.sleep(5) except Exception as e: logger.error(f"Error checking pod status: {e}") time.sleep(10) logger.warning(f"⚠ Pods for server {server_id} not ready within {timeout} seconds") # Don't set to Error, let periodic sync handle it - pods might still be starting sync_server_status_from_k8s(server_id, user_id) except Exception as e: logger.error(f"Error in wait_and_sync_pod_status: {e}") sync_server_status_from_k8s(server_id, user_id) def create_windows_vps(config: Dict, user_id: str, server_id: str, namespace: str, image_config: Dict, ports: List, storage_size: str, cpu_cores: int, memory_limit: str, memory_request: str) -> Dict: """Create Windows VPS""" pvc_name = f"{server_id}-rootdisk" if not k8s_manager.create_pvc(namespace, pvc_name, storage_size): raise Exception("Failed to create PVC") vm_body = { 'apiVersion': 'kubevirt.io/v1', 'kind': 'VirtualMachine', 'metadata': { 'name': server_id, 'namespace': namespace }, 'spec': { 'runStrategy': 'Always', 'template': { 'spec': { 'domain': { 'cpu': {'cores': cpu_cores}, 'resources': { 'requests': { 'memory': memory_request, 'cpu': f'{cpu_cores * 50}m' }, 'limits': { 'memory': memory_limit, 'cpu': f'{cpu_cores * 1000}m' } }, 'devices': { 'disks': [{'name': 'rootdisk', 'disk': {'bus': 'sata'}}], 'interfaces': [{'name': 'default', 'model': 'e1000'}] } }, 'volumes': [{'name': 'rootdisk', 'persistentVolumeClaim': {'claimName': pvc_name}}], 'networks': [{'name': 'default', 'pod': {}}] } } } } custom_objects_api.create_namespaced_custom_object( group="kubevirt.io", version="v1", namespace=namespace, plural="virtualmachines", body=vm_body ) # Create Service with FIXED port handling if ports: service_ports = [] for port in ports: external_port = port['external'] service_ports.append( client.V1ServicePort( name=f"port-{port['internal']}-{port['protocol']}".lower(), port=port['internal'], target_port=port['internal'], node_port=external_port, protocol=port['protocol'] ) ) service = client.V1Service( metadata=client.V1ObjectMeta( name=f"{server_id}-service", namespace=namespace, labels={'app': server_id} ), spec=client.V1ServiceSpec( selector={'kubevirt.io/domain': server_id}, type='NodePort', ports=service_ports ) ) try: created_service = v1.create_namespaced_service(namespace=namespace, body=service) # Get the actual assigned node ports actual_ports = [] for i, service_port in enumerate(created_service.spec.ports): actual_ports.append({ 'internal': service_port.port, 'external': service_port.node_port, 'protocol': service_port.protocol, 'name': service_port.name }) logger.info(f"✅ Created Service for Windows VPS with actual ports: {actual_ports}") # Update the ports with actual node_port values ports.clear() ports.extend(actual_ports) except Exception as e: logger.error(f"⚠ Failed to create service: {e}") server_data = { 'id': server_id, 'name': config['name'], 'type': 'vps', 'image': config['image'], 'version': config['version'], 'status': 'Creating', 'cpu': config['cpu'], 'ram': config['ram'], 'storage': config['storage'], 'ip': NODE_IP, 'ports': ports, 'ownerId': user_id, 'namespace': namespace, 'k8s_name': server_id } return create_server_record(server_data) def create_website_server(config: Dict, user_id: str, server_id: str, namespace: str) -> Dict: """Create Website Server with FIXED port handling""" storage_size = convert_storage_to_k8s_format(get_storage_size('website', config['image'])) pvc_name = f"{server_id}-data" if not k8s_manager.create_pvc(namespace, pvc_name, storage_size): raise Exception("Failed to create PVC") image_map = { 'nginx': 'nginx:stable-alpine', 'wordpress': 'wordpress:php8.3-apache', 'wordpress-sqlite': 'ghcr.io/yunussandikci/wordpress-sqlite', 'node': 'node:22-alpine' } selected_image_key = config['image'] container_image_base = image_map.get(selected_image_key, image_map['nginx']) # Append version if specified if config.get('version') and selected_image_key == 'wordpress-sqlite': container_image = f"{container_image_base}:{config['version']}" else: container_image = container_image_base ports = config.get('ports') or get_default_ports('website', selected_image_key) cpu_cores = int(config['cpu'].split()[0]) ram_gb = int(config['ram'].replace('GB', '')) memory_limit = f"{ram_gb}Gi" memory_request = f"{ram_gb * 0.5}Gi" mount_path = '/usr/share/nginx/html' if selected_image_key == 'nginx' else '/var/www/html' # Generate SFTP credentials and port sftp_user, sftp_pass, sftp_ext_port = generate_sftp_credentials() sftp_container = get_sftp_sidecar_container(server_id, sftp_user, sftp_pass, mount_path) containers = [ client.V1Container( name=server_id, image=container_image, ports=[client.V1ContainerPort(container_port=p['internal']) for p in ports], volume_mounts=[client.V1VolumeMount( name='data', mount_path=mount_path )], resources=client.V1ResourceRequirements( requests={'cpu': f'{cpu_cores * 50}m', 'memory': memory_request}, limits={'cpu': f'{cpu_cores * 1000}m', 'memory': memory_limit} ) ), sftp_container ] deployment = client.V1Deployment( metadata=client.V1ObjectMeta(name=server_id, namespace=namespace), spec=client.V1DeploymentSpec( replicas=1, selector=client.V1LabelSelector(match_labels={'app': server_id}), template=client.V1PodTemplateSpec( metadata=client.V1ObjectMeta(labels={'app': server_id}), spec=client.V1PodSpec( containers=containers, volumes=[client.V1Volume(name='data', persistent_volume_claim=client.V1PersistentVolumeClaimVolumeSource(claim_name=pvc_name))] ) ) ) ) apps_v1.create_namespaced_deployment(namespace=namespace, body=deployment) # Create Service with FIXED port handling if ports: service_ports = [] for port in ports: external_port = port['external'] service_ports.append( client.V1ServicePort( name=f"port-{port['internal']}-{port['protocol']}".lower(), port=port['internal'], target_port=port['internal'], node_port=external_port, protocol=port['protocol'] ) ) # Add SFTP port to service service_ports.append( client.V1ServicePort( name="sftp", port=22, target_port=22, node_port=sftp_ext_port, protocol='TCP' ) ) service = client.V1Service( metadata=client.V1ObjectMeta( name=f"{server_id}-service", namespace=namespace, labels={'app': server_id} ), spec=client.V1ServiceSpec( selector={'app': server_id}, type='NodePort', ports=service_ports ) ) try: created_service = v1.create_namespaced_service(namespace=namespace, body=service) # Get the actual assigned node ports actual_ports = [] sftp_actual_port = sftp_ext_port for i, service_port in enumerate(created_service.spec.ports): if service_port.name == "sftp": sftp_actual_port = service_port.node_port continue actual_ports.append({ 'internal': service_port.port, 'external': service_port.node_port, 'protocol': service_port.protocol, 'name': service_port.name }) logger.info(f"✅ Created Service for website with actual ports: {actual_ports}, SFTP: {sftp_actual_port}") # Update the ports with actual node_port values ports.clear() ports.extend(actual_ports) sftp_ext_port = sftp_actual_port except Exception as e: logger.error(f"⚠ Failed to create service: {e}") server_data = { 'id': server_id, 'name': config['name'], 'type': 'website', 'image': config['image'], 'version': config['version'], 'status': 'Creating', 'cpu': config['cpu'], 'ram': config['ram'], 'storage': config['storage'], 'ip': NODE_IP, 'ports': ports, 'ownerId': user_id, 'namespace': namespace, 'k8s_name': server_id, 'sftp_username': sftp_user, 'sftp_password': sftp_pass, 'sftp_port': sftp_ext_port } return create_server_record(server_data) def get_minecraft_image(version: str, java_version: str = None) -> str: """Get the correct itzg/minecraft-server image tag based on Minecraft version""" # If Java version is explicitly provided, use it if java_version: # Extract version number if string like "Java 17" is passed import re match = re.search(r'(\d+)', str(java_version)) if match: ver = int(match.group(1)) if ver >= 25: return 'itzg/minecraft-server:java25' elif ver >= 21: return 'itzg/minecraft-server:java21' elif ver >= 17: return 'itzg/minecraft-server:java17' elif ver == 16: return 'itzg/minecraft-server:java17' # Java 17 is backwards compatible with 16 elif ver >= 11: return 'itzg/minecraft-server:java11' elif ver == 8: return 'itzg/minecraft-server:java8' if not version: return 'itzg/minecraft-server:latest' # Mapping based on Minecraft version requirements # Java 25: 26.x+ # Java 21: 1.20.5 - 1.21.x # Java 17: 1.18 - 1.20.4 # Java 11: 1.17 (Java 16 specifically, but java17/11 images are common) # Java 8: <= 1.16.5 try: # Extract major and minor version (e.g., "1.20.1" -> 1, 20) parts = version.split('.') if len(parts) >= 1: major = int(parts[0]) minor = int(parts[1]) if len(parts) >= 2 else 0 patch = int(parts[2]) if len(parts) >= 3 else 0 # Handling for newer versioning (26.x) if major >= 26: return 'itzg/minecraft-server:java25' # Legacy versioning (1.x) if major == 1: if minor >= 21 or (minor == 20 and patch >= 5): return 'itzg/minecraft-server:java21' elif minor >= 18: return 'itzg/minecraft-server:java17' elif minor == 17: return 'itzg/minecraft-server:java17' # Use Java 17 for 1.17 elif minor >= 12: return 'itzg/minecraft-server:java8' else: return 'itzg/minecraft-server:java8' except Exception as e: logger.warning(f"Error parsing Minecraft version {version}: {e}") pass return 'itzg/minecraft-server:latest' def create_game_server(config: Dict, user_id: str, server_id: str, namespace: str) -> Dict: """Create Game Server with FIXED port handling""" storage_size = convert_storage_to_k8s_format(get_storage_size('game', config['image'])) pvc_name = f"{server_id}-data" if not k8s_manager.create_pvc(namespace, pvc_name, storage_size): raise Exception("Failed to create PVC") java_version = config.get('java_version') loader = config.get('loader') # Minecraft specific image handling for Java versions if config['image'] == 'minecraft': container_image = get_minecraft_image(config.get('version', ''), java_version) else: image_map = { 'fivem': 'fivem/fivem:latest', 'cs2': 'cm2network/cs2:latest', 'rust': 'pfeiffermax/rust-game-server:latest', 'hytale': 'deinfreu/hytale-server:latest', 'unturned': 'imperialplugins/unturned:latest', 'zomboid': 'danixu86/project-zomboid-dedicated-server:latest', 'factorio': 'factoriotools/factorio:latest', 'ark': 'cm2network/arkserver:latest', 'valheim': 'lloesche/valheim-server:latest' } if config['image'] == 'zomboid' and config.get('version') == 'unstable': container_image = 'danixu86/project-zomboid-dedicated-server:unstable' elif config['image'] == 'factorio' and config.get('version') == 'stable': container_image = 'factoriotools/factorio:stable' else: container_image = image_map.get(config['image'], 'itzg/minecraft-server:latest') ports = config.get('ports') or get_default_ports('game', config['image']) # Game-specific environment variables env_vars = [] if config['image'] == 'minecraft': # Use the allocated RAM for Minecraft JVM (e.g. "4G") memory_val = config['ram'].replace('GB', 'G') # Determine server type (loader) server_type_env = 'PAPER' if loader: server_type_env = loader.upper() version_val = config.get('version', '1.20.1') if server_type_env == 'PAPER' and version_val == '26.1': version_val = '26.1.2' env_vars = [ client.V1EnvVar(name='EULA', value='TRUE'), client.V1EnvVar(name='TYPE', value=server_type_env), client.V1EnvVar(name='VERSION', value=version_val), client.V1EnvVar(name='MEMORY', value=memory_val) ] elif config['image'] == 'fivem': env_vars = [ client.V1EnvVar(name='FIVEM_LICENSE_KEY', value='your-license-key-here') ] elif config['image'] == 'rust': # pfeiffermax/rust-game-server args: +server.ip 0.0.0.0 +server.port 28015 +rcon.ip 0.0.0.0 +rcon.port 28016 # These are usually passed as command args, but some images use env pass elif config['image'] == 'hytale': env_vars = [ client.V1EnvVar(name='SERVER_IP', value='0.0.0.0'), client.V1EnvVar(name='SERVER_PORT', value='5520'), client.V1EnvVar(name='PROD', value='FALSE'), client.V1EnvVar(name='DEBUG', value='FALSE'), client.V1EnvVar(name='TZ', value='Europe/Amsterdam') ] elif config['image'] == 'unturned': env_vars = [ client.V1EnvVar(name='SERVER_TYPE', value='rm4') ] elif config['image'] == 'zomboid': # Generate random admin password for Zomboid admin_pass = str(uuid.uuid4())[:12] env_vars = [ client.V1EnvVar(name='ADMINUSERNAME', value='admin'), client.V1EnvVar(name='ADMINPASSWORD', value=admin_pass), client.V1EnvVar(name='PORT', value='16261'), client.V1EnvVar(name='MEMORY', value=config['ram'].replace('GB', 'g')) ] if config.get('version') == 'unstable': env_vars.append(client.V1EnvVar(name='STEAMAPPBRANCH', value='unstable')) elif config['image'] == 'factorio': env_vars = [ client.V1EnvVar(name='LOAD_LATEST_SAVE', value='true'), client.V1EnvVar(name='GENERATE_NEW_SAVE', value='true') ] cpu_cores = int(config['cpu'].split()[0]) ram_gb = int(config['ram'].replace('GB', '')) memory_limit = f"{ram_gb}Gi" memory_request = f"{ram_gb * 0.5}Gi" # Custom mount paths and arguments container_args = [] mount_path = '/data' if config['image'] == 'rust': container_args = ["+server.ip", "0.0.0.0", "+server.port", "28015", "+rcon.ip", "0.0.0.0", "+rcon.port", "28016"] elif config['image'] == 'unturned': mount_path = '/home/steam/Unturned' container_args = ["-SkipAssets"] elif config['image'] == 'factorio': mount_path = '/factorio' elif config['image'] == 'hytale': mount_path = '/home/container' elif config['image'] == 'zomboid': mount_path = '/home/steam/Zomboid' # Generate SFTP credentials and port sftp_user, sftp_pass, sftp_ext_port = generate_sftp_credentials() sftp_container = get_sftp_sidecar_container(server_id, sftp_user, sftp_pass, mount_path) deployment = client.V1Deployment( metadata=client.V1ObjectMeta(name=server_id, namespace=namespace), spec=client.V1DeploymentSpec( replicas=1, selector=client.V1LabelSelector(match_labels={'app': server_id}), template=client.V1PodTemplateSpec( metadata=client.V1ObjectMeta(labels={'app': server_id}), spec=client.V1PodSpec( containers=[ client.V1Container( name=server_id, image=container_image, args=container_args if container_args else None, ports=[client.V1ContainerPort(container_port=p['internal']) for p in ports], env=env_vars, volume_mounts=[client.V1VolumeMount(name='data', mount_path=mount_path)], resources=client.V1ResourceRequirements( requests={ 'cpu': f'{cpu_cores * 50}m', 'memory': memory_request }, limits={ 'cpu': f'{cpu_cores * 1000}m', 'memory': memory_limit } ) ), sftp_container ], volumes=[client.V1Volume(name='data', persistent_volume_claim=client.V1PersistentVolumeClaimVolumeSource(claim_name=pvc_name))] ) ) ) ) apps_v1.create_namespaced_deployment(namespace=namespace, body=deployment) # Create Service with FIXED port handling if ports: service_ports = [] for port in ports: external_port = port['external'] service_ports.append( client.V1ServicePort( name=f"port-{port['internal']}-{port['protocol']}".lower(), port=port['internal'], target_port=port['internal'], node_port=external_port, protocol=port['protocol'] ) ) # Add SFTP port to service service_ports.append( client.V1ServicePort( name="sftp", port=22, target_port=22, node_port=sftp_ext_port, protocol='TCP' ) ) service = client.V1Service( metadata=client.V1ObjectMeta( name=f"{server_id}-service", namespace=namespace, labels={'app': server_id} ), spec=client.V1ServiceSpec( selector={'app': server_id}, type='NodePort', ports=service_ports ) ) try: created_service = v1.create_namespaced_service(namespace=namespace, body=service) # Get the actual assigned node ports actual_ports = [] sftp_actual_port = sftp_ext_port for i, service_port in enumerate(created_service.spec.ports): if service_port.name == "sftp": sftp_actual_port = service_port.node_port continue actual_ports.append({ 'internal': service_port.port, 'external': service_port.node_port, 'protocol': service_port.protocol, 'name': service_port.name }) logger.info(f"✅ Created Service for game server with actual ports: {actual_ports}, SFTP: {sftp_actual_port}") # Update the ports with actual node_port values ports.clear() ports.extend(actual_ports) sftp_ext_port = sftp_actual_port except Exception as e: logger.error(f"⚠ Failed to create service: {e}") server_data = { 'id': server_id, 'name': config['name'], 'type': 'game', 'image': config['image'], 'version': config['version'], 'status': 'Creating', 'cpu': config['cpu'], 'ram': config['ram'], 'storage': config['storage'], 'ip': NODE_IP, 'ports': ports, 'ownerId': user_id, 'namespace': namespace, 'k8s_name': server_id, 'java_version': java_version, 'loader': loader, 'sftp_username': sftp_user, 'sftp_password': sftp_pass, 'sftp_port': sftp_ext_port } return create_server_record(server_data) def create_server(config: Dict, user_id: str, progress_callback=None) -> Dict: """Create a server with capacity validation - UPDATED with billing date Args: config: Server configuration user_id: User ID progress_callback: Optional callback function(phase, progress, message) for progress updates """ try: user = get_user_by_id(user_id) if not user: raise Exception('User not found') # Check if this is a demo server is_demo = config.get('is_demo', False) # Step 0: Validate server name server_name = config.get('name', '').strip() is_valid, error_msg = validate_server_name(server_name) if not is_valid: raise Exception(error_msg) # If demo server, validate eligibility and set lowest specs if is_demo: eligibility = check_demo_eligibility(user_id) if not eligibility['eligible']: raise Exception(eligibility.get('reason', 'Not eligible for demo server')) # Set lowest possible specs for demo config['cpu'] = '2 vCPU' config['ram'] = '3GB' config['storage'] = '20GB' # Mark demo as used mark_demo_used(user_id) logger.info(f"🎁 Creating demo server for user {user_id}") # Step 1: Validate capacity before proceeding logger.info(f"🔍 Validating capacity for server: {config['name']}") capacity_check = validate_server_capacity(config) if not capacity_check['success']: error_msg = f"Insufficient capacity: {capacity_check['message']}" logger.error(f"❌ {error_msg}") raise Exception(error_msg) logger.info(f"✅ Capacity validation passed: {capacity_check['message']}") # Step 2: Check credits (skip for demo servers) if not is_demo: server_cost = calculate_server_cost(config) if user.get('credits', 0) < server_cost: raise Exception(f'Insufficient credits. Need {server_cost}, have {user.get("credits", 0)}') new_credits = user.get('credits', 0) - server_cost if not update_user_credits(user_id, new_credits): raise Exception('Failed to deduct credits') add_credit_transaction(user_id, -server_cost, 'deduct', f'Server creation: {config["name"]}') else: # Demo servers are free server_cost = 0 logger.info(f"🎁 Demo server created - no credits charged") # Set demo expiration time if demo server demo_expires_at = None if is_demo: # Use UTC timezone to avoid timezone issues demo_expires_at = (datetime.now(timezone.utc) + timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M:%SZ') if not k8s_manager.connected: server_id = f"server-{str(uuid.uuid4())[:8]}" ports = get_default_ports(config['serverType'], config['image']) server_data = { 'id': server_id, 'name': config['name'], 'type': config['serverType'], 'image': config['image'], 'version': config['version'], 'status': 'Creating', 'cpu': config['cpu'], 'ram': config['ram'], 'storage': config['storage'], 'ip': NODE_IP, 'ports': ports, 'ownerId': user_id, 'namespace': f"user-{user['username']}".lower(), 'k8s_name': server_id, 'is_demo': is_demo, 'demo_expires_at': demo_expires_at, 'java_version': config.get('java_version'), 'loader': config.get('loader') } # Add database config if present if config.get('databaseConfig'): server_data['databaseConfig'] = config['databaseConfig'] server = create_server_record(server_data) else: namespace = f"user-{user['username']}".lower() sanitized_name = config['name'].lower().replace(' ', '-').replace('_', '-')[:20] server_id = f"{sanitized_name}-{str(uuid.uuid4())[:8]}" if not k8s_manager.create_namespace(namespace): raise Exception(f"Failed to create Kubernetes namespace: {namespace}") server_type = config['serverType'] if server_type == 'vps': server = create_vps_server(config, user_id, server_id, namespace, progress_callback) elif server_type == 'game': server = create_game_server(config, user_id, server_id, namespace) elif server_type == 'website': server = create_website_server(config, user_id, server_id, namespace) elif server_type == 'database': server = create_database_server(config, user_id, server_id, namespace) else: raise Exception(f'Unknown server type: {server_type}') # Update server with demo fields if it's a demo server if is_demo: with database_connection() as conn: cursor = conn.cursor() cursor.execute(''' UPDATE servers SET is_demo = ?, demo_expires_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? ''', (True, demo_expires_at, server['id'])) conn.commit() # Reload server to get updated fields server = get_server_by_id(server['id']) # Zapisz datę utworzenia serwera dla celów rozliczeniowych (skip for demo servers) if not is_demo: save_server_creation_date(server['id']) return server except Exception as e: logger.error(f"Error creating server: {e}") raise Exception(f'Failed to create server: {str(e)}') def save_server_creation_date(server_id: str) -> bool: """Zapisz datę utworzenia serwera dla celów rozliczeniowych""" try: with database_connection() as conn: cursor = conn.cursor() # Utwórz tabelę jeśli nie istnieje cursor.execute(''' CREATE TABLE IF NOT EXISTS server_billing ( id TEXT PRIMARY KEY, server_id TEXT NOT NULL UNIQUE, creation_date DATE NOT NULL, next_billing_date DATE NOT NULL, last_billing_date DATE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (server_id) REFERENCES servers (id) ON DELETE CASCADE ) ''') # Utwórz indeksy cursor.execute('CREATE INDEX IF NOT EXISTS idx_server_billing_server_id ON server_billing(server_id)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_server_billing_next_billing_date ON server_billing(next_billing_date)') # Oblicz daty rozliczeniowe today = datetime.now().date() creation_date = today.isoformat() next_billing_date = (today + timedelta(days=30)).isoformat() # 30 dni później # Wstaw rekord billing_id = f"bill-{str(uuid.uuid4())[:8]}" cursor.execute(''' INSERT INTO server_billing (id, server_id, creation_date, next_billing_date) VALUES (?, ?, ?, ?) ON CONFLICT (server_id) DO UPDATE SET creation_date = excluded.creation_date, next_billing_date = excluded.next_billing_date, updated_at = CURRENT_TIMESTAMP ''', (billing_id, server_id, creation_date, next_billing_date)) conn.commit() logger.info(f"✓ Saved billing date for server {server_id}: next billing on {next_billing_date}") return True except Exception as e: logger.error(f"Error saving server billing date: {e}") return False def validate_server_name(server_name: str) -> Tuple[bool, str]: """Validate server name - only letters and spaces allowed, no numbers or special symbols""" if not server_name or not server_name.strip(): return False, "Server name cannot be empty" # Check if name contains only letters and spaces if not re.match(r'^[a-zA-Z\s]+$', server_name): return False, "Server name can only contain letters and spaces. Numbers and special symbols are not allowed." # Check length if len(server_name.strip()) < 1: return False, "Server name must be at least 1 character long" if len(server_name) > 50: return False, "Server name must be 50 characters or less" return True, ""