- Add fetch_bind_grammar.py for MCP-based grammar file retrieval - Add compare_bind_versions.py for version differences analysis - Add process_mcp_result.py for handling base64-encoded MCP output - Create upstream directory structure with fetching instructions - Document grammar file locations and structure
391 lines
14 KiB
Python
391 lines
14 KiB
Python
#!/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 <definition>; or keyword <definition> { ... };
|
|
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()
|