Fix Kubernetes namespaces for dotted usernames
Build and publish Docker image / build-and-publish (push) Successful in 8s

This commit is contained in:
wfalkowski committed 2026-10-20 19:38:31 +00:00
1 parent 522e5c5634
commit 6732ee9d47
8 files changed
+70 -19

No files matched your search

+4 -3
View File
@@ -7,6 +7,7 @@ from domain_runtime import domain_manager
import json import json
from kubernetes.client.rest import ApiException from kubernetes.client.rest import ApiException
from kubernetes_service import custom_objects_api, k8s_manager from kubernetes_service import custom_objects_api, k8s_manager
from namespace_utils import user_namespace
import re import re
from runtime_config import ( from runtime_config import (
NODE_IP, NODE_IP,
@@ -325,7 +326,7 @@ def cleanup_orphaned_ingressroutes():
for domain, username in db_domains.items(): for domain, username in db_domains.items():
if not domain or not username: if not domain or not username:
continue continue
namespace = f"user-{username}".lower() namespace = user_namespace(username)
subdomain = domain.split('.')[0] subdomain = domain.split('.')[0]
ingressroute_name = f"{subdomain}-ingress" ingressroute_name = f"{subdomain}-ingress"
@@ -590,7 +591,7 @@ def link_domain_to_server(domain_id: str, server_id: str) -> bool:
return False return False
username = user['username'] username = user['username']
namespace = (server.get('namespace') or f"user-{username}").lower() namespace = server.get('namespace') or user_namespace(username)
domain_host = domain.get('full_domain') or domain.get('domain') domain_host = domain.get('full_domain') or domain.get('domain')
if not domain_host: if not domain_host:
logger.error("Domain record has no hostname: %s", domain_id) logger.error("Domain record has no hostname: %s", domain_id)
@@ -675,7 +676,7 @@ def unlink_domain_from_server(domain_id: str) -> bool:
if user: if user:
username = user['username'] username = user['username']
namespace = ((server or {}).get('namespace') or f"user-{username}").lower() namespace = (server or {}).get('namespace') or user_namespace(username)
# Delete IngressRoute in Kubernetes # Delete IngressRoute in Kubernetes
if not delete_traefik_ingressroute(domain=domain_host, namespace=namespace): if not delete_traefik_ingressroute(domain=domain_host, namespace=namespace):
+4 -2
View File
@@ -3,6 +3,7 @@ from datetime import datetime
from kubernetes import client, config from kubernetes import client, config
from kubernetes.client.rest import ApiException from kubernetes.client.rest import ApiException
from kubernetes.stream import stream from kubernetes.stream import stream
from namespace_utils import is_valid_namespace_name
import os import os
import random import random
from runtime_config import ( from runtime_config import (
@@ -62,8 +63,9 @@ class KubernetesManager:
return False return False
def create_namespace(self, namespace_name: str, labels: Dict = None) -> bool: def create_namespace(self, namespace_name: str, labels: Dict = None) -> bool:
# Kubernetes namespaces MUST be lowercase if not is_valid_namespace_name(namespace_name):
namespace_name = namespace_name.lower() logger.error("Refusing to create invalid Kubernetes namespace name: %r", namespace_name)
return False
if not self.connected: if not self.connected:
logger.info(f"⚠ Kubernetes not connected, skipping namespace creation for {namespace_name}") logger.info(f"⚠ Kubernetes not connected, skipping namespace creation for {namespace_name}")
+43
View File
@@ -0,0 +1,43 @@
"""Helpers for translating account names to Kubernetes namespace names."""
from __future__ import annotations
import hashlib
import re
_DNS_LABEL = re.compile(r"^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$")
_INVALID_DNS_LABEL_CHARS = re.compile(r"[^a-z0-9-]+")
def is_valid_namespace_name(namespace_name: str) -> bool:
"""Return whether a name meets Kubernetes DNS-label constraints."""
return (
isinstance(namespace_name, str)
and len(namespace_name) <= 63
and bool(_DNS_LABEL.fullmatch(namespace_name))
)
def user_namespace(username: str) -> str:
"""Return a stable, DNS-label-safe namespace for a username.
Existing valid usernames retain their current namespace. Names that need
normalization receive a short digest so normalization cannot silently
merge accounts such as ``miki.dz08`` and ``miki-dz08``.
"""
normalized_username = str(username).strip().lower()
if not normalized_username:
raise ValueError("Username must not be empty")
candidate = f"user-{normalized_username}"
if is_valid_namespace_name(candidate):
return candidate
safe_username = _INVALID_DNS_LABEL_CHARS.sub("-", normalized_username)
safe_username = re.sub(r"-+", "-", safe_username).strip("-") or "user"
digest = hashlib.sha256(normalized_username.encode("utf-8")).hexdigest()[:8]
suffix = f"-{digest}"
max_base_length = 63 - len("user-") - len(suffix)
safe_username = safe_username[:max_base_length].rstrip("-") or "user"
return f"user-{safe_username}{suffix}"
+2 -1
View File
@@ -6,6 +6,7 @@ from flask import Blueprint, jsonify, request
import json import json
from urllib.parse import urlsplit from urllib.parse import urlsplit
from kubernetes_service import k8s_manager from kubernetes_service import k8s_manager
from namespace_utils import user_namespace
import requests import requests
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from runtime_config import ( from runtime_config import (
@@ -379,7 +380,7 @@ def download_plugin_to_server():
plugin_response.close() plugin_response.close()
# Upload to server # Upload to server
namespace = f"user-{user['username']}".lower() namespace = user_namespace(user['username'])
pods = k8s_manager.get_pods_for_server(namespace, server_id) pods = k8s_manager.get_pods_for_server(namespace, server_id)
if not pods: if not pods:
+6 -5
View File
@@ -4,6 +4,7 @@
from database_service import database_connection from database_service import database_connection
from kubernetes import client from kubernetes import client
from kubernetes_service import apps_v1, custom_objects_api, k8s_manager, v1 from kubernetes_service import apps_v1, custom_objects_api, k8s_manager, v1
from namespace_utils import user_namespace
from runtime_config import ( from runtime_config import (
logger, logger,
) )
@@ -59,7 +60,7 @@ def delete_server(server_id: str, user_id: str) -> bool:
pass pass
return True return True
namespace = f"user-{owner['username']}".lower() namespace = server.get('namespace') or user_namespace(owner['username'])
try: try:
custom_objects_api.delete_namespaced_custom_object( custom_objects_api.delete_namespaced_custom_object(
@@ -165,7 +166,7 @@ def start_server(server_id: str, user_id: str) -> bool:
update_server_status(server_id, 'Running') update_server_status(server_id, 'Running')
return True return True
namespace = f"user-{owner['username']}".lower() namespace = server.get('namespace') or user_namespace(owner['username'])
try: try:
patch = {'spec': {'runStrategy': 'Always'}} patch = {'spec': {'runStrategy': 'Always'}}
@@ -232,7 +233,7 @@ def stop_server(server_id: str, user_id: str) -> bool:
update_server_status(server_id, 'Stopped') update_server_status(server_id, 'Stopped')
return True return True
namespace = f"user-{owner['username']}".lower() namespace = server.get('namespace') or user_namespace(owner['username'])
try: try:
patch = {'spec': {'runStrategy': 'Halted'}} patch = {'spec': {'runStrategy': 'Halted'}}
@@ -317,7 +318,7 @@ def restart_server(server_id: str, user_id: str, skip_checks: bool = False) -> b
update_server_status(server_id, 'Running') update_server_status(server_id, 'Running')
return True return True
namespace = f"user-{owner['username']}".lower() namespace = server.get('namespace') or user_namespace(owner['username'])
update_server_status(server_id, 'Restarting') update_server_status(server_id, 'Restarting')
try: try:
@@ -721,7 +722,7 @@ def adjust_server_parameters(server_id: str, user_id: str, new_cpu: str, new_ram
conn.commit() conn.commit()
# Update server resources in Kubernetes # Update server resources in Kubernetes
namespace = f"user-{user['username']}".lower() namespace = server.get('namespace') or user_namespace(user['username'])
if k8s_manager.connected: if k8s_manager.connected:
if not update_server_resources_in_k8s(server, new_cpu, new_ram, new_storage, namespace): 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") logger.warning(f"Failed to update server resources in K8s, but database was updated")
+5 -4
View File
@@ -6,6 +6,7 @@ from datetime import datetime, timedelta, timezone
import json import json
from kubernetes import client from kubernetes import client
from kubernetes_service import apps_v1, custom_objects_api, k8s_manager, v1 from kubernetes_service import apps_v1, custom_objects_api, k8s_manager, v1
from namespace_utils import user_namespace
import random import random
import re import re
from runtime_config import ( from runtime_config import (
@@ -75,7 +76,7 @@ def change_server_version(server_id: str, user_id: str, new_version: str, java_v
logger.info(f"✓ Changed version for server {server_id} to {new_version} (billing date unchanged)") logger.info(f"✓ Changed version for server {server_id} to {new_version} (billing date unchanged)")
return cursor.rowcount > 0 return cursor.rowcount > 0
namespace = f"user-{owner['username']}".lower() namespace = server.get('namespace') or user_namespace(owner['username'])
if not k8s_manager.connected: if not k8s_manager.connected:
# If K8s not connected, just update database # If K8s not connected, just update database
@@ -368,7 +369,7 @@ def reinstall_server(server_id: str, user_id: str) -> bool:
if not owner: if not owner:
raise Exception("Server owner not found") raise Exception("Server owner not found")
namespace = f"user-{owner['username']}".lower() namespace = server.get('namespace') or user_namespace(owner['username'])
if not k8s_manager.connected: if not k8s_manager.connected:
update_server_status(server_id, 'Running') update_server_status(server_id, 'Running')
@@ -1949,7 +1950,7 @@ def create_server(config: Dict, user_id: str, progress_callback=None) -> Dict:
'ip': NODE_IP, 'ip': NODE_IP,
'ports': ports, 'ports': ports,
'ownerId': user_id, 'ownerId': user_id,
'namespace': f"user-{user['username']}".lower(), 'namespace': user_namespace(user['username']),
'k8s_name': server_id, 'k8s_name': server_id,
'is_demo': is_demo, 'is_demo': is_demo,
'demo_expires_at': demo_expires_at, 'demo_expires_at': demo_expires_at,
@@ -1964,7 +1965,7 @@ def create_server(config: Dict, user_id: str, progress_callback=None) -> Dict:
server = create_server_record(server_data) server = create_server_record(server_data)
else: else:
namespace = f"user-{user['username']}".lower() namespace = user_namespace(user['username'])
sanitized_name = config['name'].lower().replace(' ', '-').replace('_', '-')[:20] sanitized_name = config['name'].lower().replace(' ', '-').replace('_', '-')[:20]
server_id = f"{sanitized_name}-{str(uuid.uuid4())[:8]}" server_id = f"{sanitized_name}-{str(uuid.uuid4())[:8]}"
+2 -1
View File
@@ -2,6 +2,7 @@
from database_service import database_connection, dict_from_row from database_service import database_connection, dict_from_row
from namespace_utils import user_namespace
import json import json
from runtime_config import ( from runtime_config import (
logger, logger,
@@ -116,7 +117,7 @@ def get_server_access_and_namespace(server_id: str, requester_id: str) -> Tuple[
if not owner: if not owner:
raise Exception("Server owner not found") raise Exception("Server owner not found")
namespace = f"user-{owner['username']}".lower() namespace = server.get('namespace') or user_namespace(owner['username'])
return namespace, server return namespace, server
def get_user_servers(user_id: str) -> List[Dict]: def get_user_servers(user_id: str) -> List[Dict]:
+4 -3
View File
@@ -13,6 +13,7 @@ from kubernetes import client
from kubernetes.client.rest import ApiException from kubernetes.client.rest import ApiException
from kubernetes.stream import stream from kubernetes.stream import stream
from kubernetes_service import custom_objects_api, k8s_manager, v1 from kubernetes_service import custom_objects_api, k8s_manager, v1
from namespace_utils import user_namespace
import os import os
import paramiko import paramiko
import pyotp import pyotp
@@ -1263,7 +1264,7 @@ async def handle_register(client_id, data):
if k8s_manager.connected: if k8s_manager.connected:
try: try:
k8s_manager.create_namespace(f"user-{username}".lower()) k8s_manager.create_namespace(user_namespace(username))
except Exception: except Exception:
pass pass
@@ -2051,7 +2052,7 @@ async def handle_upload_file(client_id, data):
}) })
return return
namespace = f"user-{user['username']}".lower() namespace = user_namespace(user['username'])
pods = k8s_manager.get_pods_for_server(namespace, server_id) pods = k8s_manager.get_pods_for_server(namespace, server_id)
if not pods: if not pods:
@@ -3776,7 +3777,7 @@ async def handle_delete_custom_domain(client_id, data):
# If domain was linked to a server, delete IngressRoute # If domain was linked to a server, delete IngressRoute
if domain.get('server_id'): if domain.get('server_id'):
namespace = f"user-{username}".lower() namespace = user_namespace(username)
# Extract subdomain for IngressRoute name # Extract subdomain for IngressRoute name
domain_name = domain['domain'] domain_name = domain['domain']
subdomain = domain_name.split('.')[0] if '.' in domain_name else domain_name subdomain = domain_name.split('.')[0] if '.' in domain_name else domain_name