mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-21 01:53:24 +00:00
feat: Add HAProxy proxy name collision prevention system
- Add preserved_listen_blocks column to agents table for storing agent's local listen block names - Implement reserved names check (stats, monitoring, admin, etc.) for frontend/backend creation - Add dynamic collision detection against agent's preserved listen blocks - Apply collision checks to CREATE, UPDATE endpoints and bulk import - Add debug mode for failed config validation (saves to /tmp/haproxy-failed-*.cfg) - Fix JSON character stripping for ACL and use_backend rules - Remove collision protection from agent scripts (now handled by backend) - All collision checks wrapped in try-except for backwards compatibility
This commit is contained in:
@@ -1559,6 +1559,7 @@ async def run_all_migrations():
|
||||
await add_options_to_frontends()
|
||||
await add_ssl_advanced_options_to_frontends()
|
||||
await add_ssl_advanced_options_to_servers()
|
||||
await add_preserved_listen_blocks_to_agents()
|
||||
|
||||
logger.info("Database migrations completed successfully.")
|
||||
|
||||
@@ -2915,3 +2916,47 @@ async def add_ssl_advanced_options_to_servers():
|
||||
await close_database_connection(conn)
|
||||
logger.error(f"❌ Error adding SSL advanced options to servers: {e}")
|
||||
# Don't raise - we'll try to proceed
|
||||
|
||||
async def add_preserved_listen_blocks_to_agents():
|
||||
"""
|
||||
Add preserved_listen_blocks column to agents table.
|
||||
This stores the listen block names from agent's local haproxy.cfg
|
||||
so backend can detect potential naming collisions before entity creation.
|
||||
|
||||
COLLISION PREVENTION:
|
||||
- Agent sends its current config via config-sync
|
||||
- Backend parses listen blocks and stores their names here
|
||||
- When creating frontend/backend, check against this list for collisions
|
||||
- Prevents HAProxy "proxy has same name" errors at runtime
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
conn = await get_database_connection()
|
||||
|
||||
# Check if preserved_listen_blocks column exists
|
||||
column_exists = await conn.fetchval("""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'agents'
|
||||
AND column_name = 'preserved_listen_blocks'
|
||||
)
|
||||
""")
|
||||
|
||||
if not column_exists:
|
||||
await conn.execute("""
|
||||
ALTER TABLE agents
|
||||
ADD COLUMN preserved_listen_blocks JSONB DEFAULT '[]'::jsonb
|
||||
""")
|
||||
logger.info("✅ Added preserved_listen_blocks column to agents table")
|
||||
logger.info(" This column stores listen block names from agent's local config")
|
||||
logger.info(" Used for collision detection when creating new entities")
|
||||
else:
|
||||
logger.info("ℹ️ preserved_listen_blocks column already exists in agents table")
|
||||
|
||||
await close_database_connection(conn)
|
||||
|
||||
except Exception as e:
|
||||
if conn:
|
||||
await close_database_connection(conn)
|
||||
logger.error(f"❌ Error adding preserved_listen_blocks column: {e}")
|
||||
# Don't raise - not critical for system operation
|
||||
|
||||
@@ -935,12 +935,56 @@ async def agent_config_sync(agent_name: str, sync_data: dict, x_api_key: Optiona
|
||||
return {"status": "error", "message": "Agent or cluster not found"}
|
||||
|
||||
cluster_id = agent_info['cluster_id']
|
||||
agent_id = agent_info['id']
|
||||
config_content = sync_data.get('config_content', '')
|
||||
|
||||
if not config_content:
|
||||
await close_database_connection(conn)
|
||||
return {"status": "error", "message": "No config content provided"}
|
||||
|
||||
# ========================================================================
|
||||
# COLLISION PREVENTION: Extract and save listen block names
|
||||
# Agent's local listen blocks can conflict with frontend/backend names
|
||||
# By storing them, we can detect collisions BEFORE entity creation
|
||||
# ========================================================================
|
||||
preserved_listen_blocks = []
|
||||
|
||||
for line in config_content.split('\n'):
|
||||
line_stripped = line.strip()
|
||||
# Parse listen block definitions (e.g., "listen stats")
|
||||
if line_stripped.startswith('listen '):
|
||||
listen_name = line_stripped.split()[1] if len(line_stripped.split()) > 1 else None
|
||||
if listen_name:
|
||||
preserved_listen_blocks.append(listen_name)
|
||||
logger.debug(f"CONFIG-SYNC: Found listen block '{listen_name}' in agent {agent_name}")
|
||||
|
||||
# Save preserved listen blocks to agents table for collision detection
|
||||
if preserved_listen_blocks:
|
||||
try:
|
||||
import json
|
||||
await conn.execute("""
|
||||
UPDATE agents
|
||||
SET preserved_listen_blocks = $1::jsonb
|
||||
WHERE id = $2
|
||||
""", json.dumps(preserved_listen_blocks), agent_id)
|
||||
logger.info(f"CONFIG-SYNC: Agent '{agent_name}' has {len(preserved_listen_blocks)} listen blocks: {preserved_listen_blocks}")
|
||||
except Exception as e:
|
||||
logger.warning(f"CONFIG-SYNC: Failed to save listen blocks for agent {agent_name}: {e}")
|
||||
else:
|
||||
# Clear listen blocks if agent has none
|
||||
try:
|
||||
await conn.execute("""
|
||||
UPDATE agents
|
||||
SET preserved_listen_blocks = '[]'::jsonb
|
||||
WHERE id = $1
|
||||
""", agent_id)
|
||||
except:
|
||||
pass
|
||||
|
||||
# ========================================================================
|
||||
# END COLLISION PREVENTION
|
||||
# ========================================================================
|
||||
|
||||
# Parse config content to extract all configuration entities
|
||||
active_servers = []
|
||||
active_backends = []
|
||||
|
||||
@@ -525,6 +525,54 @@ async def create_backend(backend: BackendConfig, authorization: str = Header(Non
|
||||
await conn.execute("DELETE FROM backends WHERE id = $1", inactive_backend['id'])
|
||||
logger.info(f"BACKEND CREATE: Cleaned up inactive backend '{backend.name}' and all related data")
|
||||
|
||||
# CRITICAL: Check for reserved names that conflict with common HAProxy listen sections
|
||||
# Agent preserves existing listen blocks (e.g., 'listen stats') from local config
|
||||
# Creating backends with these names causes "proxy has same name" errors
|
||||
reserved_names = {'stats', 'haproxy-stats', 'haproxy_stats', 'monitoring', 'admin', 'health', 'status'}
|
||||
if backend.name.lower() in reserved_names:
|
||||
await close_database_connection(conn)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Backend name '{backend.name}' is reserved. It conflicts with common HAProxy "
|
||||
f"listen sections (e.g., 'listen stats'). Please choose a different name."
|
||||
)
|
||||
|
||||
# DYNAMIC COLLISION CHECK: Check against agents' preserved listen blocks
|
||||
# Agents report their local listen blocks via config-sync, we check for conflicts here
|
||||
# NOTE: Wrapped in try-except for backwards compatibility (column may not exist before migration)
|
||||
if backend.cluster_id:
|
||||
try:
|
||||
collision_check = await conn.fetch("""
|
||||
SELECT a.name as agent_name, a.preserved_listen_blocks
|
||||
FROM agents a
|
||||
JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id
|
||||
WHERE hc.id = $1 AND a.preserved_listen_blocks IS NOT NULL
|
||||
""", backend.cluster_id)
|
||||
|
||||
for agent in collision_check:
|
||||
listen_blocks = agent['preserved_listen_blocks'] or []
|
||||
if isinstance(listen_blocks, str):
|
||||
try:
|
||||
listen_blocks = json.loads(listen_blocks)
|
||||
except:
|
||||
listen_blocks = []
|
||||
|
||||
# Case-insensitive comparison (HAProxy proxy names are case-insensitive)
|
||||
listen_blocks_lower = [lb.lower() for lb in listen_blocks if isinstance(lb, str)]
|
||||
if backend.name.lower() in listen_blocks_lower:
|
||||
await close_database_connection(conn)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Backend name '{backend.name}' conflicts with an existing 'listen {backend.name}' "
|
||||
f"block on agent '{agent['agent_name']}'. Either rename this backend or remove the "
|
||||
f"listen block from the agent's local HAProxy configuration."
|
||||
)
|
||||
except HTTPException:
|
||||
raise # Re-raise HTTP exceptions (collision detected)
|
||||
except Exception as e:
|
||||
# Column may not exist yet (before migration) - skip check gracefully
|
||||
logger.debug(f"Dynamic collision check skipped: {e}")
|
||||
|
||||
# Check if backend name already exists in the same cluster (only active backends)
|
||||
existing = await conn.fetchrow("""
|
||||
SELECT id FROM backends
|
||||
@@ -874,6 +922,60 @@ async def update_backend(backend_id: int, backend_update: BackendConfigUpdate, r
|
||||
new_backend_name = update_data.get('name', old_backend_name)
|
||||
backend_name_changed = old_backend_name != new_backend_name
|
||||
|
||||
# CRITICAL: Validate new name if backend is being renamed
|
||||
if backend_name_changed:
|
||||
# Check for reserved names
|
||||
reserved_names = {'stats', 'haproxy-stats', 'haproxy_stats', 'monitoring', 'admin', 'health', 'status'}
|
||||
if new_backend_name.lower() in reserved_names:
|
||||
await close_database_connection(conn)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Backend name '{new_backend_name}' is reserved. It conflicts with common HAProxy "
|
||||
f"listen sections (e.g., 'listen stats'). Please choose a different name."
|
||||
)
|
||||
|
||||
# Check for collision with agent listen blocks
|
||||
# NOTE: Wrapped in try-except for backwards compatibility
|
||||
if cluster_id:
|
||||
try:
|
||||
collision_check = await conn.fetch("""
|
||||
SELECT a.name as agent_name, a.preserved_listen_blocks
|
||||
FROM agents a
|
||||
JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id
|
||||
WHERE hc.id = $1 AND a.preserved_listen_blocks IS NOT NULL
|
||||
""", cluster_id)
|
||||
|
||||
for agent in collision_check:
|
||||
listen_blocks = agent['preserved_listen_blocks'] or []
|
||||
if isinstance(listen_blocks, str):
|
||||
try:
|
||||
listen_blocks = json.loads(listen_blocks)
|
||||
except:
|
||||
listen_blocks = []
|
||||
|
||||
# Case-insensitive comparison (HAProxy proxy names are case-insensitive)
|
||||
listen_blocks_lower = [lb.lower() for lb in listen_blocks if isinstance(lb, str)]
|
||||
if new_backend_name.lower() in listen_blocks_lower:
|
||||
await close_database_connection(conn)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Backend name '{new_backend_name}' conflicts with an existing 'listen' block "
|
||||
f"on agent '{agent['agent_name']}'. Choose a different name."
|
||||
)
|
||||
except HTTPException:
|
||||
raise # Re-raise HTTP exceptions
|
||||
except Exception as e:
|
||||
logger.debug(f"Dynamic collision check skipped on update: {e}")
|
||||
|
||||
# Check if new name already exists
|
||||
name_exists = await conn.fetchrow(
|
||||
"SELECT id FROM backends WHERE name = $1 AND id != $2",
|
||||
new_backend_name, backend_id
|
||||
)
|
||||
if name_exists:
|
||||
await close_database_connection(conn)
|
||||
raise HTTPException(status_code=400, detail=f"Backend name '{new_backend_name}' already exists")
|
||||
|
||||
# Filter out 'option httpchk' from options field if present (should use health_check_uri instead)
|
||||
if 'options' in update_data and update_data['options']:
|
||||
filtered_options = filter_httpchk_from_options(update_data['options'])
|
||||
|
||||
@@ -1381,7 +1381,50 @@ async def bulk_create_entities(
|
||||
|
||||
# BULK IMPORT MVP: Process backends with UPSERT (merge strategy)
|
||||
# Create or update backends first (frontends may reference them)
|
||||
|
||||
# CRITICAL: Reserved names that conflict with common HAProxy listen sections
|
||||
# Agent preserves existing listen blocks (e.g., 'listen stats') from local config
|
||||
# Creating entities with these names causes "proxy has same name" errors
|
||||
reserved_names = {'stats', 'haproxy-stats', 'haproxy_stats', 'monitoring', 'admin', 'health', 'status'}
|
||||
|
||||
# DYNAMIC COLLISION CHECK: Get agent listen blocks for this cluster
|
||||
# NOTE: Wrapped in try-except for backwards compatibility (column may not exist before migration)
|
||||
agent_listen_blocks = set()
|
||||
try:
|
||||
collision_check = await conn.fetch("""
|
||||
SELECT a.name as agent_name, a.preserved_listen_blocks
|
||||
FROM agents a
|
||||
JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id
|
||||
WHERE hc.id = $1 AND a.preserved_listen_blocks IS NOT NULL
|
||||
""", request.cluster_id)
|
||||
|
||||
for agent in collision_check:
|
||||
listen_blocks = agent['preserved_listen_blocks'] or []
|
||||
if isinstance(listen_blocks, str):
|
||||
try:
|
||||
listen_blocks = json.loads(listen_blocks)
|
||||
except:
|
||||
listen_blocks = []
|
||||
# Store as lowercase for case-insensitive comparison (HAProxy proxy names are case-insensitive)
|
||||
agent_listen_blocks.update(lb.lower() for lb in listen_blocks if isinstance(lb, str))
|
||||
|
||||
if agent_listen_blocks:
|
||||
logger.info(f"BULK IMPORT: Cluster {request.cluster_id} agents have listen blocks: {agent_listen_blocks}")
|
||||
except Exception as e:
|
||||
# Column may not exist yet (before migration) - skip dynamic check gracefully
|
||||
logger.debug(f"BULK IMPORT: Dynamic collision check skipped: {e}")
|
||||
|
||||
for backend_data in request.backends:
|
||||
# Check for reserved names first (case-insensitive)
|
||||
if backend_data["name"].lower() in reserved_names:
|
||||
logger.warning(f"BULK IMPORT: Skipping backend '{backend_data['name']}' - reserved name")
|
||||
continue
|
||||
|
||||
# Check for dynamic collision with agent listen blocks (case-insensitive)
|
||||
if backend_data["name"].lower() in agent_listen_blocks:
|
||||
logger.warning(f"BULK IMPORT: Skipping backend '{backend_data['name']}' - conflicts with agent listen block")
|
||||
continue
|
||||
|
||||
# Check if backend already exists (check both active AND inactive)
|
||||
existing = await conn.fetchrow("""
|
||||
SELECT id, is_active FROM backends
|
||||
@@ -1698,6 +1741,16 @@ async def bulk_create_entities(
|
||||
|
||||
# BULK IMPORT MVP: Process frontends with UPSERT (merge strategy)
|
||||
for frontend_data in request.frontends:
|
||||
# Check for reserved names first (case-insensitive)
|
||||
if frontend_data["name"].lower() in reserved_names:
|
||||
logger.warning(f"BULK IMPORT: Skipping frontend '{frontend_data['name']}' - reserved name")
|
||||
continue
|
||||
|
||||
# Check for dynamic collision with agent listen blocks (case-insensitive)
|
||||
if frontend_data["name"].lower() in agent_listen_blocks:
|
||||
logger.warning(f"BULK IMPORT: Skipping frontend '{frontend_data['name']}' - conflicts with agent listen block")
|
||||
continue
|
||||
|
||||
# Check if frontend already exists (check both active AND inactive)
|
||||
existing = await conn.fetchrow("""
|
||||
SELECT id, is_active FROM frontends
|
||||
|
||||
@@ -410,6 +410,54 @@ async def create_frontend(frontend: FrontendConfig, request: Request, authorizat
|
||||
if frontend.cluster_id:
|
||||
await validate_user_cluster_access(current_user['id'], frontend.cluster_id, conn)
|
||||
|
||||
# CRITICAL: Check for reserved names that conflict with common HAProxy listen sections
|
||||
# Agent preserves existing listen blocks (e.g., 'listen stats') from local config
|
||||
# Creating frontends with these names causes "proxy has same name" errors
|
||||
reserved_names = {'stats', 'haproxy-stats', 'haproxy_stats', 'monitoring', 'admin', 'health', 'status'}
|
||||
if frontend.name.lower() in reserved_names:
|
||||
await close_database_connection(conn)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Frontend name '{frontend.name}' is reserved. It conflicts with common HAProxy "
|
||||
f"listen sections (e.g., 'listen stats'). Please choose a different name."
|
||||
)
|
||||
|
||||
# DYNAMIC COLLISION CHECK: Check against agents' preserved listen blocks
|
||||
# Agents report their local listen blocks via config-sync, we check for conflicts here
|
||||
# NOTE: Wrapped in try-except for backwards compatibility (column may not exist before migration)
|
||||
if frontend.cluster_id:
|
||||
try:
|
||||
collision_check = await conn.fetch("""
|
||||
SELECT a.name as agent_name, a.preserved_listen_blocks
|
||||
FROM agents a
|
||||
JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id
|
||||
WHERE hc.id = $1 AND a.preserved_listen_blocks IS NOT NULL
|
||||
""", frontend.cluster_id)
|
||||
|
||||
for agent in collision_check:
|
||||
listen_blocks = agent['preserved_listen_blocks'] or []
|
||||
if isinstance(listen_blocks, str):
|
||||
try:
|
||||
listen_blocks = json.loads(listen_blocks)
|
||||
except:
|
||||
listen_blocks = []
|
||||
|
||||
# Case-insensitive comparison (HAProxy proxy names are case-insensitive)
|
||||
listen_blocks_lower = [lb.lower() for lb in listen_blocks if isinstance(lb, str)]
|
||||
if frontend.name.lower() in listen_blocks_lower:
|
||||
await close_database_connection(conn)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Frontend name '{frontend.name}' conflicts with an existing 'listen {frontend.name}' "
|
||||
f"block on agent '{agent['agent_name']}'. Either rename this frontend or remove the "
|
||||
f"listen block from the agent's local HAProxy configuration."
|
||||
)
|
||||
except HTTPException:
|
||||
raise # Re-raise HTTP exceptions (collision detected)
|
||||
except Exception as e:
|
||||
# Column may not exist yet (before migration) - skip check gracefully
|
||||
logger.debug(f"Dynamic collision check skipped: {e}")
|
||||
|
||||
# Check if frontend name already exists in the same cluster (only active frontends)
|
||||
existing = await conn.fetchrow("""
|
||||
SELECT id FROM frontends
|
||||
@@ -637,6 +685,49 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R
|
||||
|
||||
# Check if name is being changed and if new name already exists
|
||||
if frontend.name != existing["name"]:
|
||||
# CRITICAL: Check for reserved names on rename
|
||||
reserved_names = {'stats', 'haproxy-stats', 'haproxy_stats', 'monitoring', 'admin', 'health', 'status'}
|
||||
if frontend.name.lower() in reserved_names:
|
||||
await close_database_connection(conn)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Frontend name '{frontend.name}' is reserved. It conflicts with common HAProxy "
|
||||
f"listen sections (e.g., 'listen stats'). Please choose a different name."
|
||||
)
|
||||
|
||||
# CRITICAL: Check for collision with agent listen blocks on rename
|
||||
# NOTE: Wrapped in try-except for backwards compatibility
|
||||
if cluster_id:
|
||||
try:
|
||||
collision_check = await conn.fetch("""
|
||||
SELECT a.name as agent_name, a.preserved_listen_blocks
|
||||
FROM agents a
|
||||
JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id
|
||||
WHERE hc.id = $1 AND a.preserved_listen_blocks IS NOT NULL
|
||||
""", cluster_id)
|
||||
|
||||
for agent in collision_check:
|
||||
listen_blocks = agent['preserved_listen_blocks'] or []
|
||||
if isinstance(listen_blocks, str):
|
||||
try:
|
||||
listen_blocks = json.loads(listen_blocks)
|
||||
except:
|
||||
listen_blocks = []
|
||||
|
||||
# Case-insensitive comparison (HAProxy proxy names are case-insensitive)
|
||||
listen_blocks_lower = [lb.lower() for lb in listen_blocks if isinstance(lb, str)]
|
||||
if frontend.name.lower() in listen_blocks_lower:
|
||||
await close_database_connection(conn)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Frontend name '{frontend.name}' conflicts with an existing 'listen' block "
|
||||
f"on agent '{agent['agent_name']}'. Choose a different name."
|
||||
)
|
||||
except HTTPException:
|
||||
raise # Re-raise HTTP exceptions
|
||||
except Exception as e:
|
||||
logger.debug(f"Dynamic collision check skipped on update: {e}")
|
||||
|
||||
name_exists = await conn.fetchrow("SELECT id FROM frontends WHERE name = $1 AND id != $2", frontend.name, frontend_id)
|
||||
if name_exists:
|
||||
await close_database_connection(conn)
|
||||
|
||||
@@ -341,17 +341,30 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
|
||||
# ACL Rules
|
||||
if frontend.get('acl_rules'):
|
||||
acl_rules = frontend['acl_rules']
|
||||
|
||||
# CRITICAL DEBUG: Log type and raw value for troubleshooting
|
||||
logger.info(f"ACL_RULES DEBUG: Frontend '{frontend['name']}' acl_rules type: {type(acl_rules)}, repr: {repr(acl_rules)}")
|
||||
|
||||
# Parse JSON string if needed
|
||||
if isinstance(acl_rules, str):
|
||||
try:
|
||||
acl_rules = json.loads(acl_rules)
|
||||
logger.info(f"ACL_RULES DEBUG: Parsed as JSON list with {len(acl_rules) if isinstance(acl_rules, list) else 'N/A'} items")
|
||||
except:
|
||||
logger.warning(f"ACL_RULES DEBUG: JSON parse failed, setting to empty list")
|
||||
acl_rules = []
|
||||
|
||||
if isinstance(acl_rules, list):
|
||||
for acl in acl_rules:
|
||||
if acl and acl.strip():
|
||||
for idx, acl in enumerate(acl_rules):
|
||||
if acl and isinstance(acl, str) and acl.strip():
|
||||
acl_text = acl.strip()
|
||||
|
||||
# CRITICAL FIX: Remove any stray JSON characters that might have leaked
|
||||
acl_text = acl_text.strip('[]"\'')
|
||||
acl_text = acl_text.strip()
|
||||
|
||||
logger.info(f"ACL_RULES DEBUG: Rule {idx}: original={repr(acl)}, cleaned={repr(acl_text)}")
|
||||
|
||||
# Skip empty strings, "[]", or invalid ACL rules
|
||||
if acl_text and acl_text not in ('[]', '{}', 'null', 'None'):
|
||||
# CRITICAL FIX: ACL rules from parser already include "acl" keyword
|
||||
@@ -412,8 +425,8 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
|
||||
if frontend.get('use_backend_rules'):
|
||||
use_backend_rules = frontend['use_backend_rules']
|
||||
|
||||
# DEBUG: Log type and value
|
||||
logger.debug(f"Frontend '{frontend['name']}' use_backend_rules type: {type(use_backend_rules)}, value: {use_backend_rules}")
|
||||
# CRITICAL DEBUG: Log type and raw value for troubleshooting
|
||||
logger.info(f"USE_BACKEND DEBUG: Frontend '{frontend['name']}' use_backend_rules type: {type(use_backend_rules)}, repr: {repr(use_backend_rules)}")
|
||||
|
||||
# Normalize to list
|
||||
rules_list = None
|
||||
@@ -424,15 +437,18 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
|
||||
parsed = json.loads(use_backend_rules)
|
||||
if isinstance(parsed, list):
|
||||
rules_list = parsed
|
||||
logger.info(f"USE_BACKEND DEBUG: Parsed as JSON list with {len(rules_list)} items")
|
||||
else:
|
||||
logger.warning(f"Frontend '{frontend['name']}' use_backend_rules parsed to non-list: {type(parsed)}")
|
||||
except:
|
||||
except json.JSONDecodeError as e:
|
||||
# Legacy format: newline-separated string
|
||||
logger.info(f"USE_BACKEND DEBUG: JSON parse failed ({e}), trying newline split")
|
||||
rules_list = [r.strip() for r in use_backend_rules.split('\n') if r.strip()]
|
||||
|
||||
elif isinstance(use_backend_rules, (list, tuple)):
|
||||
# Already a sequence
|
||||
# Already a sequence (asyncpg auto-converts JSONB to Python list)
|
||||
rules_list = list(use_backend_rules)
|
||||
logger.info(f"USE_BACKEND DEBUG: Already a list with {len(rules_list)} items")
|
||||
|
||||
else:
|
||||
# Unexpected type - try to convert to string and log warning
|
||||
@@ -449,9 +465,18 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
|
||||
|
||||
# Write rules if we successfully parsed them
|
||||
if rules_list:
|
||||
for rule in rules_list:
|
||||
for idx, rule in enumerate(rules_list):
|
||||
if rule and isinstance(rule, str):
|
||||
rule_text = rule.strip()
|
||||
|
||||
# CRITICAL FIX: Remove any stray JSON characters that might have leaked
|
||||
# This can happen due to serialization issues or database corruption
|
||||
# Remove leading/trailing brackets and quotes that shouldn't be there
|
||||
rule_text = rule_text.strip('[]"\'')
|
||||
rule_text = rule_text.strip()
|
||||
|
||||
logger.info(f"USE_BACKEND DEBUG: Rule {idx}: original={repr(rule)}, cleaned={repr(rule_text)}")
|
||||
|
||||
# Skip empty strings, "[]", or invalid use_backend rules
|
||||
if rule_text and rule_text not in ('[]', '{}', 'null', 'None', '""', "''"):
|
||||
# CRITICAL FIX: use_backend rules from parser already include "use_backend" keyword
|
||||
|
||||
@@ -1893,7 +1893,24 @@ check_config_updates() {
|
||||
|
||||
log "ERROR" "Configuration validation failed after 3 attempts"
|
||||
log "ERROR" "Final validation error: $RETRY_VALIDATION_OUTPUT"
|
||||
rm -f "$TEMP_CONFIG"
|
||||
|
||||
# CRITICAL DEBUG: Keep failed config for inspection instead of deleting
|
||||
# Move to a debug location so admin can inspect what went wrong
|
||||
DEBUG_CONFIG="/tmp/haproxy-failed-${CONFIG_VERSION}-$(date +%Y%m%d-%H%M%S).cfg"
|
||||
mv "$TEMP_CONFIG" "$DEBUG_CONFIG" 2>/dev/null || true
|
||||
log "ERROR" "Failed config saved to: $DEBUG_CONFIG (for debugging - delete manually after inspection)"
|
||||
log "ERROR" "To validate manually: $HAPROXY_BIN_PATH -c -f $DEBUG_CONFIG"
|
||||
|
||||
# Clean up old failed configs (keep last 5) to prevent disk space issues
|
||||
ls -t /tmp/haproxy-failed-*.cfg 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true
|
||||
|
||||
# Send validation failure notification to backend
|
||||
VALIDATION_ERROR_JSON=$(echo "$RETRY_VALIDATION_OUTPUT" | jq -Rs .)
|
||||
"$CURL_BIN" -k -s -X POST "$MANAGEMENT_URL/api/agents/$AGENT_NAME/config-validation-failed" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: $AGENT_TOKEN" \
|
||||
-d "{\"version\":\"$CONFIG_VERSION\",\"validation_error\":$VALIDATION_ERROR_JSON}" > /dev/null 2>&1 || true
|
||||
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1831,7 +1831,24 @@ check_config_updates() {
|
||||
done
|
||||
|
||||
log "ERROR" "Configuration validation failed after 3 attempts"
|
||||
rm -f "$TEMP_CONFIG"
|
||||
|
||||
# CRITICAL DEBUG: Keep failed config for inspection instead of deleting
|
||||
# Move to a debug location so admin can inspect what went wrong
|
||||
DEBUG_CONFIG="/tmp/haproxy-failed-${CONFIG_VERSION}-$(date +%Y%m%d-%H%M%S).cfg"
|
||||
mv "$TEMP_CONFIG" "$DEBUG_CONFIG" 2>/dev/null || true
|
||||
log "ERROR" "Failed config saved to: $DEBUG_CONFIG (for debugging - delete manually after inspection)"
|
||||
log "ERROR" "To validate manually: $HAPROXY_BIN_PATH -c -f $DEBUG_CONFIG"
|
||||
|
||||
# Clean up old failed configs (keep last 5) to prevent disk space issues
|
||||
ls -t /tmp/haproxy-failed-*.cfg 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true
|
||||
|
||||
# Send validation failure notification to backend
|
||||
VALIDATION_ERROR_JSON=$(echo "$RETRY_VALIDATION_OUTPUT" | jq -Rs . 2>/dev/null || echo "\"$RETRY_VALIDATION_OUTPUT\"")
|
||||
"$CURL_BIN" -k -s -X POST "$MANAGEMENT_URL/api/agents/$AGENT_NAME/config-validation-failed" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: $AGENT_TOKEN" \
|
||||
-d "{\"version\":\"$CONFIG_VERSION\",\"validation_error\":$VALIDATION_ERROR_JSON}" > /dev/null 2>&1 || true
|
||||
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -946,10 +946,34 @@ class HAProxyConfigParser:
|
||||
Post-parse validation of parsed configuration
|
||||
Removes invalid entities and adds warnings for skipped items
|
||||
"""
|
||||
# CRITICAL: Reserved names that conflict with common HAProxy listen sections
|
||||
# Agent preserves existing listen blocks (e.g., 'listen stats') from local config
|
||||
# Creating frontends/backends with these names causes "proxy has same name" errors
|
||||
reserved_names = {
|
||||
'stats', # Conflicts with common 'listen stats' monitoring section
|
||||
'haproxy-stats', # Alternative stats name
|
||||
'haproxy_stats', # Alternative stats name with underscore
|
||||
'monitoring', # Common monitoring section name
|
||||
'admin', # Admin interface
|
||||
'health', # Health check endpoint
|
||||
'status', # Status page
|
||||
}
|
||||
|
||||
# Check for duplicate frontend names - remove duplicates keeping first occurrence
|
||||
# Also check for reserved names that conflict with listen sections
|
||||
frontend_names_seen = set()
|
||||
valid_frontends = []
|
||||
for frontend in self.frontends:
|
||||
# Check for reserved names first
|
||||
if frontend.name.lower() in reserved_names:
|
||||
self.warnings.append(
|
||||
f"⚠️ SKIPPED: Frontend '{frontend.name}' uses a reserved name that conflicts with "
|
||||
f"common HAProxy listen sections (e.g., 'listen stats'). Agent preserves existing "
|
||||
f"listen blocks from local config, so creating a frontend with this name would cause "
|
||||
f"'proxy has same name' errors. Rename this frontend to avoid conflicts."
|
||||
)
|
||||
continue
|
||||
|
||||
if frontend.name in frontend_names_seen:
|
||||
self.warnings.append(
|
||||
f"⚠️ SKIPPED: Duplicate frontend '{frontend.name}' - keeping first occurrence only."
|
||||
@@ -959,9 +983,18 @@ class HAProxyConfigParser:
|
||||
valid_frontends.append(frontend)
|
||||
|
||||
# Check for duplicate backend names - remove duplicates keeping first occurrence
|
||||
# Also check for reserved names
|
||||
backend_names_seen = set()
|
||||
valid_backends = []
|
||||
for backend in self.backends:
|
||||
# Check for reserved names first
|
||||
if backend.name.lower() in reserved_names:
|
||||
self.warnings.append(
|
||||
f"⚠️ SKIPPED: Backend '{backend.name}' uses a reserved name that conflicts with "
|
||||
f"common HAProxy listen sections. Rename this backend to avoid conflicts."
|
||||
)
|
||||
continue
|
||||
|
||||
if backend.name in backend_names_seen:
|
||||
self.warnings.append(
|
||||
f"⚠️ SKIPPED: Duplicate backend '{backend.name}' - keeping first occurrence only."
|
||||
|
||||
Reference in New Issue
Block a user