diff --git a/backend/database/migrations.py b/backend/database/migrations.py index 7202d17..0731050 100644 --- a/backend/database/migrations.py +++ b/backend/database/migrations.py @@ -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 diff --git a/backend/routers/agent.py b/backend/routers/agent.py index 1701c3a..c5fc3bb 100644 --- a/backend/routers/agent.py +++ b/backend/routers/agent.py @@ -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 = [] diff --git a/backend/routers/backend.py b/backend/routers/backend.py index 3463bc4..7bcca07 100644 --- a/backend/routers/backend.py +++ b/backend/routers/backend.py @@ -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']) diff --git a/backend/routers/config.py b/backend/routers/config.py index 80f43ba..69275b8 100644 --- a/backend/routers/config.py +++ b/backend/routers/config.py @@ -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 diff --git a/backend/routers/frontend.py b/backend/routers/frontend.py index 023965f..b0be98d 100644 --- a/backend/routers/frontend.py +++ b/backend/routers/frontend.py @@ -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) diff --git a/backend/services/haproxy_config.py b/backend/services/haproxy_config.py index ae21034..7ac486a 100644 --- a/backend/services/haproxy_config.py +++ b/backend/services/haproxy_config.py @@ -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 diff --git a/backend/utils/agent_scripts/linux_install.sh b/backend/utils/agent_scripts/linux_install.sh index ecc197e..c2f1396 100644 --- a/backend/utils/agent_scripts/linux_install.sh +++ b/backend/utils/agent_scripts/linux_install.sh @@ -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 } diff --git a/backend/utils/agent_scripts/macos_install.sh b/backend/utils/agent_scripts/macos_install.sh index 6834e04..cbffff8 100644 --- a/backend/utils/agent_scripts/macos_install.sh +++ b/backend/utils/agent_scripts/macos_install.sh @@ -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 } diff --git a/backend/utils/haproxy_config_parser.py b/backend/utils/haproxy_config_parser.py index f5be3cb..f8748c4 100644 --- a/backend/utils/haproxy_config_parser.py +++ b/backend/utils/haproxy_config_parser.py @@ -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."