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

2079 lines
84 KiB
Python

"""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
from namespace_utils import user_namespace
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 = server.get('namespace') or user_namespace(owner['username'])
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 = server.get('namespace') or user_namespace(owner['username'])
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, progress_callback
)
# Create DataVolume
data_volume_name = f"{server_id}-rootdisk"
if progress_callback:
progress_callback('Preparing storage', 3, 'Preparing the VPS system disk.')
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('Allocating storage', 5, 'Allocating storage for the system image.')
max_wait = 600
wait_interval = 5
elapsed = 0
latest_progress = 12
image_ready = False
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', '')
# CDI reports the actual image download percentage.
progress_percent = None
if progress:
try:
progress_percent = float(progress.replace('%', ''))
except (ValueError, AttributeError):
pass
phase_progress_map = {
'Pending': 5,
'PVCBound': 9,
'ImportScheduled': 12,
'Succeeded': 84
}
if phase == 'ImportInProgress' and progress_percent is not None:
progress_percent = 12 + max(0, min(100, progress_percent)) * 0.72
else:
progress_percent = phase_progress_map.get(phase, latest_progress)
latest_progress = max(latest_progress, int(progress_percent))
phase_messages = {
'Pending': ('Allocating storage', 'Waiting for the storage volume.'),
'PVCBound': ('Preparing image download', 'Storage is ready. Preparing the system image.'),
'ImportScheduled': ('Starting image download', 'The system image download has been scheduled.'),
'ImportInProgress': ('Downloading system image', f'Downloading the system image ({progress or "in progress"}).'),
'Succeeded': ('System image ready', 'The system image is ready.')
}
progress_phase, message = phase_messages.get(
phase, ('Preparing system image', 'Waiting for the system image importer.')
)
if progress_callback:
progress_callback(progress_phase, latest_progress, message)
if phase == 'Succeeded':
logger.info("VPS system image is ready")
image_ready = True
break
elif phase == 'Failed':
error_message = status.get('conditions', [{}])[0].get('message', 'Unknown error') if status.get('conditions') else 'Unknown error'
logger.error(f"VPS system image import failed: {error_message}")
if progress_callback:
progress_callback('Failed', latest_progress, f'System image download failed: {error_message}')
raise RuntimeError(f'System image download failed: {error_message}')
except Exception as e:
if isinstance(e, RuntimeError):
raise
logger.warning(f"Could not read VPS image import status: {e}")
if progress_callback:
progress_callback(
'Downloading system image', latest_progress,
'Waiting for the image download status to update.'
)
if image_ready:
break
time.sleep(wait_interval)
elapsed += wait_interval
if elapsed >= max_wait:
logger.warning(f"VPS image import did not finish within {max_wait}s")
if progress_callback:
progress_callback('Failed', latest_progress, 'The system image download timed out.')
raise TimeoutError('System image download timed out; the virtual machine was not started.')
# Create VM
if progress_callback:
progress_callback('Starting virtual machine', 88, 'Creating the virtual machine resource.')
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}")
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)}")
if progress_callback:
progress_callback('Configuring network', 92, 'Setting up the VPS network and ports.')
# 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 VPS network service: {e}")
if progress_callback:
progress_callback('Failed', 92, 'Could not create the VPS network service.')
raise RuntimeError(f'Failed to create VPS network service: {e}') from e
# Create server record
if progress_callback:
progress_callback('Registering VPS', 97, 'Saving the VPS to your server list.')
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
}
server = create_server_record(server_data)
if not server:
raise RuntimeError('VPS resources were created, but the server record could not be saved.')
if k8s_manager.connected:
threading.Thread(
target=wait_and_sync_vm_status,
args=(server_id, user_id, namespace),
daemon=True
).start()
return server
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,
progress_callback=None) -> Dict:
"""Create Windows VPS"""
pvc_name = f"{server_id}-rootdisk"
if progress_callback:
progress_callback('Preparing storage', 5, 'Preparing storage for the Windows VPS.')
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': {}}]
}
}
}
}
if progress_callback:
progress_callback('Starting virtual machine', 88, 'Creating the Windows virtual machine resource.')
custom_objects_api.create_namespaced_custom_object(
group="kubevirt.io",
version="v1",
namespace=namespace,
plural="virtualmachines",
body=vm_body
)
if progress_callback:
progress_callback('Configuring network', 92, 'Setting up the Windows VPS network and ports.')
# 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 Windows VPS network service: {e}")
if progress_callback:
progress_callback('Failed', 92, 'Could not create the VPS network service.')
raise RuntimeError(f'Failed to create Windows VPS network service: {e}') from 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
}
if progress_callback:
progress_callback('Registering VPS', 97, 'Saving the VPS to your server list.')
server = create_server_record(server_data)
if not server:
raise RuntimeError('VPS resources were created, but the server record could not be saved.')
if k8s_manager.connected:
threading.Thread(
target=wait_and_sync_vm_status,
args=(server_id, user_id, namespace),
daemon=True
).start()
return server
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': user_namespace(user['username']),
'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 = user_namespace(user['username'])
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, ""