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:
taylanbakircioglu
2026-01-26 15:25:15 +03:00
parent 5d054f3426
commit 851377aedf
9 changed files with 436 additions and 9 deletions
+45
View File
@@ -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
+44
View File
@@ -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 = []
+102
View File
@@ -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'])
+53
View File
@@ -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
+91
View File
@@ -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)
+32 -7
View File
@@ -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
+18 -1
View File
@@ -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
}
+18 -1
View File
@@ -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
}
+33
View File
@@ -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."