fix(domains): provision routes when domains are linked
Build and publish Docker image / build-and-publish (push) Successful in 10s

This commit is contained in:
wfalkowski committed 2026-10-20 18:50:46 +00:00
1 parent 8cb6820bea
commit 9822e43592
3 files changed
+131 -154

No files matched your search

+68 -70
View File
@@ -12,10 +12,7 @@ from runtime_config import (
NODE_IP,
logger,
)
from server_records import get_server_by_id
import time
from typing import Dict, List, Optional
from user_service import get_user_by_id
import uuid
__all__ = ['cleanup_orphaned_ingressroutes', 'create_custom_domain_record', 'create_domain_record', 'create_traefik_ingressroute', 'delete_domain_record', 'delete_traefik_ingressroute', 'get_domain_by_id', 'get_user_custom_domains', 'get_user_domains', 'is_domain_available', 'link_domain_to_server', 'unlink_domain_from_server', 'verify_domain_dns']
@@ -23,7 +20,7 @@ __all__ = ['cleanup_orphaned_ingressroutes', 'create_custom_domain_record', 'cre
def create_custom_domain_record(domain_data: Dict) -> Optional[Dict]:
"""Create custom domain record in database and create IngressRoute if server_id is provided"""
"""Create a custom domain record, then link it if a server was selected."""
try:
with database_connection() as conn:
cursor = conn.cursor()
@@ -53,7 +50,7 @@ def create_custom_domain_record(domain_data: Dict) -> Optional[Dict]:
domain_data['record_type'],
domain_data['ip_address'],
domain_data.get('ttl', 3600),
domain_data.get('server_id'),
None,
domain_data.get('created_at', datetime.now().isoformat())
))
conn.commit()
@@ -61,35 +58,9 @@ def create_custom_domain_record(domain_data: Dict) -> Optional[Dict]:
cursor.execute('SELECT * FROM user_domains WHERE id = ?', (domain_id,))
domain = dict_from_row(cursor.fetchone())
# IMPORTANT: If server_id is provided, create IngressRoute
if domain and domain_data.get('server_id'):
# Get user and server information
user = get_user_by_id(domain_data['user_id'])
server = get_server_by_id(domain_data['server_id'])
if user and server:
namespace = f"user-{user['username']}".lower()
service_name = f"{domain_data['server_id']}-service"
# Get the correct port for the service
service_port = 80
if server['ports']:
ports_data = json.loads(server['ports']) if isinstance(server['ports'], str) else server['ports']
for port in ports_data:
if port['internal'] == 80:
service_port = 80
break
else:
service_port = ports_data[0]['internal'] if ports_data else 80
# Create IngressRoute for custom domain
logger.info(f"Creating IngressRoute for custom domain {domain_data['domain']}")
create_traefik_ingressroute(
domain=domain_data['domain'],
namespace=namespace,
service_name=service_name,
service_port=service_port
)
if link_domain_to_server(domain_id, domain_data['server_id']):
domain['server_id'] = domain_data['server_id']
return domain
@@ -249,7 +220,7 @@ def is_domain_available(domain: str, current_user_id: str) -> Dict:
def create_domain_record(domain_data: Dict) -> Optional[Dict]:
"""Create domain record in database"""
"""Create a domain record and provision its route when a server is selected."""
try:
with database_connection() as conn:
cursor = conn.cursor()
@@ -278,13 +249,17 @@ def create_domain_record(domain_data: Dict) -> Optional[Dict]:
domain_data['record_type'],
domain_data['ip_address'],
domain_data.get('ttl', 3600),
domain_data.get('server_id'),
None,
domain_data.get('created_at', datetime.now().isoformat())
))
conn.commit()
cursor.execute('SELECT * FROM user_domains WHERE id = ?', (domain_id,))
return dict_from_row(cursor.fetchone())
domain = dict_from_row(cursor.fetchone())
server_id = domain_data.get('server_id')
if domain and server_id and link_domain_to_server(domain_id, server_id):
domain['server_id'] = server_id
return domain
except Exception as e:
logger.error(f"Error creating domain record: {e}")
@@ -485,22 +460,33 @@ def create_traefik_ingressroute(domain: str, namespace: str, service_name: str,
}
}
# Try to delete existing IngressRoute first (if updating)
# Replace an existing route atomically so a failed update cannot drop
# a domain that is already serving traffic.
try:
custom_objects_api.delete_namespaced_custom_object(
existing = custom_objects_api.get_namespaced_custom_object(
group="traefik.io",
version="v1alpha1",
namespace=namespace,
plural="ingressroutes",
name=ingressroute_name
)
logger.info(f"Deleted existing IngressRoute: {ingressroute_name}")
time.sleep(1)
ingressroute_body['metadata']['resourceVersion'] = existing['metadata']['resourceVersion']
custom_objects_api.replace_namespaced_custom_object(
group="traefik.io",
version="v1alpha1",
namespace=namespace,
plural="ingressroutes",
name=ingressroute_name,
body=ingressroute_body
)
logger.info(f"Successfully updated IngressRoute: {ingressroute_name}")
return True
except ApiException as e:
if e.status != 404:
logger.warning(f"Error deleting existing IngressRoute: {e.reason}")
logger.error(f"Failed to read existing IngressRoute: {e.status} - {e.reason}")
return False
# Create new IngressRoute
# No prior route exists; create it.
try:
result = custom_objects_api.create_namespaced_custom_object(
group="traefik.io",
@@ -570,7 +556,7 @@ def delete_traefik_ingressroute(domain: str, namespace: str) -> bool:
return False
def link_domain_to_server(domain_id: str, server_id: str) -> bool:
"""Link a domain to a server and create IngressRoute"""
"""Create the Kubernetes route, then mark the domain as linked."""
try:
with database_connection() as conn:
cursor = conn.cursor()
@@ -591,6 +577,10 @@ def link_domain_to_server(domain_id: str, server_id: str) -> bool:
logger.error(f"Server not found: {server_id}")
return False
if server.get('type') not in ('website', 'vps'):
logger.error("Domains can only be linked to website and VPS servers")
return False
# Get user details for namespace
cursor.execute('SELECT username FROM users WHERE id = ?', (domain['user_id'],))
user = cursor.fetchone()
@@ -600,7 +590,11 @@ def link_domain_to_server(domain_id: str, server_id: str) -> bool:
return False
username = user['username']
namespace = f"user-{username}".lower()
namespace = (server.get('namespace') or f"user-{username}").lower()
domain_host = domain.get('full_domain') or domain.get('domain')
if not domain_host:
logger.error("Domain record has no hostname: %s", domain_id)
return False
# Determine service name and port
service_name = f"{server_id}-service"
@@ -621,33 +615,30 @@ def link_domain_to_server(domain_id: str, server_id: str) -> bool:
else:
service_port = 80
logger.info(f"Linking domain {domain['full_domain']} to server {server['name']}")
logger.info(f"Linking domain {domain_host} to server {server['name']}")
logger.info(f"Service: {service_name}, Port: {service_port}, Namespace: {namespace}")
# Update database
if not create_traefik_ingressroute(
domain=domain_host,
namespace=namespace,
service_name=service_name,
service_port=service_port
):
logger.error(f"Failed to create IngressRoute for {domain_host}; domain remains unlinked")
return False
cursor.execute('''
UPDATE user_domains
SET server_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (server_id, domain_id))
if cursor.rowcount != 1:
delete_traefik_ingressroute(domain_host, namespace)
conn.rollback()
return False
conn.commit()
# Create IngressRoute in Kubernetes
if k8s_manager.connected:
success = create_traefik_ingressroute(
domain=domain['full_domain'],
namespace=namespace,
service_name=service_name,
service_port=service_port
)
if not success:
logger.warning(f"Failed to create IngressRoute for {domain['full_domain']}, but database updated")
# Don't rollback database - domain is still linked, just no routing yet
else:
logger.warning("Kubernetes not connected, IngressRoute not created")
logger.info(f"✅ Successfully linked domain {domain['full_domain']} to server {server['name']}")
logger.info(f"Successfully linked domain {domain_host} to server {server['name']}")
return True
except Exception as e:
@@ -668,21 +659,28 @@ def unlink_domain_from_server(domain_id: str) -> bool:
logger.error(f"Domain not found: {domain_id}")
return False
domain_host = domain.get('full_domain') or domain.get('domain')
if not domain_host:
logger.error("Domain record has no hostname: %s", domain_id)
return False
server = None
if domain.get('server_id'):
cursor.execute('SELECT namespace FROM servers WHERE id = ?', (domain['server_id'],))
server = dict_from_row(cursor.fetchone())
# Get user details for namespace
cursor.execute('SELECT username FROM users WHERE id = ?', (domain['user_id'],))
user = cursor.fetchone()
if user:
username = user['username']
namespace = f"user-{username}".lower()
namespace = ((server or {}).get('namespace') or f"user-{username}").lower()
# Delete IngressRoute in Kubernetes
if k8s_manager.connected:
logger.info(f"Deleting IngressRoute for domain: {domain['full_domain']} in namespace: {namespace}")
delete_traefik_ingressroute(
domain=domain['full_domain'],
namespace=namespace
)
if not delete_traefik_ingressroute(domain=domain_host, namespace=namespace):
logger.error("Failed to remove IngressRoute for %s; domain remains linked", domain_host)
return False
# Update database to remove server link
cursor.execute('''
@@ -692,7 +690,7 @@ def unlink_domain_from_server(domain_id: str) -> bool:
''', (domain_id,))
conn.commit()
logger.info(f"✅ Successfully unlinked domain {domain['full_domain']} from server")
logger.info(f"Successfully unlinked domain {domain_host} from server")
return True
except Exception as e: