#!/usr/bin/env python3 """ Compare BIND9 grammar files between versions to identify breaking changes. This script compares grammar files from two BIND9 versions and generates a comprehensive report of: - Removed options (breaking changes) - Added options (new features) - Modified option syntax - Deprecated options Usage: python scripts/compare_bind_versions.py \\ --version1-dir bind9-grammar/upstream/v9.18.44 \\ --version2-dir bind9-grammar/upstream/v9.20.18 \\ --output docs/BIND_VERSION_DIFFERENCES.md """ import argparse import json import sys from pathlib import Path from typing import Dict, List, Set, Tuple import re class GrammarComparator: """Compare BIND9 grammar files between versions.""" def __init__(self, version1_dir: Path, version2_dir: Path): """ Initialize comparator with two version directories. Args: version1_dir: Path to first version's grammar files version2_dir: Path to second version's grammar files """ self.version1_dir = version1_dir self.version2_dir = version2_dir self.version1_name = version1_dir.name self.version2_name = version2_dir.name def load_grammar_file(self, file_path: Path) -> Dict: """Load and parse a grammar file.""" try: with open(file_path, 'r', encoding='utf-8') as f: content = f.read() # Parse grammar file into structured data options = self._parse_grammar(content) return options except FileNotFoundError: return {} def _parse_grammar(self, content: str) -> Dict[str, str]: """ Parse grammar content into a dictionary of options. This is a simplified parser that extracts top-level keywords and their definitions. """ options = {} lines = content.split('\n') for line in lines: line = line.strip() if not line or line.startswith('#') or line.startswith('//'): continue # Extract keyword and its definition # Pattern: keyword ; or keyword { ... }; match = re.match(r'^([a-z0-9-]+)\s+(.+?)(?:;|$)', line) if match: keyword = match.group(1) definition = match.group(2).strip() # Extract flags from comments flags = [] if '// may occur multiple times' in line: flags.append('may occur multiple times') if '// deprecated' in line: flags.append('deprecated') if '// obsolete' in line: flags.append('obsolete') if '// not configured' in line: flags.append('not configured') if '// test only' in line: flags.append('test only') if '// experimental' in line: flags.append('experimental') options[keyword] = { 'definition': definition, 'flags': flags, 'raw_line': line } return options def compare_files(self, filename: str) -> Dict: """ Compare a specific grammar file between two versions. Returns: Dict with added, removed, modified, and deprecated options """ file1 = self.version1_dir / 'grammar' / filename file2 = self.version2_dir / 'grammar' / filename options1 = self.load_grammar_file(file1) options2 = self.load_grammar_file(file2) keys1 = set(options1.keys()) keys2 = set(options2.keys()) # Identify changes added = keys2 - keys1 removed = keys1 - keys2 common = keys1 & keys2 modified = [] deprecated_new = [] for key in common: opt1 = options1[key] opt2 = options2[key] # Check if definition changed if opt1['definition'] != opt2['definition']: modified.append({ 'option': key, 'old_definition': opt1['definition'], 'new_definition': opt2['definition'] }) # Check if newly deprecated if 'deprecated' not in opt1['flags'] and 'deprecated' in opt2['flags']: deprecated_new.append(key) return { 'file': filename, 'added': sorted(added), 'removed': sorted(removed), 'modified': modified, 'deprecated_new': deprecated_new, 'options1_count': len(options1), 'options2_count': len(options2) } def compare_all(self) -> Dict: """Compare all grammar files between versions.""" # List of grammar files to compare grammar_files = [ 'options', 'forward.zoneopt', 'hint.zoneopt', 'in-view.zoneopt', 'mirror.zoneopt', 'primary.zoneopt', 'redirect.zoneopt', 'secondary.zoneopt', 'static-stub.zoneopt', 'stub.zoneopt', 'delegation-only.zoneopt', 'rndc.grammar', ] results = {} for filename in grammar_files: result = self.compare_files(filename) results[filename] = result return results def generate_markdown_report(self, results: Dict) -> str: """Generate a Markdown report from comparison results.""" lines = [] lines.append(f"# BIND9 Version Differences: {self.version1_name} vs {self.version2_name}") lines.append("") lines.append(f"This document compares BIND9 configuration grammar between {self.version1_name} and {self.version2_name}.") lines.append("") lines.append("Generated automatically by `scripts/compare_bind_versions.py`.") lines.append("") # Summary lines.append("## Summary") lines.append("") total_added = sum(len(r['added']) for r in results.values()) total_removed = sum(len(r['removed']) for r in results.values()) total_modified = sum(len(r['modified']) for r in results.values()) total_deprecated = sum(len(r['deprecated_new']) for r in results.values()) lines.append(f"- **New Options**: {total_added}") lines.append(f"- **Removed Options**: {total_removed} ⚠️") lines.append(f"- **Modified Options**: {total_modified}") lines.append(f"- **Newly Deprecated**: {total_deprecated}") lines.append("") # Breaking Changes if total_removed > 0: lines.append("## ⚠️ Breaking Changes") lines.append("") lines.append(f"The following options were removed in {self.version2_name} and will cause configuration errors:") lines.append("") for filename, result in results.items(): if result['removed']: lines.append(f"### {filename}") lines.append("") for option in result['removed']: lines.append(f"- `{option}`") lines.append("") # New Features if total_added > 0: lines.append("## ✨ New Features") lines.append("") lines.append(f"The following options were added in {self.version2_name}:") lines.append("") for filename, result in results.items(): if result['added']: lines.append(f"### {filename}") lines.append("") for option in result['added']: lines.append(f"- `{option}`") lines.append("") # Modified Options if total_modified > 0: lines.append("## 🔧 Modified Options") lines.append("") lines.append(f"The following options have syntax changes in {self.version2_name}:") lines.append("") for filename, result in results.items(): if result['modified']: lines.append(f"### {filename}") lines.append("") for mod in result['modified']: lines.append(f"#### `{mod['option']}`") lines.append("") lines.append(f"**{self.version1_name}**:") lines.append(f"```") lines.append(f"{mod['old_definition']}") lines.append(f"```") lines.append("") lines.append(f"**{self.version2_name}**:") lines.append(f"```") lines.append(f"{mod['new_definition']}") lines.append(f"```") lines.append("") # Deprecated Options if total_deprecated > 0: lines.append("## 📋 Newly Deprecated Options") lines.append("") lines.append(f"The following options were marked as deprecated in {self.version2_name}:") lines.append("") for filename, result in results.items(): if result['deprecated_new']: lines.append(f"### {filename}") lines.append("") for option in result['deprecated_new']: lines.append(f"- `{option}`") lines.append("") # File-by-File Comparison lines.append("## Detailed File-by-File Comparison") lines.append("") for filename, result in results.items(): lines.append(f"### {filename}") lines.append("") lines.append(f"- {self.version1_name}: {result['options1_count']} options") lines.append(f"- {self.version2_name}: {result['options2_count']} options") lines.append(f"- Added: {len(result['added'])}") lines.append(f"- Removed: {len(result['removed'])}") lines.append(f"- Modified: {len(result['modified'])}") lines.append("") # Migration Guide if total_removed > 0 or total_deprecated > 0: lines.append("## Migration Guide") lines.append("") lines.append(f"### Migrating from {self.version1_name} to {self.version2_name}") lines.append("") if total_removed > 0: lines.append("1. **Remove unsupported options** from your configuration") lines.append(" - Review the Breaking Changes section above") lines.append(" - Check if there are replacement options") lines.append("") if total_deprecated > 0: lines.append("2. **Plan for deprecated options**") lines.append(" - These options still work but may be removed in future versions") lines.append(" - Start planning migration to recommended alternatives") lines.append("") lines.append("3. **Test your configuration**") lines.append(" - Use `named-checkconf` to validate syntax") lines.append(" - Test in a development environment before production") lines.append("") return '\n'.join(lines) def main(): """Main entry point.""" parser = argparse.ArgumentParser( description="Compare BIND9 grammar files between versions" ) parser.add_argument( "--version1-dir", type=Path, required=True, help="Directory containing first version's grammar files" ) parser.add_argument( "--version2-dir", type=Path, required=True, help="Directory containing second version's grammar files" ) parser.add_argument( "--output", type=Path, default=Path("docs/BIND_VERSION_DIFFERENCES.md"), help="Output file for Markdown report" ) parser.add_argument( "--json", type=Path, help="Also output raw comparison as JSON" ) args = parser.parse_args() # Validate directories if not args.version1_dir.exists(): print(f"Error: {args.version1_dir} does not exist", file=sys.stderr) sys.exit(1) if not args.version2_dir.exists(): print(f"Error: {args.version2_dir} does not exist", file=sys.stderr) sys.exit(1) # Perform comparison print(f"Comparing BIND9 versions:") print(f" Version 1: {args.version1_dir.name}") print(f" Version 2: {args.version2_dir.name}") comparator = GrammarComparator(args.version1_dir, args.version2_dir) results = comparator.compare_all() # Generate and save Markdown report report = comparator.generate_markdown_report(results) args.output.parent.mkdir(parents=True, exist_ok=True) with open(args.output, 'w', encoding='utf-8') as f: f.write(report) print(f"✓ Markdown report saved to: {args.output}") # Save JSON if requested if args.json: args.json.parent.mkdir(parents=True, exist_ok=True) with open(args.json, 'w', encoding='utf-8') as f: json.dump(results, f, indent=2) print(f"✓ JSON comparison saved to: {args.json}") # Print summary total_added = sum(len(r['added']) for r in results.values()) total_removed = sum(len(r['removed']) for r in results.values()) total_modified = sum(len(r['modified']) for r in results.values()) print(f"\nComparison Summary:") print(f" Added options: {total_added}") print(f" Removed options: {total_removed}") print(f" Modified options: {total_modified}") if total_removed > 0: print(f"\n⚠️ Warning: {total_removed} breaking changes detected!") if __name__ == "__main__": main()