Files
anchorhost-backend/server_lifecycle.py
T
wfalkowski c177d7981d
Build and publish Docker image / build-and-publish (push) Successful in 2m45s
Refactor backend and add registry build workflow
2026-10-20 16:44:49 +00:00

748 lines
28 KiB
Python

"""Server lifecycle operations."""
from database_service import database_connection
from kubernetes import client
from kubernetes_service import apps_v1, custom_objects_api, k8s_manager, v1
from runtime_config import (
logger,
)
from server_capacity import calculate_monthly_server_cost, calculate_server_cost, validate_server_capacity
from server_provisioning import wait_and_sync_pod_status, wait_and_sync_vm_status
from server_records import delete_server_record, get_server_by_id, update_server_status
import threading
import time
from typing import Dict
from user_service import add_credit_transaction, get_user_by_id, update_user_credits
__all__ = ['adjust_server_parameters', 'delete_server', 'restart_server', 'start_server', 'stop_server', 'update_server_resources_in_k8s']
def delete_server(server_id: str, user_id: str) -> bool:
try:
user = get_user_by_id(user_id)
if not user:
raise Exception('User not found')
server = get_server_by_id(server_id)
if not server:
raise Exception('Server not found')
# Verify ownership or admin
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: Access denied")
# Get owner for namespace
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")
original_cost = calculate_server_cost({
'serverType': server['type'],
'cpu': server['cpu'],
'ram': server['ram'],
'storage': server['storage']
})
refund_amount = int(original_cost * 0.5)
if not k8s_manager.connected:
if delete_server_record(server_id):
# Removed refund logic
pass
return True
namespace = f"user-{owner['username']}".lower()
try:
custom_objects_api.delete_namespaced_custom_object(
group="kubevirt.io",
version="v1",
namespace=namespace,
plural="virtualmachines",
name=server_id
)
except Exception:
pass
try:
custom_objects_api.delete_namespaced_custom_object(
group="kubevirt.io",
version="v1",
namespace=namespace,
plural="virtualmachineinstances",
name=server_id
)
except Exception:
pass
try:
apps_v1.delete_namespaced_deployment(name=server_id, namespace=namespace)
except Exception:
pass
try:
v1.delete_namespaced_service(name=f"{server_id}-service", namespace=namespace)
except Exception:
pass
try:
v1.delete_namespaced_persistent_volume_claim(name=f"{server_id}-data", namespace=namespace)
except Exception:
pass
try:
v1.delete_namespaced_persistent_volume_claim(name=f"{server_id}-rootdisk", namespace=namespace)
except Exception:
pass
if delete_server_record(server_id):
# Removed refund logic
pass
return True
except Exception as e:
raise Exception(f'Failed to delete server: {str(e)}')
def start_server(server_id: str, user_id: str) -> bool:
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")
# Step 4: Check if user has enough credits (must be non-negative)
# Use owner's credits check if admin is starting, or just skip?
# Ideally check owner's credits.
owner_credits = owner.get('credits', 0)
if owner_credits < 0:
raise Exception(f'Insufficient credits to start server. Owner has {owner_credits} credits.')
# Step 5: Check capacity if server is not already running
if server.get('status') != 'Running':
logger.info(f"🔍 Validating capacity before starting server: {server_id}")
server_config = {
'type': server['type'],
'cpu': server['cpu'],
'ram': server['ram'],
'storage': server['storage']
}
capacity_check = validate_server_capacity(server_config)
if not capacity_check['success']:
error_msg = f"Insufficient capacity to start server: {capacity_check['message']}"
logger.error(f"❌ Capacity validation failed: {error_msg}")
raise Exception(error_msg)
logger.info(f"✅ Capacity validation passed: {capacity_check['message']}")
if not k8s_manager.connected:
update_server_status(server_id, 'Running')
return True
namespace = f"user-{owner['username']}".lower()
try:
patch = {'spec': {'runStrategy': 'Always'}}
custom_objects_api.patch_namespaced_custom_object(
group="kubevirt.io",
version="v1",
namespace=namespace,
plural="virtualmachines",
name=server_id,
body=patch
)
update_server_status(server_id, 'Starting')
threading.Thread(
target=wait_and_sync_vm_status,
args=(server_id, owner['id'], namespace),
daemon=True
).start()
return True
except Exception:
pass
try:
patch = {'spec': {'replicas': 1}}
apps_v1.patch_namespaced_deployment_scale(name=server_id, namespace=namespace, body=patch)
update_server_status(server_id, 'Starting')
return True
except Exception:
pass
return False
except Exception as e:
raise Exception(f'Failed to start server: {str(e)}')
def stop_server(server_id: str, user_id: str) -> bool:
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")
if not k8s_manager.connected:
update_server_status(server_id, 'Stopped')
return True
namespace = f"user-{owner['username']}".lower()
try:
patch = {'spec': {'runStrategy': 'Halted'}}
custom_objects_api.patch_namespaced_custom_object(
group="kubevirt.io",
version="v1",
namespace=namespace,
plural="virtualmachines",
name=server_id,
body=patch
)
update_server_status(server_id, 'Stopping')
return True
except Exception:
pass
try:
patch = {'spec': {'replicas': 0}}
apps_v1.patch_namespaced_deployment_scale(name=server_id, namespace=namespace, body=patch)
update_server_status(server_id, 'Stopping')
return True
except Exception:
pass
return False
except Exception as e:
raise Exception(f'Failed to stop server: {str(e)}')
def restart_server(server_id: str, user_id: str, skip_checks: bool = False) -> bool:
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")
# Step 4: Check if user has enough credits (skip for downgrades)
if not skip_checks:
# Check owner's credits
owner_credits = owner.get('credits', 0)
if owner_credits < 0:
raise Exception(f'Insufficient credits to restart server. Owner has {owner_credits} credits.')
# Step 5: Check capacity before restarting (skip for downgrades)
if not skip_checks:
logger.info(f"🔍 Validating capacity before restarting server: {server_id}")
server_config = {
'type': server['type'],
'cpu': server['cpu'],
'ram': server['ram'],
'storage': server['storage']
}
capacity_check = validate_server_capacity(server_config)
if not capacity_check['success']:
error_msg = f"Insufficient capacity to restart server: {capacity_check['message']}"
logger.error(f"❌ Capacity validation failed: {error_msg}")
raise Exception(error_msg)
logger.info(f"✅ Capacity validation passed: {capacity_check['message']}")
if not k8s_manager.connected:
update_server_status(server_id, 'Restarting')
time.sleep(2)
update_server_status(server_id, 'Running')
return True
namespace = f"user-{owner['username']}".lower()
update_server_status(server_id, 'Restarting')
try:
stop_patch = {'spec': {'runStrategy': 'Halted'}}
custom_objects_api.patch_namespaced_custom_object(
group="kubevirt.io",
version="v1",
namespace=namespace,
plural="virtualmachines",
name=server_id,
body=stop_patch
)
time.sleep(5)
start_patch = {'spec': {'runStrategy': 'Always'}}
custom_objects_api.patch_namespaced_custom_object(
group="kubevirt.io",
version="v1",
namespace=namespace,
plural="virtualmachines",
name=server_id,
body=start_patch
)
threading.Thread(
target=wait_and_sync_vm_status,
args=(server_id, owner['id'], namespace),
daemon=True
).start()
return True
except Exception:
pass
try:
pods = k8s_manager.get_pods_for_server(namespace, server_id)
if pods:
for pod in pods:
v1.delete_namespaced_pod(name=pod, namespace=namespace)
update_server_status(server_id, 'Restarting')
# Wait for pods to restart and update status
threading.Thread(
target=wait_and_sync_pod_status,
args=(server_id, user_id, namespace),
daemon=True
).start()
return True
except Exception:
pass
return False
except Exception as e:
raise Exception(f'Failed to restart server: {str(e)}')
def update_server_resources_in_k8s(server: Dict, new_cpu: str, new_ram: str, new_storage: str, namespace: str) -> bool:
"""Update server resources (CPU, RAM, Storage) in Kubernetes"""
try:
server_type = server.get('type')
server_id = server['id']
if server_type == 'vps':
# Update VirtualMachine resources
cpu_cores = int(new_cpu.split()[0])
memory_size = new_ram.replace('GB', 'Gi')
# Parse storage size
import re
storage_match = re.search(r'(\d+)\s*GB', new_storage, re.IGNORECASE)
if storage_match:
storage_gb = int(storage_match.group(1))
storage_size = f"{storage_gb}Gi"
else:
storage_match = re.search(r'(\d+)\s*GiB', new_storage, re.IGNORECASE)
if storage_match:
storage_size = f"{storage_match.group(1)}Gi"
else:
raise Exception(f"Invalid storage format: {new_storage}")
# Get current VM spec to preserve network interfaces and other configurations
try:
current_vm = custom_objects_api.get_namespaced_custom_object(
group="kubevirt.io",
version="v1",
namespace=namespace,
plural="virtualmachines",
name=server_id
)
# Preserve existing domain configuration (especially devices/interfaces)
current_domain = current_vm.get('spec', {}).get('template', {}).get('spec', {}).get('domain', {})
current_devices = current_domain.get('devices', {})
current_machine = current_domain.get('machine', {})
# Build patch with CPU and resources, explicitly preserving devices (network interfaces)
domain_patch = {
'cpu': {
'cores': cpu_cores,
'sockets': 1,
'threads': 1
},
'resources': {
'requests': {
'memory': memory_size,
'cpu': f'{cpu_cores * 100}m'
},
'limits': {
'memory': memory_size,
'cpu': f'{cpu_cores * 1000}m'
}
}
}
# Preserve devices (network interfaces) if they exist
if current_devices:
domain_patch['devices'] = current_devices
# Preserve machine type if it exists
if current_machine:
domain_patch['machine'] = current_machine
vm_patch = {
'spec': {
'template': {
'spec': {
'domain': domain_patch
}
}
}
}
# Use JSON merge patch (default) which preserves fields not mentioned
# But we explicitly include devices to ensure they're preserved
custom_objects_api.patch_namespaced_custom_object(
group="kubevirt.io",
version="v1",
namespace=namespace,
plural="virtualmachines",
name=server_id,
body=vm_patch
)
logger.info(f"✓ Updated VM {server_id} resources while preserving network interfaces")
except Exception as e:
logger.error(f"Error updating VM resources: {e}")
raise
# Update storage (PVC resize)
# Find the PVC name - it's usually {server_id}-rootdisk
pvc_name = f"{server_id}-rootdisk"
try:
pvc = v1.read_namespaced_persistent_volume_claim(name=pvc_name, namespace=namespace)
if pvc:
# Resize PVC
pvc_patch = {
'spec': {
'resources': {
'requests': {
'storage': storage_size
}
}
}
}
v1.patch_namespaced_persistent_volume_claim(
name=pvc_name,
namespace=namespace,
body=pvc_patch
)
logger.info(f"✓ Updated PVC {pvc_name} to {storage_size}")
except Exception as e:
logger.warning(f"Could not resize PVC {pvc_name}: {e}")
logger.info(f"✓ Updated VM {server_id} resources: CPU={cpu_cores}, RAM={memory_size}")
return True
elif server_type == 'game':
# Update Deployment resources
cpu_cores = int(new_cpu.split()[0])
ram_gb = int(re.search(r'(\d+)\s*GB', new_ram, re.IGNORECASE).group(1))
memory_size = f"{ram_gb}Gi"
# Parse storage size
storage_match = re.search(r'(\d+)\s*GB', new_storage, re.IGNORECASE)
if storage_match:
storage_gb = int(storage_match.group(1))
storage_size = f"{storage_gb}Gi"
else:
storage_match = re.search(r'(\d+)\s*GiB', new_storage, re.IGNORECASE)
if storage_match:
storage_size = f"{storage_match.group(1)}Gi"
else:
raise Exception(f"Invalid storage format: {new_storage}")
# Get current deployment
deployment = apps_v1.read_namespaced_deployment(name=server_id, namespace=namespace)
# Update container resources and environment variables
if deployment.spec.template.spec.containers:
container = deployment.spec.template.spec.containers[0]
container.resources = client.V1ResourceRequirements(
requests={
'memory': memory_size,
'cpu': f'{cpu_cores * 100}m'
},
limits={
'memory': memory_size,
'cpu': f'{cpu_cores * 1000}m'
}
)
# Update MEMORY environment variable if it exists
if container.env:
for env_var in container.env:
if env_var.name == 'MEMORY':
env_var.value = f"{ram_gb}G"
logger.info(f"✓ Updated MEMORY env var to {ram_gb}G for server {server_id}")
# Update deployment
apps_v1.patch_namespaced_deployment(
name=server_id,
namespace=namespace,
body=deployment
)
# Update storage (PVC resize)
pvc_name = f"{server_id}-data"
try:
pvc = v1.read_namespaced_persistent_volume_claim(name=pvc_name, namespace=namespace)
if pvc:
pvc_patch = {
'spec': {
'resources': {
'requests': {
'storage': storage_size
}
}
}
}
v1.patch_namespaced_persistent_volume_claim(
name=pvc_name,
namespace=namespace,
body=pvc_patch
)
logger.info(f"✓ Updated PVC {pvc_name} to {storage_size}")
except Exception as e:
logger.warning(f"Could not resize PVC {pvc_name}: {e}")
logger.info(f"✓ Updated Deployment {server_id} resources: CPU={cpu_cores}, RAM={memory_size}")
return True
else:
# For other server types (website, database), update deployment similarly
cpu_cores = int(new_cpu.split()[0])
ram_gb = int(re.search(r'(\d+)\s*GB', new_ram, re.IGNORECASE).group(1))
memory_size = f"{ram_gb}Gi"
try:
deployment = apps_v1.read_namespaced_deployment(name=server_id, namespace=namespace)
if deployment.spec.template.spec.containers:
container = deployment.spec.template.spec.containers[0]
container.resources = client.V1ResourceRequirements(
requests={
'memory': memory_size,
'cpu': f'{cpu_cores * 100}m'
},
limits={
'memory': memory_size,
'cpu': f'{cpu_cores * 1000}m'
}
)
# Update MEMORY environment variable if it exists
if container.env:
for env_var in container.env:
if env_var.name == 'MEMORY':
env_var.value = f"{ram_gb}G"
logger.info(f"✓ Updated MEMORY env var to {ram_gb}G for server {server_id}")
apps_v1.patch_namespaced_deployment(
name=server_id,
namespace=namespace,
body=deployment
)
logger.info(f"✓ Updated Deployment {server_id} resources")
return True
except Exception as e:
logger.error(f"Error updating deployment resources: {e}")
return False
except Exception as e:
logger.error(f"Error updating server resources in K8s: {e}")
return False
def adjust_server_parameters(server_id: str, user_id: str, new_cpu: str, new_ram: str, new_storage: str) -> Dict:
"""Adjust server parameters (upgrade/downgrade) with credit calculation"""
try:
# Get user first
user = get_user_by_id(user_id)
if not user:
raise Exception("User not found")
# Get server
server = get_server_by_id(server_id)
if not server:
raise Exception("Server 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")
# Step 1: Validate capacity for new resources BEFORE proceeding
new_server_config = {
'name': server['name'],
'type': server['type'],
'cpu': new_cpu,
'ram': new_ram,
'storage': new_storage
}
# Check if this is an upgrade (increased resources)
current_cpu = int(server['cpu'].split()[0])
current_ram = int(server['ram'].replace('GB', ''))
current_storage = int(server['storage'].replace('GB', ''))
new_cpu_val = int(new_cpu.split()[0])
new_ram_val = int(new_ram.replace('GB', ''))
new_storage_val = int(new_storage.replace('GB', ''))
is_upgrade = (new_cpu_val > current_cpu or new_ram_val > current_ram or new_storage_val > current_storage)
# Only check capacity if upgrading (increasing resources)
if is_upgrade:
logger.info(f"🔍 Validating capacity for server parameter upgrade: {server_id}")
capacity_check = validate_server_capacity(new_server_config)
if not capacity_check['success']:
error_msg = f"Insufficient capacity: {capacity_check['message']}"
logger.error(f"❌ Capacity validation failed: {error_msg}")
raise Exception(error_msg)
logger.info(f"✅ Capacity validation passed: {capacity_check['message']}")
# Calculate current monthly cost
current_monthly_cost = calculate_monthly_server_cost(server)
# Calculate new monthly cost
new_monthly_cost = calculate_monthly_server_cost(new_server_config)
# Calculate credit charge based on upgrade/downgrade
if is_upgrade:
# For upgrades: charge the difference between new and current monthly costs
# Ensure we never charge negative credits (safeguard)
credits_to_charge = max(0, new_monthly_cost - current_monthly_cost)
else:
# For downgrades: no charge
credits_to_charge = 0
logger.info(f"Server parameter adjustment for {server_id}:")
logger.info(f" Current monthly cost: {current_monthly_cost} credits")
logger.info(f" New monthly cost: {new_monthly_cost} credits")
logger.info(f" Credits to charge: {credits_to_charge} credits")
logger.info(f" Is upgrade: {is_upgrade}")
# Only check credits when UPGRADING (increasing resources)
# When downgrading, we should never check credits regardless of credits_to_charge value
if is_upgrade and credits_to_charge > 0:
user_credits = user.get('credits', 0)
if user_credits < credits_to_charge:
raise Exception(f'Insufficient credits. Need {credits_to_charge} credits, have {user_credits}')
# Deduct credits
new_credits = user_credits - credits_to_charge
if not update_user_credits(user_id, new_credits):
raise Exception('Failed to deduct credits')
add_credit_transaction(
user_id,
-credits_to_charge,
'server_upgrade',
f'Server upgrade: {server["name"]} - {credits_to_charge} credits'
)
logger.info(f"✓ Charged {credits_to_charge} credits for server upgrade")
elif not is_upgrade:
# For downgrades, never check credits - allow the downgrade regardless
logger.info(f"✓ Server downgrade: no credit check required (decreasing resources)")
elif credits_to_charge <= 0:
# credits_to_charge <= 0, no charge needed (edge case where upgrade doesn't require additional credits)
logger.info(f"✓ No credit adjustment needed")
# Update server in database
with database_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE servers
SET cpu = ?, ram = ?, storage = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (new_cpu, new_ram, new_storage, server_id))
conn.commit()
# Update server resources in Kubernetes
namespace = f"user-{user['username']}".lower()
if k8s_manager.connected:
if not update_server_resources_in_k8s(server, new_cpu, new_ram, new_storage, namespace):
logger.warning(f"Failed to update server resources in K8s, but database was updated")
# Restart server to apply changes
# Skip credit/capacity checks for downgrades (when credits_to_charge <= 0)
skip_checks = credits_to_charge <= 0
restart_server(server_id, user_id, skip_checks=skip_checks)
logger.info(f"✓ Successfully adjusted server parameters for {server_id}")
return {
'success': True,
'message': 'Server parameters adjusted successfully',
'credits_charged': credits_to_charge if credits_to_charge > 0 else 0,
'new_monthly_cost': new_monthly_cost,
'current_monthly_cost': current_monthly_cost,
'is_upgrade': is_upgrade
}
except Exception as e:
logger.error(f"Error adjusting server parameters: {e}")
raise Exception(f'Failed to adjust server parameters: {str(e)}')