Navegador

navegador / navegador / ingestion / ansible.py
Blame History Raw 675 lines
1
"""
2
Ansible playbook/task parser — extracts plays, tasks, handlers, roles,
3
and variables from Ansible YAML files into the navegador graph.
4
5
Unlike other parsers this does NOT use tree-sitter. Ansible semantics
6
are encoded in YAML structure (dicts with well-known keys like ``hosts``,
7
``tasks``, ``handlers``), so we parse with ``yaml.safe_load()`` and walk
8
the resulting Python data structures directly.
9
10
Invoked via a hook in RepoIngester rather than through LANGUAGE_MAP.
11
"""
12
13
import logging
14
import re
15
from pathlib import Path
16
17
from navegador.graph.schema import EdgeType, NodeLabel
18
from navegador.graph.store import GraphStore
19
from navegador.ingestion.parser import LanguageParser
20
21
logger = logging.getLogger(__name__)
22
23
# Well-known Ansible module names — used to identify task dicts that lack
24
# an explicit ``name`` key and to extract the module used by a task.
25
_ANSIBLE_MODULES = {
26
"apt",
27
"yum",
28
"dnf",
29
"pip",
30
"gem",
31
"npm",
32
"copy",
33
"template",
34
"file",
35
"lineinfile",
36
"blockinfile",
37
"service",
38
"systemd",
39
"command",
40
"shell",
41
"raw",
42
"script",
43
"git",
44
"get_url",
45
"uri",
46
"unarchive",
47
"user",
48
"group",
49
"cron",
50
"mount",
51
"docker_container",
52
"docker_image",
53
"k8s",
54
"helm",
55
"debug",
56
"assert",
57
"fail",
58
"set_fact",
59
"include_tasks",
60
"import_tasks",
61
"include_role",
62
"import_role",
63
"block",
64
"rescue",
65
"always",
66
"wait_for",
67
"pause",
68
"stat",
69
"find",
70
"replace",
71
"package",
72
"hostname",
73
"timezone",
74
"sysctl",
75
"authorized_key",
76
"firewalld",
77
"iptables",
78
"aws_s3",
79
"ec2",
80
"ec2_instance",
81
"s3_bucket",
82
"ansible.builtin.copy",
83
"ansible.builtin.template",
84
"ansible.builtin.file",
85
"ansible.builtin.command",
86
"ansible.builtin.shell",
87
"ansible.builtin.service",
88
"ansible.builtin.debug",
89
"ansible.builtin.set_fact",
90
"ansible.builtin.include_tasks",
91
"ansible.builtin.import_tasks",
92
"ansible.builtin.include_role",
93
"ansible.builtin.import_role",
94
"ansible.builtin.apt",
95
"ansible.builtin.yum",
96
"ansible.builtin.pip",
97
"ansible.builtin.git",
98
"ansible.builtin.user",
99
"ansible.builtin.group",
100
"ansible.builtin.uri",
101
"ansible.builtin.get_url",
102
"ansible.builtin.lineinfile",
103
"ansible.builtin.blockinfile",
104
"ansible.builtin.systemd",
105
"ansible.builtin.raw",
106
"ansible.builtin.script",
107
"ansible.builtin.unarchive",
108
"ansible.builtin.assert",
109
"ansible.builtin.fail",
110
"ansible.builtin.wait_for",
111
"ansible.builtin.pause",
112
"ansible.builtin.stat",
113
"ansible.builtin.find",
114
"ansible.builtin.replace",
115
"ansible.builtin.package",
116
}
117
118
# Patterns in file paths that strongly suggest Ansible content
119
_ROLE_TASKS_RE = re.compile(r"roles/[^/]+/tasks/")
120
_ROLE_HANDLERS_RE = re.compile(r"roles/[^/]+/handlers/")
121
_ROLE_DEFAULTS_RE = re.compile(r"roles/[^/]+/defaults/")
122
_ROLE_VARS_RE = re.compile(r"roles/[^/]+/vars/")
123
_PLAYBOOKS_DIR_RE = re.compile(r"(^|/)playbooks/")
124
_COMMON_PLAYBOOK_RE = re.compile(
125
r"(^|/)(playbook[^/]*|site|main|common|deploy|provision|setup|configure)\.(yml|yaml)$"
126
)
127
_GROUP_VARS_RE = re.compile(r"(^|/)group_vars/")
128
_HOST_VARS_RE = re.compile(r"(^|/)host_vars/")
129
130
131
class AnsibleParser(LanguageParser):
132
"""Parses Ansible YAML files into the navegador graph."""
133
134
def __init__(self) -> None:
135
pass # no tree-sitter parser needed
136
137
@staticmethod
138
def is_ansible_file(path: Path, repo_root: Path | None = None) -> bool:
139
"""Return True if *path* looks like an Ansible YAML file."""
140
if path.suffix not in (".yml", ".yaml"):
141
return False
142
143
rel = str(path)
144
if repo_root is not None:
145
try:
146
rel = str(path.relative_to(repo_root))
147
except ValueError:
148
pass
149
150
# Structural heuristics based on path
151
if _ROLE_TASKS_RE.search(rel):
152
return True
153
if _ROLE_HANDLERS_RE.search(rel):
154
return True
155
if _ROLE_DEFAULTS_RE.search(rel):
156
return True
157
if _ROLE_VARS_RE.search(rel):
158
return True
159
if _PLAYBOOKS_DIR_RE.search(rel):
160
return True
161
if _GROUP_VARS_RE.search(rel):
162
return True
163
if _HOST_VARS_RE.search(rel):
164
return True
165
166
# ansible.cfg sibling in repo root
167
if repo_root is not None and (repo_root / "ansible.cfg").exists():
168
if _COMMON_PLAYBOOK_RE.search(rel):
169
return True
170
171
# Content-based: top-level list whose items contain "hosts:" key
172
try:
173
text = path.read_text(encoding="utf-8", errors="replace")
174
except OSError:
175
return False
176
177
if not text.lstrip().startswith("---"):
178
return False
179
180
try:
181
import yaml
182
183
data = yaml.safe_load(text)
184
except Exception:
185
return False
186
187
if isinstance(data, list) and data:
188
if any(isinstance(item, dict) and "hosts" in item for item in data):
189
return True
190
191
return False
192
193
# ── Main entry point ─────────────────────────────────────────────────────
194
195
def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]:
196
rel_path = str(path.relative_to(repo_root))
197
stats = {"functions": 0, "classes": 0, "edges": 0}
198
199
try:
200
import yaml
201
202
text = path.read_text(encoding="utf-8", errors="replace")
203
data = yaml.safe_load(text)
204
except Exception as exc:
205
logger.warning("Could not parse Ansible file %s: %s", rel_path, exc)
206
return stats
207
208
if data is None:
209
return stats
210
211
# File node
212
store.create_node(
213
NodeLabel.File,
214
{
215
"name": path.name,
216
"path": rel_path,
217
"language": "ansible",
218
"line_count": text.count("\n"),
219
},
220
)
221
222
rel_str = rel_path.replace("\\", "/")
223
224
# Dispatch based on file type
225
if _ROLE_DEFAULTS_RE.search(rel_str) or _ROLE_VARS_RE.search(rel_str):
226
self._parse_variable_file(data, rel_path, store, stats)
227
elif _GROUP_VARS_RE.search(rel_str) or _HOST_VARS_RE.search(rel_str):
228
self._parse_variable_file(data, rel_path, store, stats)
229
elif _ROLE_HANDLERS_RE.search(rel_str):
230
self._parse_handler_file(data, rel_path, store, stats)
231
elif _ROLE_TASKS_RE.search(rel_str):
232
self._parse_task_file(data, rel_path, store, stats)
233
elif (
234
isinstance(data, list)
235
and data
236
and any(isinstance(item, dict) and "hosts" in item for item in data)
237
):
238
self._parse_playbook(data, rel_path, store, stats)
239
elif isinstance(data, list):
240
# Might be a task list (e.g. included task file)
241
self._parse_task_file(data, rel_path, store, stats)
242
elif isinstance(data, dict):
243
# Standalone variable file
244
self._parse_variable_file(data, rel_path, store, stats)
245
246
return stats
247
248
# ── Playbook parsing ─────────────────────────────────────────────────────
249
250
def _parse_playbook(
251
self,
252
data: list,
253
file_path: str,
254
store: GraphStore,
255
stats: dict,
256
) -> None:
257
"""Parse a full playbook (list of plays)."""
258
playbook_name = Path(file_path).stem
259
260
# Module node for the playbook file
261
store.create_node(
262
NodeLabel.Module,
263
{
264
"name": playbook_name,
265
"file_path": file_path,
266
"docstring": "",
267
"semantic_type": "ansible_playbook",
268
},
269
)
270
store.create_edge(
271
NodeLabel.File,
272
{"path": file_path},
273
EdgeType.CONTAINS,
274
NodeLabel.Module,
275
{"name": playbook_name, "file_path": file_path},
276
)
277
stats["edges"] += 1
278
279
for play in data:
280
if not isinstance(play, dict):
281
continue
282
if "hosts" not in play:
283
continue
284
self._parse_play(play, file_path, playbook_name, store, stats)
285
286
def _parse_play(
287
self,
288
play: dict,
289
file_path: str,
290
playbook_name: str,
291
store: GraphStore,
292
stats: dict,
293
) -> None:
294
"""Parse a single play dict."""
295
play_name = play.get("name", f"play:{play.get('hosts', 'unknown')}")
296
297
store.create_node(
298
NodeLabel.Class,
299
{
300
"name": play_name,
301
"file_path": file_path,
302
"line_start": 0,
303
"line_end": 0,
304
"docstring": f"hosts: {play.get('hosts', '')}",
305
"semantic_type": "ansible_play",
306
},
307
)
308
store.create_edge(
309
NodeLabel.Module,
310
{"name": playbook_name, "file_path": file_path},
311
EdgeType.CONTAINS,
312
NodeLabel.Class,
313
{"name": play_name, "file_path": file_path},
314
)
315
stats["classes"] += 1
316
stats["edges"] += 1
317
318
# Tasks
319
for task_dict in play.get("tasks", []) or []:
320
if isinstance(task_dict, dict):
321
self._parse_task(task_dict, file_path, play_name, store, stats)
322
323
# Pre-tasks
324
for task_dict in play.get("pre_tasks", []) or []:
325
if isinstance(task_dict, dict):
326
self._parse_task(task_dict, file_path, play_name, store, stats)
327
328
# Post-tasks
329
for task_dict in play.get("post_tasks", []) or []:
330
if isinstance(task_dict, dict):
331
self._parse_task(task_dict, file_path, play_name, store, stats)
332
333
# Handlers
334
for handler_dict in play.get("handlers", []) or []:
335
if isinstance(handler_dict, dict):
336
self._parse_handler(handler_dict, file_path, play_name, store, stats)
337
338
# Roles
339
for role in play.get("roles", []) or []:
340
self._parse_role_reference(role, file_path, play_name, store, stats)
341
342
# Variables
343
self._parse_vars_block(play.get("vars"), file_path, play_name, store, stats)
344
345
# ── Task parsing ─────────────────────────────────────────────────────────
346
347
def _task_name(self, task: dict) -> str:
348
"""Derive a task name from the dict."""
349
if "name" in task and task["name"]:
350
return str(task["name"])
351
# Fall back to module name
352
for key in task:
353
if key in _ANSIBLE_MODULES:
354
return key
355
# Last resort: first non-meta key
356
_meta_keys = {
357
"name",
358
"register",
359
"when",
360
"notify",
361
"tags",
362
"become",
363
"become_user",
364
"ignore_errors",
365
"changed_when",
366
"failed_when",
367
"loop",
368
"with_items",
369
"with_dict",
370
"with_fileglob",
371
"until",
372
"retries",
373
"delay",
374
"no_log",
375
"environment",
376
"vars",
377
"listen",
378
"delegate_to",
379
"run_once",
380
"timeout",
381
}
382
for key in task:
383
if key not in _meta_keys:
384
return key
385
return "unnamed_task"
386
387
def _parse_task(
388
self,
389
task: dict,
390
file_path: str,
391
parent_name: str,
392
store: GraphStore,
393
stats: dict,
394
) -> None:
395
"""Parse a single task dict into a Function node."""
396
task_name = self._task_name(task)
397
398
store.create_node(
399
NodeLabel.Function,
400
{
401
"name": task_name,
402
"file_path": file_path,
403
"line_start": 0,
404
"line_end": 0,
405
"docstring": "",
406
"semantic_type": "ansible_task",
407
},
408
)
409
store.create_edge(
410
NodeLabel.Class,
411
{"name": parent_name, "file_path": file_path},
412
EdgeType.CONTAINS,
413
NodeLabel.Function,
414
{"name": task_name, "file_path": file_path},
415
)
416
stats["functions"] += 1
417
stats["edges"] += 1
418
419
# notify: -> CALLS edge to handler
420
notify = task.get("notify")
421
if notify:
422
if isinstance(notify, str):
423
notify = [notify]
424
for handler_name in notify:
425
store.create_edge(
426
NodeLabel.Function,
427
{"name": task_name, "file_path": file_path},
428
EdgeType.CALLS,
429
NodeLabel.Function,
430
{"name": str(handler_name), "file_path": file_path},
431
)
432
stats["edges"] += 1
433
434
# Handle block/rescue/always
435
for block_key in ("block", "rescue", "always"):
436
block_tasks = task.get(block_key)
437
if isinstance(block_tasks, list):
438
for sub_task in block_tasks:
439
if isinstance(sub_task, dict):
440
self._parse_task(sub_task, file_path, parent_name, store, stats)
441
442
# ── Handler parsing ──────────────────────────────────────────────────────
443
444
def _parse_handler(
445
self,
446
handler: dict,
447
file_path: str,
448
parent_name: str,
449
store: GraphStore,
450
stats: dict,
451
) -> None:
452
"""Parse a handler dict into a Function node."""
453
handler_name = handler.get("name", self._task_name(handler))
454
455
store.create_node(
456
NodeLabel.Function,
457
{
458
"name": handler_name,
459
"file_path": file_path,
460
"line_start": 0,
461
"line_end": 0,
462
"docstring": "",
463
"semantic_type": "ansible_handler",
464
},
465
)
466
store.create_edge(
467
NodeLabel.Class,
468
{"name": parent_name, "file_path": file_path},
469
EdgeType.CONTAINS,
470
NodeLabel.Function,
471
{"name": handler_name, "file_path": file_path},
472
)
473
stats["functions"] += 1
474
stats["edges"] += 1
475
476
# ── Role reference parsing ───────────────────────────────────────────────
477
478
def _parse_role_reference(
479
self,
480
role,
481
file_path: str,
482
play_name: str,
483
store: GraphStore,
484
stats: dict,
485
) -> None:
486
"""Parse a role reference (string or dict with 'role' key)."""
487
if isinstance(role, str):
488
role_name = role
489
elif isinstance(role, dict):
490
role_name = role.get("role") or role.get("name", "")
491
else:
492
return
493
494
if not role_name:
495
return
496
497
store.create_node(
498
NodeLabel.Import,
499
{
500
"name": role_name,
501
"file_path": file_path,
502
"line_start": 0,
503
"module": role_name,
504
"semantic_type": "ansible_role",
505
},
506
)
507
store.create_edge(
508
NodeLabel.Class,
509
{"name": play_name, "file_path": file_path},
510
EdgeType.IMPORTS,
511
NodeLabel.Import,
512
{"name": role_name, "file_path": file_path},
513
)
514
stats["edges"] += 1
515
516
# ── Variable parsing ─────────────────────────────────────────────────────
517
518
def _parse_vars_block(
519
self,
520
vars_data,
521
file_path: str,
522
parent_name: str,
523
store: GraphStore,
524
stats: dict,
525
) -> None:
526
"""Parse a vars: block (dict) into Variable nodes."""
527
if not isinstance(vars_data, dict):
528
return
529
530
for var_name, var_value in vars_data.items():
531
store.create_node(
532
NodeLabel.Variable,
533
{
534
"name": str(var_name),
535
"file_path": file_path,
536
"line_start": 0,
537
"semantic_type": "ansible_variable",
538
},
539
)
540
store.create_edge(
541
NodeLabel.Class,
542
{"name": parent_name, "file_path": file_path},
543
EdgeType.CONTAINS,
544
NodeLabel.Variable,
545
{"name": str(var_name), "file_path": file_path},
546
)
547
stats["edges"] += 1
548
549
# ── Standalone file parsers ──────────────────────────────────────────────
550
551
def _parse_task_file(
552
self,
553
data,
554
file_path: str,
555
store: GraphStore,
556
stats: dict,
557
) -> None:
558
"""Parse a standalone task file (roles/*/tasks/main.yml or included file)."""
559
if not isinstance(data, list):
560
return
561
562
# Use file stem as a synthetic parent class
563
parent_name = Path(file_path).stem
564
store.create_node(
565
NodeLabel.Class,
566
{
567
"name": parent_name,
568
"file_path": file_path,
569
"line_start": 0,
570
"line_end": 0,
571
"docstring": "",
572
"semantic_type": "ansible_play",
573
},
574
)
575
store.create_edge(
576
NodeLabel.File,
577
{"path": file_path},
578
EdgeType.CONTAINS,
579
NodeLabel.Class,
580
{"name": parent_name, "file_path": file_path},
581
)
582
stats["classes"] += 1
583
stats["edges"] += 1
584
585
for task_dict in data:
586
if isinstance(task_dict, dict):
587
self._parse_task(task_dict, file_path, parent_name, store, stats)
588
589
def _parse_handler_file(
590
self,
591
data,
592
file_path: str,
593
store: GraphStore,
594
stats: dict,
595
) -> None:
596
"""Parse a standalone handler file (roles/*/handlers/main.yml)."""
597
if not isinstance(data, list):
598
return
599
600
parent_name = Path(file_path).stem
601
store.create_node(
602
NodeLabel.Class,
603
{
604
"name": parent_name,
605
"file_path": file_path,
606
"line_start": 0,
607
"line_end": 0,
608
"docstring": "",
609
"semantic_type": "ansible_play",
610
},
611
)
612
store.create_edge(
613
NodeLabel.File,
614
{"path": file_path},
615
EdgeType.CONTAINS,
616
NodeLabel.Class,
617
{"name": parent_name, "file_path": file_path},
618
)
619
stats["classes"] += 1
620
stats["edges"] += 1
621
622
for handler_dict in data:
623
if isinstance(handler_dict, dict):
624
self._parse_handler(handler_dict, file_path, parent_name, store, stats)
625
626
def _parse_variable_file(
627
self,
628
data,
629
file_path: str,
630
store: GraphStore,
631
stats: dict,
632
) -> None:
633
"""Parse a variable file (defaults/main.yml, vars/main.yml, group_vars/, host_vars/)."""
634
if not isinstance(data, dict):
635
return
636
637
# Use file stem as a synthetic parent
638
parent_name = Path(file_path).stem
639
store.create_node(
640
NodeLabel.Module,
641
{
642
"name": parent_name,
643
"file_path": file_path,
644
"docstring": "",
645
"semantic_type": "ansible_playbook",
646
},
647
)
648
store.create_edge(
649
NodeLabel.File,
650
{"path": file_path},
651
EdgeType.CONTAINS,
652
NodeLabel.Module,
653
{"name": parent_name, "file_path": file_path},
654
)
655
stats["edges"] += 1
656
657
for var_name in data:
658
store.create_node(
659
NodeLabel.Variable,
660
{
661
"name": str(var_name),
662
"file_path": file_path,
663
"line_start": 0,
664
"semantic_type": "ansible_variable",
665
},
666
)
667
store.create_edge(
668
NodeLabel.Module,
669
{"name": parent_name, "file_path": file_path},
670
EdgeType.CONTAINS,
671
NodeLabel.Variable,
672
{"name": str(var_name), "file_path": file_path},
673
)
674
stats["edges"] += 1
675

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button