Refactor backend and add registry build workflow
Build and publish Docker image / build-and-publish (push) Successful in 2m45s

This commit is contained in:
wfalkowski committed 2026-10-20 16:44:49 +00:00
commit c177d7981d
43 files changed
+18124

No files matched your search

+674
View File
@@ -0,0 +1,674 @@
"""Server access operations."""
from database_service import database_connection
from datetime import datetime
import json
from kubernetes import client
from kubernetes.client.rest import ApiException
from kubernetes_service import apps_v1, k8s_manager, v1
import os
import random
import re
from runtime_config import (
logger,
)
from server_provisioning import generate_sftp_credentials, get_sftp_sidecar_container
from server_records import get_server_access_and_namespace, get_server_by_id
from typing import Dict, List
__all__ = ['create_server_directory', 'delete_server_path', 'download_server_file', 'exec_server_command', 'expose_port', 'get_server_logs', 'get_server_metrics', 'list_server_files', 'read_server_file', 'read_server_file_base64', 'reconcile_sftp', 'remove_port', 'upload_server_file', 'validate_path_for_server', 'write_server_file']
def expose_port(server_id: str, user_id: str, internal_port: int, protocol: str = 'TCP') -> bool:
"""Expose a new port for a server"""
try:
namespace, server = get_server_access_and_namespace(server_id, user_id)
if not k8s_manager.connected:
logger.info("Kubernetes not connected, simulating port exposure")
# Update database with new port
if server:
ports = server['ports']
external_port = random.randint(30000, 32768)
ports.append({
'internal': internal_port,
'external': external_port,
'protocol': protocol,
'name': f'port-{internal_port}-{protocol}'.lower()
})
with database_connection() as conn:
cursor = conn.cursor()
cursor.execute(
'UPDATE servers SET ports = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
(json.dumps(ports), server_id)
)
conn.commit()
return True
service_name = f"{server_id}-service"
# Get existing service
try:
service = v1.read_namespaced_service(name=service_name, namespace=namespace)
except ApiException as e:
if e.status == 404:
# Service doesn't exist, create it
external_port = random.randint(30000, 32768)
service_ports = [
client.V1ServicePort(
name=f"port-{internal_port}-{protocol}".lower(),
port=internal_port,
target_port=internal_port,
node_port=external_port,
protocol=protocol
)
]
service = client.V1Service(
metadata=client.V1ObjectMeta(
name=service_name,
namespace=namespace,
labels={'app': server_id}
),
spec=client.V1ServiceSpec(
selector={'app': server_id},
type='NodePort',
ports=service_ports
)
)
created_service = v1.create_namespaced_service(namespace=namespace, body=service)
# Get the actual assigned node port
actual_external_port = created_service.spec.ports[0].node_port
# Update database
server = get_server_by_id(server_id)
if server:
ports = [{
'internal': internal_port,
'external': actual_external_port,
'protocol': protocol,
'name': f'port-{internal_port}-{protocol}'.lower()
}]
with database_connection() as conn:
cursor = conn.cursor()
cursor.execute(
'UPDATE servers SET ports = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
(json.dumps(ports), server_id)
)
conn.commit()
return True
else:
raise e
# Check if port already exists
for port in service.spec.ports:
if port.port == internal_port and port.protocol == protocol:
return True # Port already exposed
# Add new port
external_port = random.randint(30000, 32768)
new_port = client.V1ServicePort(
name=f"port-{internal_port}-{protocol}".lower(),
port=internal_port,
target_port=internal_port,
node_port=external_port,
protocol=protocol
)
service.spec.ports.append(new_port)
updated_service = v1.patch_namespaced_service(name=service_name, namespace=namespace, body=service)
# Get the actual assigned node port
actual_external_port = None
for port in updated_service.spec.ports:
if port.port == internal_port and port.protocol == protocol:
actual_external_port = port.node_port
break
# Update server ports in database
server = get_server_by_id(server_id)
if server:
ports = server['ports']
ports.append({
'internal': internal_port,
'external': actual_external_port or external_port,
'protocol': protocol,
'name': f'port-{internal_port}-{protocol}'.lower()
})
with database_connection() as conn:
cursor = conn.cursor()
cursor.execute(
'UPDATE servers SET ports = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
(json.dumps(ports), server_id)
)
conn.commit()
return True
except Exception as e:
logger.error(f"Error exposing port: {e}")
raise Exception(f'Failed to expose port: {str(e)}')
def remove_port(server_id: str, user_id: str, internal_port: int, protocol: str = 'TCP') -> bool:
"""Remove an exposed port from a server"""
try:
namespace, server = get_server_access_and_namespace(server_id, user_id)
if not k8s_manager.connected:
logger.info("Kubernetes not connected, simulating port removal")
if server:
ports = server['ports']
new_ports = [p for p in ports if not (p['internal'] == internal_port and p['protocol'] == protocol)]
with database_connection() as conn:
cursor = conn.cursor()
cursor.execute(
'UPDATE servers SET ports = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
(json.dumps(new_ports), server_id)
)
conn.commit()
return True
service_name = f"{server_id}-service"
# Get existing service
try:
service = v1.read_namespaced_service(name=service_name, namespace=namespace)
except ApiException as e:
if e.status == 404:
return True # Service doesn't exist, nothing to remove
else:
raise e
# Remove port from service - FIXED: Check both port number and protocol
service.spec.ports = [p for p in service.spec.ports if not (p.port == internal_port and p.protocol == protocol)]
# If no ports left, delete the service
if not service.spec.ports:
v1.delete_namespaced_service(name=service_name, namespace=namespace)
else:
# Update the service with remaining ports - Use replace instead of patch for reliable array updates
# Clear managed_fields to avoid conflicts with server-side apply
if hasattr(service.metadata, 'managed_fields') and service.metadata.managed_fields:
service.metadata.managed_fields = None
v1.replace_namespaced_service(name=service_name, namespace=namespace, body=service)
# Update server ports in database
server = get_server_by_id(server_id)
if server:
ports = [p for p in server['ports'] if not (p['internal'] == internal_port and p['protocol'] == protocol)]
with database_connection() as conn:
cursor = conn.cursor()
cursor.execute(
'UPDATE servers SET ports = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
(json.dumps(ports), server_id)
)
conn.commit()
logger.info(f"✅ Successfully removed port {internal_port}/{protocol} from server {server_id}")
return True
except Exception as e:
logger.error(f"Error removing port: {e}")
raise Exception(f'Failed to remove port: {str(e)}')
def get_server_logs(server_id: str, user_id: str) -> str:
try:
namespace, _ = get_server_access_and_namespace(server_id, user_id)
if not k8s_manager.connected:
return f"Mock logs for {server_id}"
pods = k8s_manager.get_pods_for_server(namespace, server_id)
if not pods:
return f"No pods found"
logs = k8s_manager.get_pod_logs(namespace, pods[0], container_name=server_id)
return logs
except Exception as e:
return f"Error: {str(e)}"
def reconcile_sftp(server_id: str, user_id: str) -> bool:
"""Ensure SFTP sidecar and port are correctly configured for a server"""
try:
logger.info(f"Checking SFTP reconciliation for server {server_id}")
namespace, server = get_server_access_and_namespace(server_id, user_id)
if server['type'] == 'vps' or not k8s_manager.connected:
logger.info(f"Skipping SFTP for {server_id}: type={server['type']}, k8s_connected={k8s_manager.connected}")
return True
# 1. Ensure credentials exist
u = server.get('sftp_username')
p = server.get('sftp_password')
port = server.get('sftp_port')
if not u or not p or not port:
logger.info(f"Generating missing SFTP credentials for {server_id}")
u, p, port = generate_sftp_credentials()
with database_connection() as conn:
cursor = conn.cursor()
cursor.execute(
'UPDATE servers SET sftp_username = ?, sftp_password = ?, sftp_port = ? WHERE id = ?',
(u, p, port, server_id)
)
conn.commit()
server['sftp_username'] = u
server['sftp_password'] = p
server['sftp_port'] = port
# 2. Ensure sidecar in Deployment
try:
deployment = apps_v1.read_namespaced_deployment(name=server_id, namespace=namespace)
containers = deployment.spec.template.spec.containers
sftp_container_index = next((i for i, c in enumerate(containers) if c.name == 'sftp'), -1)
# Determine mount path based on image
mount_path = '/data'
if server['image'] == 'nginx': mount_path = '/usr/share/nginx/html'
elif server['image'] in ['wordpress', 'wordpress-sqlite', 'node']: mount_path = '/var/www/html'
elif server['image'] == 'unturned': mount_path = '/home/steam/Unturned'
elif server['image'] == 'factorio': mount_path = '/factorio'
elif server['image'] == 'hytale': mount_path = '/home/container'
elif server['image'] == 'zomboid': mount_path = '/home/steam/Zomboid'
sftp_container = get_sftp_sidecar_container(server_id, u, p, mount_path)
container_spec = client.ApiClient().sanitize_for_serialization(sftp_container)
if sftp_container_index == -1:
logger.info(f"Adding SFTP sidecar to deployment {server_id}")
patch = [
{
"op": "add",
"path": "/spec/template/spec/containers/-",
"value": container_spec
}
]
apps_v1.patch_namespaced_deployment(
name=server_id,
namespace=namespace,
body=patch,
_content_type='application/json-patch+json'
)
logger.info(f"✓ Successfully added SFTP sidecar to deployment {server_id}")
else:
# Update existing sidecar if needed (check if env, image or security_context differ)
existing_container = containers[sftp_container_index]
needs_update = False
# Check SFTP_USERS env
existing_sftp_users = next((env.value for env in existing_container.env if env.name == 'SFTP_USERS'), None)
expected_sftp_users = f"{u}:{p}:1000:1000:data"
# Check image
existing_image = existing_container.image
if existing_sftp_users != expected_sftp_users or existing_image != "atmoz/sftp:alpine":
logger.info(f"Updating SFTP configuration for {server_id} (image or credentials changed)")
needs_update = True
if needs_update:
patch = [
{
"op": "replace",
"path": f"/spec/template/spec/containers/{sftp_container_index}",
"value": container_spec
}
]
apps_v1.patch_namespaced_deployment(
name=server_id,
namespace=namespace,
body=patch,
_content_type='application/json-patch+json'
)
logger.info(f"✓ Successfully updated SFTP sidecar for deployment {server_id}")
else:
logger.info(f"SFTP sidecar already exists and is up to date for {server_id}")
except Exception as e:
logger.error(f"Error reconciling SFTP sidecar for {server_id}: {e}")
# 3. Ensure port in Service
try:
service_name = f"{server_id}-service"
service = v1.read_namespaced_service(name=service_name, namespace=namespace)
ports = service.spec.ports
sftp_port_exists = any(p.name == 'sftp' for p in ports)
if not sftp_port_exists:
logger.info(f"Adding SFTP port {port} to service {service_name}")
sftp_service_port = client.V1ServicePort(
name="sftp",
port=22,
target_port=22,
node_port=int(port),
protocol="TCP"
)
port_spec = client.ApiClient().sanitize_for_serialization(sftp_service_port)
patch = [
{
"op": "add",
"path": "/spec/ports/-",
"value": port_spec
}
]
v1.patch_namespaced_service(
name=service_name,
namespace=namespace,
body=patch,
_content_type='application/json-patch+json'
)
logger.info(f"✓ Successfully added SFTP port to service {service_name}")
else:
logger.info(f"SFTP port already exists for {service_name}")
except Exception as e:
logger.error(f"Error reconciling SFTP port for {server_id}: {e}")
return True
except Exception as e:
logger.error(f"Critical error in reconcile_sftp for {server_id}: {e}")
return False
def get_server_metrics(server_id: str, user_id: str) -> Dict:
try:
namespace, server = get_server_access_and_namespace(server_id, user_id)
server_ram_gb = 1
server_cpu_cores = 1
server_ram_gb = int(server['ram'].replace('GB', '')) if 'GB' in server['ram'] else 1
# Parse CPU cores from server config
cpu_str = server.get('cpu', '1')
if 'vCPU' in cpu_str:
server_cpu_cores = int(cpu_str.split()[0]) if cpu_str.split()[0].isdigit() else 1
else:
match = re.search(r'(\d+)', cpu_str)
if match:
server_cpu_cores = int(match.group(1))
pods = k8s_manager.get_pods_for_server(namespace, server_id)
if not pods:
# Use fallback metrics when no pods found
cpu_millicores = random.randint(50, 300)
memory_mebibytes = random.randint(128, 1024)
max_cpu_millicores = server_cpu_cores * 1000
cpu_percentage = min((cpu_millicores / max_cpu_millicores) * 100, 100) if max_cpu_millicores > 0 else 0
memory_percentage = min((memory_mebibytes / (server_ram_gb * 1024)) * 100, 100)
return {
'cpu': f'{cpu_millicores}m',
'memory': f'{memory_mebibytes}Mi',
'cpu_percentage': round(cpu_percentage, 1),
'memory_percentage': round(memory_percentage, 1),
'timestamp': datetime.now().isoformat()
}
metrics = k8s_manager.get_pod_metrics(namespace, pods[0], server_ram_gb, server_cpu_cores)
return metrics
except Exception as e:
# Use fallback metrics on error
cpu_millicores = random.randint(50, 300)
memory_mebibytes = random.randint(128, 1024)
server_cpu_cores = 1 # Default fallback
server_ram_gb = 1 # Default fallback
max_cpu_millicores = server_cpu_cores * 1000
cpu_percentage = min((cpu_millicores / max_cpu_millicores) * 100, 100) if max_cpu_millicores > 0 else 0
memory_percentage = min((memory_mebibytes / (server_ram_gb * 1024)) * 100, 100)
return {
'cpu': f'{cpu_millicores}m',
'memory': f'{memory_mebibytes}Mi',
'cpu_percentage': round(cpu_percentage, 1),
'memory_percentage': round(memory_percentage, 1),
'error': str(e),
'timestamp': datetime.now().isoformat()
}
def validate_path_for_server(server_id: str, path: str) -> str:
"""Validate and normalize path for server, restricting servers to their data directories"""
server = get_server_by_id(server_id)
if not server:
return path
server_type = server.get('type')
image = server.get('image')
# Image to base path mapping
base_paths = {
# Game servers
'minecraft': '/data',
'rust': '/data',
'unturned': '/home/steam/Unturned',
'factorio': '/factorio',
'hytale': '/home/container',
'zomboid': '/home/steam/Zomboid',
# Website servers
'nginx': '/usr/share/nginx/html',
'wordpress': '/var/www/html',
'node': '/var/www/html'
}
# Determine base path
if server_type == 'game':
base_path = base_paths.get(image, '/data')
elif server_type == 'website':
base_path = base_paths.get(image, '/var/www/html')
else:
# For VPS or other types, allow full path or define specific root if needed
return path
# If path is relative, make it absolute starting from base_path
if not path.startswith('/'):
path = os.path.join(base_path, path)
elif not path.startswith(base_path):
# If it's absolute but doesn't start with base_path, prefix it with base_path
# e.g. /etc/config -> /data/etc/config
path = os.path.join(base_path, path.lstrip('/'))
# Ensure path doesn't go above base path
normalized = os.path.normpath(path)
if not normalized.startswith(base_path):
return base_path
return normalized
def list_server_files(server_id: str, user_id: str, path: str = "/") -> List[Dict]:
"""List files in server with improved error handling"""
try:
# Validate path for Minecraft servers
path = validate_path_for_server(server_id, path)
namespace, _ = get_server_access_and_namespace(server_id, user_id)
if not k8s_manager.connected:
# Return mock data for development
return [
{"name": "eula.txt", "type": "file", "size": 1024, "modified": "2024-01-01T00:00:00Z"},
{"name": "server.properties", "type": "file", "size": 2048, "modified": "2024-01-01T00:00:00Z"},
{"name": "logs", "type": "directory", "size": 4096, "modified": "2024-01-01T00:00:00Z"},
{"name": "world", "type": "directory", "size": 4096, "modified": "2024-01-01T00:00:00Z"}
]
pods = k8s_manager.get_pods_for_server(namespace, server_id)
if not pods:
return []
return k8s_manager.list_files(namespace, pods[0], path, container_name=server_id)
except Exception as e:
logger.error(f"Error listing server files: {e}")
return []
def read_server_file(server_id: str, user_id: str, file_path: str) -> str:
try:
# Validate path for Minecraft servers
file_path = validate_path_for_server(server_id, file_path)
namespace, _ = get_server_access_and_namespace(server_id, user_id)
if not k8s_manager.connected:
return ""
pods = k8s_manager.get_pods_for_server(namespace, server_id)
if not pods:
return ""
return k8s_manager.read_file(namespace, pods[0], file_path, container_name=server_id)
except Exception:
return ""
def read_server_file_base64(server_id: str, user_id: str, file_path: str) -> str:
"""Read server file as base64 encoded string"""
try:
# Validate path for Minecraft servers
file_path = validate_path_for_server(server_id, file_path)
namespace, _ = get_server_access_and_namespace(server_id, user_id)
if not k8s_manager.connected:
return ""
pods = k8s_manager.get_pods_for_server(namespace, server_id)
if not pods:
return ""
return k8s_manager.read_file_base64(namespace, pods[0], file_path, container_name=server_id)
except Exception:
return ""
def write_server_file(server_id: str, user_id: str, file_path: str, content: str) -> bool:
try:
# Validate path for Minecraft servers
file_path = validate_path_for_server(server_id, file_path)
namespace, _ = get_server_access_and_namespace(server_id, user_id)
if not k8s_manager.connected:
return True
pods = k8s_manager.get_pods_for_server(namespace, server_id)
if not pods:
return False
return k8s_manager.write_file(namespace, pods[0], file_path, content, container_name=server_id)
except Exception:
return False
def create_server_directory(server_id: str, user_id: str, dir_path: str) -> bool:
"""Create directory in server container"""
try:
# Validate path for Minecraft servers
dir_path = validate_path_for_server(server_id, dir_path)
namespace, _ = get_server_access_and_namespace(server_id, user_id)
if not k8s_manager.connected:
return True
pods = k8s_manager.get_pods_for_server(namespace, server_id)
if not pods:
return False
return k8s_manager.create_directory(namespace, pods[0], dir_path, container_name=server_id)
except Exception:
return False
def delete_server_path(server_id: str, user_id: str, path: str) -> bool:
"""Delete file or directory in server container"""
try:
# Validate path for Minecraft servers
path = validate_path_for_server(server_id, path)
namespace, _ = get_server_access_and_namespace(server_id, user_id)
if not k8s_manager.connected:
return True
pods = k8s_manager.get_pods_for_server(namespace, server_id)
if not pods:
return False
return k8s_manager.delete_path(namespace, pods[0], path, container_name=server_id)
except Exception:
return False
def upload_server_file(server_id: str, user_id: str, file_path: str, file_content: str, is_base64: bool = True) -> bool:
"""Upload file to server container (now uses binary upload for all files)"""
try:
# Validate path for Minecraft servers
file_path = validate_path_for_server(server_id, file_path)
namespace, _ = get_server_access_and_namespace(server_id, user_id)
if not k8s_manager.connected:
return True
pods = k8s_manager.get_pods_for_server(namespace, server_id)
if not pods:
return False
# Use write_file_binary for all files to support binary files
return k8s_manager.write_file_binary(namespace, pods[0], file_path, file_content, container_name=server_id)
except Exception as e:
logger.error(f"Error in upload_server_file: {e}")
return False
def download_server_file(server_id: str, user_id: str, file_path: str) -> str:
"""Download file from server container"""
try:
# Validate path for Minecraft servers
file_path = validate_path_for_server(server_id, file_path)
namespace, _ = get_server_access_and_namespace(server_id, user_id)
if not k8s_manager.connected:
logger.warning("K8s manager not connected, cannot download file")
return ""
pods = k8s_manager.get_pods_for_server(namespace, server_id)
if not pods:
logger.error(f"No pods found for server {server_id}")
return ""
return k8s_manager.download_file(namespace, pods[0], file_path, container_name=server_id)
except Exception as e:
logger.error(f"Error downloading file {file_path} from server {server_id}: {e}", exc_info=True)
return ""
def exec_server_command(server_id: str, user_id: str, command: str) -> str:
"""Execute command in server container"""
try:
namespace, _ = get_server_access_and_namespace(server_id, user_id)
if not k8s_manager.connected:
return f"$ {command}\nMock command execution completed"
pods = k8s_manager.get_pods_for_server(namespace, server_id)
if not pods:
return ""
return k8s_manager.exec_command(namespace, pods[0], ['sh', '-c', command], container_name=server_id)
except Exception:
return ""