mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-16 15:45:11 +00:00
8076f3fcfd
- 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.
562 lines
21 KiB
Python
562 lines
21 KiB
Python
"""
|
||
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
|