Add core modules: manifest, merger, validator
This commit is contained in:
444
confsync/core/validator.py
Normal file
444
confsync/core/validator.py
Normal file
@@ -0,0 +1,444 @@
|
||||
"""Configuration validation for ConfSync."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
import yaml
|
||||
import toml
|
||||
import configparser
|
||||
|
||||
from confsync.models.config_models import (
|
||||
ValidationResult,
|
||||
ValidationIssue,
|
||||
Severity,
|
||||
ConfigFile,
|
||||
)
|
||||
|
||||
|
||||
class Validator:
|
||||
"""Validates configuration files for errors and conflicts."""
|
||||
|
||||
def __init__(self):
|
||||
self.validation_rules: Dict[str, List[str]] = {
|
||||
".json": ["valid_json", "no_duplicate_keys"],
|
||||
".yaml": ["valid_yaml", "no_duplicate_keys"],
|
||||
".yml": ["valid_yaml", "no_duplicate_keys"],
|
||||
".toml": ["valid_toml", "no_duplicate_sections"],
|
||||
".ini": ["valid_ini"],
|
||||
".cfg": ["valid_ini"],
|
||||
".sh": ["valid_shell_syntax"],
|
||||
".bash": ["valid_shell_syntax"],
|
||||
".zsh": ["valid_shell_syntax"],
|
||||
".gitconfig": ["valid_git_config"],
|
||||
".gitignore": ["valid_gitignore"],
|
||||
"Dockerfile": ["valid_dockerfile"],
|
||||
}
|
||||
|
||||
def validate_file(self, config_file: ConfigFile) -> ValidationResult:
|
||||
"""Validate a single configuration file."""
|
||||
result = ValidationResult(is_valid=True, validated_files=1)
|
||||
|
||||
suffix = Path(config_file.path).suffix.lower()
|
||||
|
||||
for rule in self.validation_rules.get(suffix, []):
|
||||
validator = getattr(self, f"check_{rule}", None)
|
||||
if validator:
|
||||
issue = validator(config_file)
|
||||
if issue:
|
||||
result.add_issue(issue)
|
||||
|
||||
if not suffix or suffix not in self.validation_rules:
|
||||
if config_file.content:
|
||||
issue = ValidationIssue(
|
||||
rule="unknown_format",
|
||||
message=f"Unknown configuration format for {config_file.path}",
|
||||
severity=Severity.INFO,
|
||||
file_path=config_file.path,
|
||||
suggestion="Consider converting to a standard format like YAML or JSON",
|
||||
)
|
||||
result.add_issue(issue)
|
||||
|
||||
return result
|
||||
|
||||
def validate_manifest(
|
||||
self,
|
||||
configs: List[ConfigFile],
|
||||
check_conflicts: bool = True
|
||||
) -> ValidationResult:
|
||||
"""Validate multiple configuration files."""
|
||||
result = ValidationResult(is_valid=True)
|
||||
|
||||
for config_file in configs:
|
||||
file_result = self.validate_file(config_file)
|
||||
result.validated_files += file_result.validated_files
|
||||
result.issues.extend(file_result.issues)
|
||||
|
||||
if check_conflicts:
|
||||
conflict_issues = self._check_conflicts(configs)
|
||||
result.issues.extend(conflict_issues)
|
||||
|
||||
result.is_valid = all(
|
||||
issue.severity != Severity.ERROR for issue in result.issues
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def check_valid_json(self, config_file: ConfigFile) -> Optional[ValidationIssue]:
|
||||
"""Check if JSON content is valid."""
|
||||
try:
|
||||
json.loads(config_file.content)
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
return ValidationIssue(
|
||||
rule="valid_json",
|
||||
message=f"Invalid JSON: {str(e)}",
|
||||
severity=Severity.ERROR,
|
||||
file_path=config_file.path,
|
||||
line_number=e.lineno if hasattr(e, 'lineno') else None,
|
||||
suggestion="Check for missing commas, brackets, or quotes",
|
||||
)
|
||||
|
||||
def check_valid_yaml(self, config_file: ConfigFile) -> Optional[ValidationIssue]:
|
||||
"""Check if YAML content is valid."""
|
||||
try:
|
||||
yaml.safe_load(config_file.content)
|
||||
return None
|
||||
except yaml.YAMLError as e:
|
||||
return ValidationIssue(
|
||||
rule="valid_yaml",
|
||||
message=f"Invalid YAML: {str(e)}",
|
||||
severity=Severity.ERROR,
|
||||
file_path=config_file.path,
|
||||
suggestion="Check indentation and proper YAML syntax",
|
||||
)
|
||||
|
||||
def check_valid_toml(self, config_file: ConfigFile) -> Optional[ValidationIssue]:
|
||||
"""Check if TOML content is valid."""
|
||||
try:
|
||||
toml.loads(config_file.content)
|
||||
return None
|
||||
except toml.TomlDecodeError as e:
|
||||
return ValidationIssue(
|
||||
rule="valid_toml",
|
||||
message=f"Invalid TOML: {str(e)}",
|
||||
severity=Severity.ERROR,
|
||||
file_path=config_file.path,
|
||||
suggestion="Check TOML syntax (tables, keys, values)",
|
||||
)
|
||||
|
||||
def check_valid_ini(self, config_file: ConfigFile) -> Optional[ValidationIssue]:
|
||||
"""Check if INI content is valid."""
|
||||
try:
|
||||
parser = configparser.ConfigParser()
|
||||
parser.read_string(config_file.content)
|
||||
return None
|
||||
except configparser.Error as e:
|
||||
return ValidationIssue(
|
||||
rule="valid_ini",
|
||||
message=f"Invalid INI: {str(e)}",
|
||||
severity=Severity.ERROR,
|
||||
file_path=config_file.path,
|
||||
suggestion="Check section headers and key-value format",
|
||||
)
|
||||
|
||||
def check_no_duplicate_keys(self, config_file: ConfigFile) -> Optional[ValidationIssue]:
|
||||
"""Check for duplicate keys in JSON or YAML."""
|
||||
suffix = Path(config_file.path).suffix.lower()
|
||||
|
||||
if suffix == ".json":
|
||||
try:
|
||||
data = json.loads(config_file.content)
|
||||
duplicates = self._find_json_duplicates(data)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
elif suffix in (".yaml", ".yml"):
|
||||
try:
|
||||
data = yaml.safe_load(config_file.content)
|
||||
if isinstance(data, dict):
|
||||
duplicates = self._find_dict_duplicates(data)
|
||||
else:
|
||||
return None
|
||||
except yaml.YAMLError:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
if duplicates:
|
||||
return ValidationIssue(
|
||||
rule="no_duplicate_keys",
|
||||
message=f"Duplicate keys found: {', '.join(duplicates)}",
|
||||
severity=Severity.WARNING,
|
||||
file_path=config_file.path,
|
||||
suggestion="Consider consolidating duplicate configurations",
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _find_json_duplicates(self, data: Any, path: str = "") -> List[str]:
|
||||
"""Find duplicate keys in JSON data."""
|
||||
duplicates = []
|
||||
if isinstance(data, dict):
|
||||
seen = {}
|
||||
for key, value in data.items():
|
||||
full_path = f"{path}.{key}" if path else key
|
||||
if isinstance(value, (dict, list)):
|
||||
sub_dups = self._find_json_duplicates(value, full_path)
|
||||
duplicates.extend(sub_dups)
|
||||
if key in seen:
|
||||
duplicates.append(full_path)
|
||||
else:
|
||||
seen[key] = full_path
|
||||
elif isinstance(data, list):
|
||||
for i, item in enumerate(data):
|
||||
sub_dups = self._find_json_duplicates(item, f"{path}[{i}]")
|
||||
duplicates.extend(sub_dups)
|
||||
return duplicates
|
||||
|
||||
def _find_dict_duplicates(self, data: Dict, prefix: str = "") -> List[str]:
|
||||
"""Find duplicate keys in nested dictionary."""
|
||||
duplicates = []
|
||||
seen = {}
|
||||
for key, value in data.items():
|
||||
full_key = f"{prefix}.{key}" if prefix else key
|
||||
if isinstance(value, dict):
|
||||
sub_dups = self._find_dict_duplicates(value, full_key)
|
||||
duplicates.extend(sub_dups)
|
||||
if key in seen:
|
||||
duplicates.append(full_key)
|
||||
else:
|
||||
seen[key] = full_key
|
||||
return duplicates
|
||||
|
||||
def check_no_duplicate_sections(self, config_file: ConfigFile) -> Optional[ValidationIssue]:
|
||||
"""Check for duplicate sections in TOML."""
|
||||
try:
|
||||
data = toml.loads(config_file.content)
|
||||
except toml.TomlDecodeError:
|
||||
return None
|
||||
|
||||
if isinstance(data, dict):
|
||||
sections: Dict[str, List[str]] = {}
|
||||
for section in data.keys():
|
||||
if section in sections:
|
||||
sections[section].append(section)
|
||||
else:
|
||||
sections[section] = [section]
|
||||
|
||||
duplicates = [s for s, ids in sections.items() if len(ids) > 1]
|
||||
if duplicates:
|
||||
return ValidationIssue(
|
||||
rule="no_duplicate_sections",
|
||||
message=f"Duplicate sections found: {', '.join(duplicates)}",
|
||||
severity=Severity.WARNING,
|
||||
file_path=config_file.path,
|
||||
suggestion="Remove duplicate sections from TOML file",
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def check_valid_shell_syntax(self, config_file: ConfigFile) -> Optional[ValidationIssue]:
|
||||
"""Check basic shell script syntax."""
|
||||
content = config_file.content.strip()
|
||||
|
||||
common_issues = []
|
||||
|
||||
if content and not content.startswith('#') and not content.startswith('alias '):
|
||||
pass
|
||||
|
||||
unclosed_quotes = self._check_unclosed_quotes(content)
|
||||
if unclosed_quotes:
|
||||
common_issues.append(f"Unclosed quotes: {unclosed_quotes}")
|
||||
|
||||
unbalanced_brackets = self._check_unbalanced_brackets(content)
|
||||
if unbalanced_brackets:
|
||||
common_issues.append(f"Unbalanced brackets: {unbalanced_brackets}")
|
||||
|
||||
if common_issues:
|
||||
return ValidationIssue(
|
||||
rule="valid_shell_syntax",
|
||||
message=f"Potential shell syntax issues: {'; '.join(common_issues)}",
|
||||
severity=Severity.WARNING,
|
||||
file_path=config_file.path,
|
||||
suggestion="Review shell script syntax",
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _check_unclosed_quotes(self, content: str) -> Optional[str]:
|
||||
"""Check for unclosed quotes."""
|
||||
in_single = False
|
||||
in_double = False
|
||||
|
||||
i = 0
|
||||
while i < len(content):
|
||||
char = content[i]
|
||||
if char == "'" and not in_double:
|
||||
in_single = not in_single
|
||||
elif char == '"' and not in_single:
|
||||
in_double = not in_double
|
||||
i += 1
|
||||
|
||||
if in_single:
|
||||
return "single quote (')"
|
||||
if in_double:
|
||||
return 'double quote (")'
|
||||
return None
|
||||
|
||||
def _check_unbalanced_brackets(self, content: str) -> Optional[str]:
|
||||
"""Check for unbalanced brackets."""
|
||||
brackets = {'(': ')', '[': ']', '{': '}'}
|
||||
stack = []
|
||||
|
||||
for i, char in enumerate(content):
|
||||
if char in brackets:
|
||||
stack.append((char, i))
|
||||
elif char in brackets.values():
|
||||
if not stack:
|
||||
return f"unmatched {char} at position {i}"
|
||||
opening, _ = stack.pop()
|
||||
if brackets.get(opening) != char:
|
||||
return f"mismatched {opening} at position {_} and {char} at position {i}"
|
||||
|
||||
if stack:
|
||||
opening, pos = stack[0]
|
||||
return f"unclosed {opening} at position {pos}"
|
||||
|
||||
return None
|
||||
|
||||
def check_valid_git_config(self, config_file: ConfigFile) -> Optional[ValidationIssue]:
|
||||
"""Check if git config is valid."""
|
||||
try:
|
||||
parser = configparser.ConfigParser()
|
||||
parser.read_string(config_file.content)
|
||||
|
||||
for section in parser.sections():
|
||||
if section.startswith('include'):
|
||||
pass
|
||||
|
||||
return None
|
||||
except configparser.Error as e:
|
||||
return ValidationIssue(
|
||||
rule="valid_git_config",
|
||||
message=f"Invalid git config: {str(e)}",
|
||||
severity=Severity.ERROR,
|
||||
file_path=config_file.path,
|
||||
)
|
||||
|
||||
def check_valid_gitignore(self, config_file: ConfigFile) -> Optional[ValidationIssue]:
|
||||
"""Check gitignore for common issues."""
|
||||
issues = []
|
||||
lines = config_file.content.split('\n')
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
|
||||
if line.startswith('/') and line.count('/') > 1:
|
||||
issues.append(f"Line {i}: Multiple leading slashes may cause issues")
|
||||
|
||||
if '**' in line and line.count('**') != 2:
|
||||
issues.append(f"Line {i}: Malformed glob pattern with '**'")
|
||||
|
||||
if line.endswith('/') and len(line) > 1:
|
||||
issues.append(f"Line {i}: Trailing slash - directory pattern detected")
|
||||
|
||||
if issues:
|
||||
return ValidationIssue(
|
||||
rule="valid_gitignore",
|
||||
message=f"Potential gitignore issues: {'; '.join(issues)}",
|
||||
severity=Severity.WARNING,
|
||||
file_path=config_file.path,
|
||||
suggestion="Review gitignore patterns for correctness",
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def check_valid_dockerfile(self, config_file: ConfigFile) -> Optional[ValidationIssue]:
|
||||
"""Check Dockerfile for common issues."""
|
||||
issues = []
|
||||
lines = config_file.content.split('\n')
|
||||
|
||||
has_from = False
|
||||
for i, line in enumerate(lines, 1):
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
|
||||
upper_line = line.upper()
|
||||
|
||||
if upper_line.startswith('FROM'):
|
||||
has_from = True
|
||||
if ':' not in line:
|
||||
issues.append(f"Line {i}: FROM instruction without tag")
|
||||
|
||||
if upper_line.startswith('RUN') and ('apt-get' in line or 'yum' in line):
|
||||
if '&&' not in line:
|
||||
issues.append(f"Line {i}: Package manager command without cleanup")
|
||||
|
||||
if not has_from:
|
||||
issues.append("No FROM instruction found")
|
||||
|
||||
if issues:
|
||||
return ValidationIssue(
|
||||
rule="valid_dockerfile",
|
||||
message=f"Dockerfile issues: {'; '.join(issues)}",
|
||||
severity=Severity.WARNING if has_from else Severity.ERROR,
|
||||
file_path=config_file.path,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _check_conflicts(self, configs: List[ConfigFile]) -> List[ValidationIssue]:
|
||||
"""Check for conflicts between configurations."""
|
||||
issues: List[ValidationIssue] = []
|
||||
tool_groups: Dict[str, List[ConfigFile]] = {}
|
||||
|
||||
for config in configs:
|
||||
tool = config.tool_name.lower()
|
||||
if tool not in tool_groups:
|
||||
tool_groups[tool] = []
|
||||
tool_groups[tool].append(config)
|
||||
|
||||
for tool, tool_configs in tool_groups.items():
|
||||
if len(tool_configs) > 1:
|
||||
issue = ValidationIssue(
|
||||
rule="multiple_configs",
|
||||
message=f"Multiple configurations found for {tool}: {[c.path for c in tool_configs]}",
|
||||
severity=Severity.INFO,
|
||||
suggestion="Ensure these are intentional (e.g., different environments)",
|
||||
)
|
||||
issues.append(issue)
|
||||
|
||||
return issues
|
||||
|
||||
def generate_report(self, result: ValidationResult) -> str:
|
||||
"""Generate a human-readable validation report."""
|
||||
lines = []
|
||||
lines.append("=" * 60)
|
||||
lines.append("Configuration Validation Report")
|
||||
lines.append("=" * 60)
|
||||
lines.append(f"Validated Files: {result.validated_files}")
|
||||
lines.append(f"Overall Status: {'VALID' if result.is_valid else 'ISSUES FOUND'}")
|
||||
lines.append("-" * 60)
|
||||
|
||||
if not result.issues:
|
||||
lines.append("No issues found.")
|
||||
return '\n'.join(lines)
|
||||
|
||||
for severity in [Severity.ERROR, Severity.WARNING, Severity.INFO]:
|
||||
severity_issues = [i for i in result.issues if i.severity == severity]
|
||||
if severity_issues:
|
||||
lines.append(f"\n{severity.value.upper()}S ({len(severity_issues)}):")
|
||||
for issue in severity_issues:
|
||||
lines.append(f" - [{issue.rule}] {issue.message}")
|
||||
if issue.file_path:
|
||||
lines.append(f" File: {issue.file_path}")
|
||||
if issue.suggestion:
|
||||
lines.append(f" Suggestion: {issue.suggestion}")
|
||||
|
||||
lines.append("-" * 60)
|
||||
lines.append(f"Total Issues: {len(result.issues)}")
|
||||
lines.append("=" * 60)
|
||||
|
||||
return '\n'.join(lines)
|
||||
Reference in New Issue
Block a user