Repository navigation
Expand file tree
/
Copy pathvalidate_yaml.py
More file actions
348 lines (276 loc) · 11.6 KB
/
Copy pathvalidate_yaml.py
File metadata and controls
348 lines (276 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
#!/usr/bin/env python3
"""
CI Validation Script for YAML Files
Runs yamllint and yamlfmt --lint with beautified error output
Usage: python scripts/validate_yaml.py [path_to_yaml_files...]
"""
import argparse
import subprocess
import sys
from pathlib import Path
from typing import List, Tuple, Optional
def find_yamlfmt(custom_path: Optional[str] = None) -> Optional[str]:
"""Find yamlfmt executable in common locations or use custom path
Args:
custom_path: Optional path to yamlfmt binary
Returns:
Path to yamlfmt executable or None if not found
"""
# If custom path provided, verify and use it
if custom_path:
custom_path_obj = Path(custom_path)
if custom_path_obj.exists():
return str(custom_path_obj)
else:
print_error(f"yamlfmt not found at specified path: {custom_path}")
return None
# Check if yamlfmt is in PATH
for cmd in ['yamlfmt', 'yamlfmt.exe']:
try:
result = subprocess.run([cmd, '--version'], capture_output=True, text=True)
if result.returncode == 0:
return cmd
except FileNotFoundError:
pass
# Check common installation paths
possible_paths = [
Path.home() / 'go' / 'bin' / 'yamlfmt',
Path.home() / 'go' / 'bin' / 'yamlfmt.exe',
Path('/usr/local/bin/yamlfmt'),
Path('/usr/bin/yamlfmt'),
]
for path in possible_paths:
if path.exists():
return str(path)
print_error("yamlfmt not found. Install with: go install github.com/google/yamlfmt/cmd/yamlfmt@latest")
print("Make sure $GOPATH/bin is in your PATH")
print("Or use --yamlfmt-path to specify a custom yamlfmt binary location")
return None
class Colors:
"""ANSI color codes for terminal output"""
RED = '\033[91m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
BLUE = '\033[94m'
MAGENTA = '\033[95m'
CYAN = '\033[96m'
BOLD = '\033[1m'
RESET = '\033[0m'
@classmethod
def disable(cls):
"""Disable colors for non-terminal output"""
cls.RED = cls.GREEN = cls.YELLOW = cls.BLUE = ''
cls.MAGENTA = cls.CYAN = cls.BOLD = cls.RESET = ''
def print_header(text: str):
"""Print a formatted header"""
print(f"\n{Colors.BOLD}{Colors.CYAN}{'=' * 80}{Colors.RESET}")
print(f"{Colors.BOLD}{Colors.CYAN}{text:^80}{Colors.RESET}")
print(f"{Colors.BOLD}{Colors.CYAN}{'=' * 80}{Colors.RESET}\n")
def print_error(text: str):
"""Print an error message"""
print(f"{Colors.RED}[ERROR] {text}{Colors.RESET}")
def print_success(text: str):
"""Print a success message"""
print(f"{Colors.GREEN}[PASS] {text}{Colors.RESET}")
def print_warning(text: str):
"""Print a warning message"""
print(f"{Colors.YELLOW}[WARN] {text}{Colors.RESET}")
def find_yaml_files(paths: List[str]) -> List[Path]:
"""Find all YAML files in detections/ from given paths"""
yaml_files = []
for path_str in paths:
path = Path(path_str)
# Make relative to repo root if absolute
if path.is_absolute():
try:
repo_root = Path(subprocess.run(
['git', 'rev-parse', '--show-toplevel'],
capture_output=True,
text=True,
check=True
).stdout.strip())
path = path.relative_to(repo_root)
except:
pass
if path.is_file():
# Check if path contains 'detections' or if it's in detections/
path_str_normalized = str(path).replace('\\', '/')
if 'detections/' in path_str_normalized and path.suffix in ['.yml', '.yaml']:
yaml_files.append(path)
elif path.is_dir():
for yaml_file in path.rglob('*.yml'):
path_str_normalized = str(yaml_file).replace('\\', '/')
if 'detections/' in path_str_normalized:
yaml_files.append(yaml_file)
for yaml_file in path.rglob('*.yaml'):
path_str_normalized = str(yaml_file).replace('\\', '/')
if 'detections/' in path_str_normalized:
yaml_files.append(yaml_file)
return sorted(set(yaml_files))
def run_yamllint(files: List[Path], config: Path) -> Tuple[bool, dict]:
"""Run yamllint on files and return success status and errors by file
Returns:
Tuple of (success, file_errors_dict)
"""
if not files:
return True, {}
# Process files in batches to avoid command line length limits on Windows
batch_size = 50
all_file_errors = {}
all_passed = True
for i in range(0, len(files), batch_size):
batch = files[i:i + batch_size]
cmd = ['yamllint', '-c', str(config)] + [str(f) for f in batch]
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='replace')
if result.returncode != 0:
all_passed = False
# Parse yamllint output
errors = result.stdout.strip().split('\n')
current_file = None
for line in errors:
if not line.strip():
continue
# Check if this is a file path line
if not line.startswith(' '):
current_file = line
if current_file not in all_file_errors:
all_file_errors[current_file] = []
else:
# This is an error line
if current_file:
all_file_errors[current_file].append(line.strip())
return all_passed, all_file_errors
def run_yamlfmt_lint(files: List[Path], config: Path, yamlfmt_path: Optional[str] = None) -> Tuple[bool, set]:
"""Run yamlfmt --lint on files and return success status and set of files with issues
Returns:
Tuple of (success, set_of_unformatted_files)
"""
if not files:
return True, set()
# Find yamlfmt executable
yamlfmt = find_yamlfmt(yamlfmt_path)
if not yamlfmt:
return False, set()
# Process files in batches to avoid command line length limits on Windows
batch_size = 50
all_unformatted_files = set()
all_passed = True
for i in range(0, len(files), batch_size):
batch = files[i:i + batch_size]
# yamlfmt --lint returns non-zero if files need formatting
cmd = [yamlfmt, '-lint', '-conf', str(config)] + [str(f) for f in batch]
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='replace')
if result.returncode != 0:
all_passed = False
# Parse yamlfmt output to find files that need formatting
output = result.stdout.strip()
if output:
lines = output.split('\n')
for line in lines:
line = line.strip()
# yamlfmt outputs the file path, often followed by a colon
if (line.endswith('.yml') or line.endswith('.yaml') or
line.endswith('.yml:') or line.endswith('.yaml:')):
# Remove trailing colon if present
file_path = line.rstrip(':')
if file_path:
all_unformatted_files.add(file_path)
# If we couldn't parse files from this batch, add all batch files
if not output and result.returncode != 0:
all_unformatted_files.update(str(f) for f in batch)
return all_passed, all_unformatted_files
def main():
"""Main entry point"""
parser = argparse.ArgumentParser(
description='Validate YAML files with yamllint and yamlfmt',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=r"""
Examples:
# Validate all detection files
python scripts/validate_yaml.py detections/
# Validate specific files
python scripts/validate_yaml.py detections/endpoint/file.yml
# Validate changed files (for CI)
python scripts/validate_yaml.py $(git diff --name-only --diff-filter=ACM origin/develop...HEAD | grep '^detections/.*\.yml$')
"""
)
parser.add_argument(
'paths',
nargs='*',
default=['detections/'],
help='Paths to YAML files or directories to validate (default: detections/)'
)
parser.add_argument(
'--no-color',
action='store_true',
help='Disable colored output'
)
parser.add_argument(
'--yamlfmt-path',
help='Path to yamlfmt binary (useful when Go is not available but standalone binary is)'
)
args = parser.parse_args()
if args.no_color or not sys.stdout.isatty():
Colors.disable()
# Find repo root
try:
repo_root = subprocess.run(
['git', 'rev-parse', '--show-toplevel'],
capture_output=True,
text=True,
check=True
).stdout.strip()
repo_root = Path(repo_root)
except (subprocess.CalledProcessError, FileNotFoundError):
repo_root = Path.cwd()
# Find config files
yamllint_config = repo_root / '.yamllint'
yamlfmt_config = repo_root / '.yamlfmt'
if not yamllint_config.exists():
print_error(f".yamllint config not found at {yamllint_config}")
return 1
if not yamlfmt_config.exists():
print_error(f".yamlfmt config not found at {yamlfmt_config}")
return 1
# Find YAML files
yaml_files = find_yaml_files(args.paths)
if not yaml_files:
print_warning("No YAML files found in detections/ directory")
return 0
print(f"{Colors.BOLD}Found {len(yaml_files)} YAML file(s) to validate{Colors.RESET}")
print_header("Validating YAML Files")
# Run both validations
yamllint_passed, yamllint_errors = run_yamllint(yaml_files, yamllint_config)
yamlfmt_passed, yamlfmt_files = run_yamlfmt_lint(yaml_files, yamlfmt_config, args.yamlfmt_path)
# Combine results by file
all_issues = {} # file_path -> list of errors
# Add yamllint errors
for file_path, errors in yamllint_errors.items():
if file_path not in all_issues:
all_issues[file_path] = []
all_issues[file_path].extend(errors)
# Add yamlfmt formatting issues
for file_path in yamlfmt_files:
if file_path not in all_issues:
all_issues[file_path] = []
all_issues[file_path].append("Formatting differences detected")
# Display combined results
if not all_issues:
print_success(f"All {len(yaml_files)} file(s) passed validation!")
return 0
# Show issues grouped by file
total_errors = sum(len(errors) for errors in all_issues.values())
print(f"\n{Colors.RED}[X] Found issues in {len(all_issues)} file(s):{Colors.RESET}\n")
for file_path, errors in sorted(all_issues.items()):
print(f" {Colors.BOLD}{Colors.MAGENTA}> {file_path}{Colors.RESET}")
for error in errors:
print(f" {Colors.RED}- {error}{Colors.RESET}")
print() # Empty line between files
# Show fix instructions
print(f"{Colors.CYAN}[TIP] To fix these issues, run:{Colors.RESET}")
print(f" {Colors.BOLD}yamlfmt -conf .yamlfmt detections/{Colors.RESET}")
print(f"\n{Colors.CYAN} Or for specific files:{Colors.RESET}")
print(f" {Colors.BOLD}yamlfmt -conf .yamlfmt <file_path>{Colors.RESET}\n")
return 1
if __name__ == '__main__':
sys.exit(main())