Files
haproxy-openmanager/backend/utils/haproxy_error_parser.py
T
taylanbakircioglu 8076f3fcfd feat: add intelligent HAProxy validation error display in UI
- Add haproxy_error_parser.py: Parses HAProxy validation errors with
  confidence scoring, extracts entity type/name, line number, error type
- Add ValidationErrorModal.js: Rich modal with parsed error summary,
  quick fix suggestions, and manual troubleshooting guide
- Update cluster.py: Integrate error parser into agent-sync and
  config-versions endpoints with graceful fallback
- Update ApplyManagement.js: Add validation error banner with quick
  navigation buttons and error detail modal
- Update FrontendManagement.js & BackendServers.js: Handle URL params
  for deep-linking to entity edit forms with field highlighting

Enables users to see actionable validation failure details directly
in the UI without needing server access for debugging.
2026-01-26 15:25:15 +03:00

562 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
HAProxy Validation Error Parser
Parses HAProxy validation errors and provides structured information
for UI display and quick fix suggestions.
"""
import re
import logging
from typing import Dict, List, Optional, Any
from dataclasses import dataclass, asdict
from enum import Enum
logger = logging.getLogger("haproxy_openmanager.error_parser")
class ErrorType(Enum):
"""Known HAProxy error types"""
UNKNOWN_BACKEND = "unknown_backend"
UNKNOWN_FRONTEND = "unknown_frontend"
DUPLICATE_NAME = "duplicate_name"
MODE_MISMATCH = "mode_mismatch"
INVALID_KEYWORD = "invalid_keyword"
SYNTAX_ERROR = "syntax_error"
MISSING_SERVER = "missing_server"
ACL_ERROR = "acl_error"
BIND_ERROR = "bind_error"
UNKNOWN = "unknown"
# Error type to user-friendly display name mapping
ERROR_TYPE_DISPLAY = {
ErrorType.UNKNOWN_BACKEND: "Unknown Backend Reference",
ErrorType.UNKNOWN_FRONTEND: "Unknown Frontend Reference",
ErrorType.DUPLICATE_NAME: "Duplicate Proxy Name",
ErrorType.MODE_MISMATCH: "Mode Mismatch",
ErrorType.INVALID_KEYWORD: "Invalid Keyword for Mode",
ErrorType.SYNTAX_ERROR: "Syntax Error",
ErrorType.MISSING_SERVER: "Missing Server Definition",
ErrorType.ACL_ERROR: "ACL Definition Error",
ErrorType.BIND_ERROR: "Bind/Port Error",
ErrorType.UNKNOWN: "Configuration Error",
}
# Error type to field hint mapping
ERROR_TYPE_FIELD_HINTS = {
ErrorType.UNKNOWN_BACKEND: ["use_backend_rules", "default_backend"],
ErrorType.UNKNOWN_FRONTEND: ["use_backend_rules"],
ErrorType.DUPLICATE_NAME: ["name"],
ErrorType.MODE_MISMATCH: ["mode"],
ErrorType.INVALID_KEYWORD: ["options", "acl_rules", "use_backend_rules"],
ErrorType.SYNTAX_ERROR: ["options", "acl_rules"],
ErrorType.MISSING_SERVER: ["servers"],
ErrorType.ACL_ERROR: ["acl_rules"],
ErrorType.BIND_ERROR: ["bind_address", "bind_port"],
}
# Suggestion templates for each error type
SUGGESTION_TEMPLATES = {
ErrorType.UNKNOWN_BACKEND: "Backend '{related_entity}' bulunamadı. Bu backend'i oluşturun veya '{entity_name}' frontend'indeki use_backend/default_backend kurallarını düzeltin.",
ErrorType.UNKNOWN_FRONTEND: "Frontend '{related_entity}' bulunamadı. Bu frontend'i oluşturun veya referansı düzeltin.",
ErrorType.DUPLICATE_NAME: "'{entity_name}' ismi zaten kullanılıyor (muhtemelen agent'ın local listen block'u). Farklı bir isim seçin.",
ErrorType.MODE_MISMATCH: "'{entity_name}' frontend'inin mode'u ({mode}) backend ile uyumsuz. Frontend veya backend mode'unu değiştirin.",
ErrorType.INVALID_KEYWORD: "'{keyword}' keyword'ü {mode} mode'da geçersiz. HTTP mode için http-request, TCP mode için tcp-request kullanın.",
ErrorType.SYNTAX_ERROR: "Syntax hatası tespit edildi. İlgili satırı kontrol edin.",
ErrorType.MISSING_SERVER: "'{entity_name}' backend'inde en az bir server tanımlı olmalı.",
ErrorType.ACL_ERROR: "ACL tanımında hata var. Doğru format: 'acl name condition value'",
ErrorType.BIND_ERROR: "Port {port} zaten kullanımda veya erişilemez. Farklı bir port deneyin.",
ErrorType.UNKNOWN: "Lütfen ham hata mesajını inceleyin ve ilgili entity'yi kontrol edin.",
}
@dataclass
class ParsedError:
"""Structured representation of a parsed HAProxy error"""
# Parse status
parse_success: bool = False
parse_confidence: int = 0
# Raw message (always present)
raw_message: str = ""
raw_message_truncated: bool = False
# Parsed fields (nullable)
line_number: Optional[int] = None
error_type: str = "unknown"
error_type_display: str = "Configuration Error"
entity_type: Optional[str] = None # "frontend", "backend", "server"
entity_name: Optional[str] = None
related_entity: Optional[str] = None
field_hint: Optional[str] = None
mode: Optional[str] = None
keyword: Optional[str] = None
port: Optional[int] = None
# Suggestion
suggestion: str = "Lütfen ham hata mesajını inceleyin ve ilgili entity'yi kontrol edin."
suggestion_type: str = "generic" # "specific", "generic", "none"
# Quick fix
quick_fix_available: bool = False
quick_fix_url: Optional[str] = None
# Multiple errors
has_multiple_errors: bool = False
additional_errors_count: int = 0
additional_errors_raw: Optional[str] = None
# Entity verification (set later by entity matcher)
entity_id: Optional[int] = None
entity_verified: bool = False
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization"""
return asdict(self)
class HAProxyErrorParser:
"""
Parses HAProxy validation error messages and extracts structured information.
Key design principle: ALWAYS return a valid result, never raise exceptions.
Parse is a "bonus" - the raw message is always the primary value.
"""
# Regex patterns for parsing HAProxy errors
PATTERNS = {
# Line number pattern: parsing [/path/file.cfg:45]
'line_number': re.compile(r'\[.*?:(\d+)\]'),
# Unknown backend: references unknown backend 'name' in frontend 'name'
'unknown_backend': re.compile(
r"references unknown backend '([^']+)'.*?(?:in (?:frontend|backend) '([^']+)')?",
re.IGNORECASE
),
# Duplicate name: has the same name as a previously declared 'listen' block
'duplicate_name': re.compile(
r"(?:frontend|backend|listen) (?:section )?'([^']+)' has the same name",
re.IGNORECASE
),
# Mode mismatch / invalid keyword: unexpected keyword 'tcp-request' in 'http' mode
'invalid_keyword': re.compile(
r"unexpected keyword '([^']+)'.*?'(http|tcp)' mode.*?(?:frontend|backend) '([^']+)'",
re.IGNORECASE
),
# ACL error: acl 'name' involves some response-only criteria
'acl_error': re.compile(
r"acl '([^']+)'.*?(?:frontend|backend) '([^']+)'",
re.IGNORECASE
),
# Bind error: cannot bind socket
'bind_error': re.compile(
r"cannot bind (?:socket|to) .*?:(\d+)",
re.IGNORECASE
),
# General frontend/backend name extraction
'entity_name': re.compile(
r"(?:in |section )(?:frontend|backend) '([^']+)'",
re.IGNORECASE
),
# Frontend keyword in error
'frontend_ref': re.compile(
r"frontend '([^']+)'",
re.IGNORECASE
),
# Backend keyword in error
'backend_ref': re.compile(
r"backend '([^']+)'",
re.IGNORECASE
),
# Multiple errors detection
'alert_count': re.compile(r'\[ALERT\]', re.IGNORECASE),
}
# Maximum message length before truncation
MAX_MESSAGE_LENGTH = 10000
def __init__(self):
pass
def parse(self, error_message: str) -> ParsedError:
"""
Parse a HAProxy validation error message.
ALWAYS returns a valid ParsedError, never raises exceptions.
If parsing fails, returns a result with parse_success=False but
the raw message is always preserved.
Args:
error_message: Raw HAProxy validation error output
Returns:
ParsedError with structured information
"""
result = ParsedError()
# Handle empty/null message
if not error_message or not error_message.strip():
result.raw_message = ""
result.suggestion = "Hata detayı alınamadı. Agent loglarını kontrol edin."
result.suggestion_type = "none"
return result
# Store raw message (with truncation if needed)
if len(error_message) > self.MAX_MESSAGE_LENGTH:
result.raw_message = error_message[:self.MAX_MESSAGE_LENGTH] + "\n... [truncated]"
result.raw_message_truncated = True
else:
result.raw_message = error_message
# Check for multiple errors
alert_matches = self.PATTERNS['alert_count'].findall(error_message)
if len(alert_matches) > 1:
result.has_multiple_errors = True
result.additional_errors_count = len(alert_matches) - 1
try:
# Extract line number
line_match = self.PATTERNS['line_number'].search(error_message)
if line_match:
result.line_number = int(line_match.group(1))
# Try to identify error type and extract details
parsed = self._identify_error_type(error_message)
result.error_type = parsed.get('error_type', ErrorType.UNKNOWN).value
result.error_type_display = ERROR_TYPE_DISPLAY.get(
parsed.get('error_type', ErrorType.UNKNOWN),
"Configuration Error"
)
result.entity_type = parsed.get('entity_type')
result.entity_name = parsed.get('entity_name')
result.related_entity = parsed.get('related_entity')
result.mode = parsed.get('mode')
result.keyword = parsed.get('keyword')
result.port = parsed.get('port')
# Set field hint based on error type
error_type_enum = parsed.get('error_type', ErrorType.UNKNOWN)
field_hints = ERROR_TYPE_FIELD_HINTS.get(error_type_enum, [])
if field_hints:
result.field_hint = field_hints[0]
# Generate suggestion
result.suggestion = self._generate_suggestion(result, parsed)
result.suggestion_type = "specific" if error_type_enum != ErrorType.UNKNOWN else "generic"
# Calculate confidence score
result.parse_confidence = self._calculate_confidence(result)
result.parse_success = result.parse_confidence >= 30
# Quick fix is available if we have entity info
result.quick_fix_available = bool(result.entity_name and result.entity_type)
logger.info(f"Parsed HAProxy error: type={result.error_type}, entity={result.entity_name}, confidence={result.parse_confidence}")
except Exception as e:
# Parse failed - log but don't raise
logger.warning(f"HAProxy error parse failed: {e}")
# result already has safe defaults
return result
def _identify_error_type(self, message: str) -> Dict[str, Any]:
"""
Identify the error type and extract relevant details.
Returns dict with:
- error_type: ErrorType enum
- entity_type: "frontend" or "backend"
- entity_name: name of affected entity
- related_entity: name of related entity (e.g., unknown backend name)
- mode: http/tcp if relevant
- keyword: problematic keyword if relevant
- port: port number if relevant
"""
result = {
'error_type': ErrorType.UNKNOWN,
'entity_type': None,
'entity_name': None,
'related_entity': None,
'mode': None,
'keyword': None,
'port': None,
}
message_lower = message.lower()
# Check for unknown backend
if 'unknown backend' in message_lower or 'references unknown' in message_lower:
match = self.PATTERNS['unknown_backend'].search(message)
if match:
result['error_type'] = ErrorType.UNKNOWN_BACKEND
result['related_entity'] = match.group(1) # The unknown backend
result['entity_type'] = 'frontend'
if match.group(2):
result['entity_name'] = match.group(2) # The frontend
else:
result['error_type'] = ErrorType.UNKNOWN_BACKEND
return result
# Check for duplicate name
if 'same name' in message_lower or 'already exists' in message_lower:
match = self.PATTERNS['duplicate_name'].search(message)
if match:
result['error_type'] = ErrorType.DUPLICATE_NAME
result['entity_name'] = match.group(1)
# Determine entity type from message
if 'frontend' in message_lower:
result['entity_type'] = 'frontend'
elif 'backend' in message_lower:
result['entity_type'] = 'backend'
else:
result['error_type'] = ErrorType.DUPLICATE_NAME
return result
# Check for invalid keyword (mode mismatch)
if 'unexpected keyword' in message_lower:
match = self.PATTERNS['invalid_keyword'].search(message)
if match:
result['error_type'] = ErrorType.INVALID_KEYWORD
result['keyword'] = match.group(1)
result['mode'] = match.group(2)
result['entity_name'] = match.group(3)
result['entity_type'] = 'frontend' # Usually frontend
else:
result['error_type'] = ErrorType.INVALID_KEYWORD
return result
# Check for ACL error
if 'acl' in message_lower and ('error' in message_lower or 'invalid' in message_lower or 'involves' in message_lower):
match = self.PATTERNS['acl_error'].search(message)
if match:
result['error_type'] = ErrorType.ACL_ERROR
result['related_entity'] = match.group(1) # ACL name
result['entity_name'] = match.group(2) if len(match.groups()) > 1 else None
result['entity_type'] = 'frontend'
else:
result['error_type'] = ErrorType.ACL_ERROR
# Try to extract entity name
frontend_match = self.PATTERNS['frontend_ref'].search(message)
if frontend_match:
result['entity_name'] = frontend_match.group(1)
result['entity_type'] = 'frontend'
return result
# Check for bind error
if 'cannot bind' in message_lower or 'address already in use' in message_lower:
match = self.PATTERNS['bind_error'].search(message)
if match:
result['error_type'] = ErrorType.BIND_ERROR
result['port'] = int(match.group(1))
else:
result['error_type'] = ErrorType.BIND_ERROR
# Try to extract frontend
frontend_match = self.PATTERNS['frontend_ref'].search(message)
if frontend_match:
result['entity_name'] = frontend_match.group(1)
result['entity_type'] = 'frontend'
return result
# Check for missing server
if 'no server' in message_lower or 'missing server' in message_lower:
result['error_type'] = ErrorType.MISSING_SERVER
backend_match = self.PATTERNS['backend_ref'].search(message)
if backend_match:
result['entity_name'] = backend_match.group(1)
result['entity_type'] = 'backend'
return result
# Generic error - try to extract any entity reference
frontend_match = self.PATTERNS['frontend_ref'].search(message)
backend_match = self.PATTERNS['backend_ref'].search(message)
if frontend_match:
result['entity_name'] = frontend_match.group(1)
result['entity_type'] = 'frontend'
elif backend_match:
result['entity_name'] = backend_match.group(1)
result['entity_type'] = 'backend'
# Check for syntax keywords
if 'syntax' in message_lower or 'parsing' in message_lower:
result['error_type'] = ErrorType.SYNTAX_ERROR
return result
def _generate_suggestion(self, result: ParsedError, parsed: Dict[str, Any]) -> str:
"""Generate a helpful suggestion based on the error type"""
error_type = parsed.get('error_type', ErrorType.UNKNOWN)
template = SUGGESTION_TEMPLATES.get(error_type, SUGGESTION_TEMPLATES[ErrorType.UNKNOWN])
try:
return template.format(
entity_name=result.entity_name or 'unknown',
related_entity=result.related_entity or 'unknown',
mode=result.mode or 'unknown',
keyword=result.keyword or 'unknown',
port=result.port or 'unknown',
)
except KeyError:
return SUGGESTION_TEMPLATES[ErrorType.UNKNOWN]
def _calculate_confidence(self, result: ParsedError) -> int:
"""
Calculate confidence score (0-100) based on parsed information.
Scoring:
- Line number found: +30
- Entity name found: +25
- Error type identified: +20
- Field hint available: +10
- Related entity found: +10
- Mode/keyword extracted: +5
"""
score = 0
if result.line_number:
score += 30
if result.entity_name:
score += 25
if result.error_type != "unknown":
score += 20
if result.field_hint:
score += 10
if result.related_entity:
score += 10
if result.mode or result.keyword:
score += 5
return min(score, 100)
# Singleton instance for convenience
_parser_instance = None
def get_parser() -> HAProxyErrorParser:
"""Get singleton parser instance"""
global _parser_instance
if _parser_instance is None:
_parser_instance = HAProxyErrorParser()
return _parser_instance
def parse_haproxy_error(error_message: str) -> Dict[str, Any]:
"""
Convenience function to parse HAProxy error.
Args:
error_message: Raw HAProxy validation error output
Returns:
Dictionary with parsed error information
"""
parser = get_parser()
result = parser.parse(error_message)
return result.to_dict()
async def find_affected_entity(
parsed_error: Dict[str, Any],
cluster_id: int,
conn
) -> Dict[str, Any]:
"""
Find the affected entity in the database based on parsed error.
Args:
parsed_error: Dictionary from parse_haproxy_error()
cluster_id: Cluster ID to search in
conn: Database connection
Returns:
Dictionary with entity information:
- entity_id: Database ID
- entity_type: "frontend" or "backend"
- entity_name: Entity name
- entity_verified: True if found in DB
- edit_url: URL to edit the entity
- field_to_fix: Field that needs fixing
- current_value: Current value of the field (if applicable)
"""
result = {
'entity_id': None,
'entity_type': parsed_error.get('entity_type'),
'entity_name': parsed_error.get('entity_name'),
'entity_verified': False,
'edit_url': None,
'field_to_fix': parsed_error.get('field_hint'),
'current_value': None,
}
entity_name = parsed_error.get('entity_name')
entity_type = parsed_error.get('entity_type')
if not entity_name or not entity_type:
return result
try:
if entity_type == 'frontend':
# Find frontend
frontend = await conn.fetchrow(
"""
SELECT id, name, default_backend, use_backend_rules, acl_rules, mode,
bind_address, bind_port, options
FROM frontends
WHERE cluster_id = $1 AND LOWER(name) = LOWER($2)
""",
cluster_id, entity_name
)
if frontend:
result['entity_id'] = frontend['id']
result['entity_verified'] = True
result['edit_url'] = f"/frontends?edit={frontend['id']}"
# Get current value of problematic field
field_hint = parsed_error.get('field_hint')
if field_hint and field_hint in frontend.keys():
result['current_value'] = frontend[field_hint]
result['edit_url'] += f"&highlight={field_hint}"
elif entity_type == 'backend':
# Find backend
backend = await conn.fetchrow(
"""
SELECT id, name, mode, options
FROM backends
WHERE cluster_id = $1 AND LOWER(name) = LOWER($2)
""",
cluster_id, entity_name
)
if backend:
result['entity_id'] = backend['id']
result['entity_verified'] = True
result['edit_url'] = f"/backends?edit={backend['id']}"
field_hint = parsed_error.get('field_hint')
if field_hint and field_hint in backend.keys():
result['current_value'] = backend[field_hint]
result['edit_url'] += f"&highlight={field_hint}"
logger.info(f"Entity lookup: type={entity_type}, name={entity_name}, verified={result['entity_verified']}")
except Exception as e:
logger.warning(f"Failed to find affected entity: {e}")
return result