master
py 929 lines 32.1 KB
Raw
1 #!/usr/bin/env python3
2
3 import argparse
4 import bisect
5 import json
6 import subprocess
7 import sys
8 import unicodedata
9 from dataclasses import dataclass
10 from pathlib import Path
11
12 from _common import (
13 COLLECTOR_SOURCES,
14 GITHUB_ACTIONS,
15 INTEGRATIONS_PATH,
16 REPO_PATH,
17 WARNINGS,
18 load_collectors,
19 load_yaml,
20 make_id,
21 make_validator,
22 )
23
24 TAXONOMY_PATH = INTEGRATIONS_PATH / 'taxonomy'
25 SECTIONS_PATH = TAXONOMY_PATH / 'sections.yaml'
26 ICONS_PATH = TAXONOMY_PATH / 'icons.yaml'
27 OUTPUT_PATH = INTEGRATIONS_PATH / 'taxonomy.json'
28
29 SECTIONS_VALIDATOR = make_validator('./taxonomy_sections.json#')
30 COLLECTOR_TAXONOMY_VALIDATOR = make_validator('./taxonomy_collector.json#')
31 OUTPUT_VALIDATOR = make_validator('./taxonomy_output.json#')
32
33 FATAL = 'fatal'
34 WARNING = 'warning'
35
36 DISPLAY_KEYS = (
37 'title',
38 'short_name',
39 'icon',
40 'priority',
41 'families',
42 'tooltip',
43 'menu_pattern',
44 'hide_sub_icon',
45 'force_visibility',
46 'fallback_icon',
47 'include_grand_parents',
48 'properties',
49 )
50
51 WIDGET_KEYS = (
52 'chart_library',
53 'group_by',
54 'group_by_label',
55 'aggregation_method',
56 'selected_dimensions',
57 'dimensions_sort',
58 'colors',
59 'layout',
60 'table_columns',
61 'table_sort_by',
62 'labels',
63 'value_range',
64 'eliminate_zero_dimensions',
65 'context_items',
66 'post_group_by',
67 'show_post_aggregations',
68 'grouping_method',
69 'sparkline',
70 'renderer',
71 )
72
73 SELECTOR_KEYS = ('context_prefix', 'context_prefix_exclude', 'collect_plugin')
74 SORTED_LIST_KEYS = set(SELECTOR_KEYS)
75
76 ITEM_COPY_KEYS = {
77 'owned_context': ('context', *DISPLAY_KEYS, 'single_node'),
78 'group': ('id', *DISPLAY_KEYS, 'section_filters', 'dyncfg', 'single_node'),
79 'flatten': ('id', *DISPLAY_KEYS, 'single_node'),
80 'selector': ('id', *DISPLAY_KEYS, *SELECTOR_KEYS, 'single_node'),
81 'context': ('id', *DISPLAY_KEYS, 'contexts', *WIDGET_KEYS, 'single_node'),
82 'grid': ('id', *DISPLAY_KEYS, 'renderer', 'single_node'),
83 'first_available': ('id', *DISPLAY_KEYS, 'single_node'),
84 'view_switch': ('id',),
85 }
86
87 PLACEMENT_COPY_KEYS = (
88 'short_name',
89 'icon',
90 'priority',
91 'families',
92 'tooltip',
93 'menu_pattern',
94 'hide_sub_icon',
95 'force_visibility',
96 'fallback_icon',
97 'include_grand_parents',
98 'properties',
99 'single_node',
100 )
101
102
103 @dataclass(frozen=True)
104 class Finding:
105 code: str
106 severity: str
107 path: Path
108 message: str
109 line: int | None = None
110
111 def render(self):
112 location = str(self.path)
113 if GITHUB_ACTIONS:
114 line = f',line={self.line}' if self.line else ''
115 level = 'error' if self.severity == FATAL else 'warning'
116 return f'::{level} file={location}{line},title={self.code}::{self.message}'
117
118 line = f':{self.line}' if self.line else ''
119 return f'{location}{line}: {self.severity.upper()} {self.code}: {self.message}'
120
121
122 def relpath(path):
123 try:
124 return path.relative_to(REPO_PATH).as_posix()
125 except ValueError:
126 return path.as_posix()
127
128
129 def normalize_title(value):
130 normalized = unicodedata.normalize('NFC', value or '')
131 return normalized.casefold()
132
133
134 def path_segment(section):
135 return section['id'].rsplit('.', 1)[-1]
136
137
138 def run_git(*args):
139 try:
140 return subprocess.check_output(
141 ['git', '-C', str(REPO_PATH), *args],
142 text=True,
143 stderr=subprocess.DEVNULL,
144 ).strip()
145 except (subprocess.CalledProcessError, FileNotFoundError):
146 return 'unknown'
147
148
149 def source_info():
150 return {
151 'netdata_commit': run_git('rev-parse', 'HEAD'),
152 'generated_at': run_git('log', '-1', '--format=%cI'),
153 }
154
155
156 def discover_taxonomy_files():
157 files = []
158 for _, root, recursive in COLLECTOR_SOURCES:
159 if root.exists() and root.is_dir() and recursive:
160 files.extend(root.glob('*/taxonomy.yaml'))
161 elif root.exists() and root.is_file() and root.name == 'taxonomy.yaml':
162 files.append(root)
163 return sorted(set(files), key=lambda p: relpath(p))
164
165
166 def validate_schema(validator, data, path, default_code, findings):
167 valid = True
168 for error in sorted(validator.iter_errors(data), key=lambda e: list(e.absolute_path)):
169 valid = False
170 code = default_code
171 absolute_path = [str(part) for part in error.absolute_path]
172 if 'single_node' in absolute_path:
173 code = 'TAX021'
174 elif error.validator == 'additionalProperties' and 'section_path' in error.message:
175 code = 'TAX028'
176 findings.append(Finding(
177 code=code,
178 severity=FATAL,
179 path=path,
180 message=f'{error.message} (schema path: {"/".join(str(p) for p in error.absolute_schema_path)})',
181 ))
182 return valid
183
184
185 def load_icons(findings):
186 data = load_yaml(ICONS_PATH)
187 if not data:
188 findings.append(Finding('TAX001', FATAL, ICONS_PATH, 'Unable to load taxonomy icon registry.'))
189 return set()
190 icons = data.get('icons', [])
191 seen = set()
192 for icon in icons:
193 if icon in seen:
194 findings.append(Finding('TAX028', FATAL, ICONS_PATH, f'Duplicate icon id: {icon}'))
195 seen.add(icon)
196 return seen
197
198
199 def load_sections(findings, icons):
200 data = load_yaml(SECTIONS_PATH)
201 if not data:
202 findings.append(Finding('TAX001', FATAL, SECTIONS_PATH, 'Unable to load taxonomy sections registry.'))
203 return [], {}
204
205 if not validate_schema(SECTIONS_VALIDATOR, data, SECTIONS_PATH, 'TAX001', findings):
206 return [], {}
207
208 sections = data['sections']
209 by_id = {}
210 for section in sections:
211 section_id = section['id']
212 if section_id in by_id:
213 findings.append(Finding('TAX006', FATAL, SECTIONS_PATH, f'Duplicate section id: {section_id}'))
214 by_id[section_id] = section
215
216 for section in sections:
217 icon = section.get('icon')
218 if icon and icon not in icons:
219 findings.append(Finding('TAX028', FATAL, SECTIONS_PATH, f'Section {section["id"]} references unknown icon: {icon}'))
220
221 parent_id = section.get('parent_id')
222 if parent_id and parent_id not in by_id:
223 findings.append(Finding('TAX028', FATAL, SECTIONS_PATH, f'Section {section["id"]} references unknown parent_id: {parent_id}'))
224
225 deprecation = section.get('deprecation', {})
226 replacement_id = deprecation.get('replacement_id')
227 if replacement_id and replacement_id not in by_id:
228 findings.append(Finding('TAX028', FATAL, SECTIONS_PATH, f'Section {section["id"]} references unknown replacement_id: {replacement_id}'))
229
230 paths = {}
231
232 def resolve_path(section_id, visiting):
233 if section_id in paths:
234 return paths[section_id]
235 if section_id in visiting:
236 findings.append(Finding('TAX028', FATAL, SECTIONS_PATH, f'Section parent cycle includes: {section_id}'))
237 return section_id
238 section = by_id[section_id]
239 parent_id = section.get('parent_id')
240 if not parent_id:
241 paths[section_id] = section_id
242 return section_id
243 paths[section_id] = f'{resolve_path(parent_id, visiting | {section_id})}.{path_segment(section)}'
244 return paths[section_id]
245
246 for section_id in by_id:
247 resolve_path(section_id, set())
248
249 emitted = []
250 for section in sorted(sections, key=lambda s: (s['section_order'], normalize_title(s['title']), s['id'])):
251 item = {
252 'id': section['id'],
253 'path': paths[section['id']],
254 'title': section['title'],
255 'section_order': section['section_order'],
256 'status': section['status'],
257 }
258 for key in ('parent_id', 'short_name', 'icon', 'deprecation'):
259 if key in section:
260 item[key] = section[key]
261 extras = {k: v for k, v in section.items() if k.startswith('x_')}
262 if extras:
263 item['_extra'] = extras
264 emitted.append(item)
265
266 return emitted, {section_id: (section, paths[section_id]) for section_id, section in by_id.items()}
267
268
269 def module_contexts(module):
270 contexts = []
271 for scope in module.get('metrics', {}).get('scopes', []):
272 for metric in scope.get('metrics', []):
273 name = metric.get('name')
274 if name and name not in contexts:
275 contexts.append(name)
276 return contexts
277
278
279 def dynamic_declarations(module):
280 metrics = module.get('metrics', {})
281 prefixes = {item['prefix'] for item in metrics.get('dynamic_context_prefixes', [])}
282 plugins = {item['plugin'] for item in metrics.get('dynamic_collect_plugins', [])}
283 return prefixes, plugins
284
285
286 def build_metadata_indexes(findings):
287 warning_start = len(WARNINGS)
288 modules = load_collectors()
289 for path, message in WARNINGS[warning_start:]:
290 findings.append(Finding('TAX001', FATAL, Path(path), message))
291
292 by_path_module = {}
293 context_to_modules = {}
294 contexts_by_plugin = {}
295 all_contexts = set()
296
297 for module in modules:
298 src_path = Path(module['_src_path'])
299 meta = module['meta']
300 key = (src_path, meta['plugin_name'], meta['module_name'])
301 by_path_module.setdefault(key, []).append(module)
302
303 contexts = module_contexts(module)
304 contexts_by_plugin.setdefault(meta['plugin_name'], set()).update(contexts)
305 for context in contexts:
306 all_contexts.add(context)
307 context_to_modules.setdefault(context, set()).add(key)
308
309 return {
310 'modules': modules,
311 'by_path_module': by_path_module,
312 'context_to_modules': context_to_modules,
313 'contexts_by_plugin': contexts_by_plugin,
314 'all_contexts': sorted(all_contexts),
315 }
316
317
318 def prescan_removed_shapes(data, path, findings):
319 def walk(node):
320 if isinstance(node, dict):
321 if 'multi_node' in node and node.get('type') != 'view_switch':
322 findings.append(Finding('TAX022', FATAL, path, '`multi_node:` is accepted only inside `type: view_switch`.'))
323 for key, value in node.items():
324 if key.endswith('_extend'):
325 findings.append(Finding('TAX023', FATAL, path, f'List-merge field `{key}:` is not accepted in taxonomy v1.'))
326 walk(value)
327 elif isinstance(node, list):
328 for item in node:
329 walk(item)
330
331 walk(data)
332
333
334 def collector_ids(modules):
335 ids = []
336 for module in modules:
337 ids.append(make_id(module['meta']))
338 return sorted(ids)
339
340
341 def merged_module_contexts(modules):
342 contexts = set()
343 for module in modules:
344 contexts.update(module_contexts(module))
345 return contexts
346
347
348 def merged_dynamic_declarations(modules, inline):
349 prefixes = set()
350 plugins = set()
351 for module in modules:
352 module_prefixes, module_plugins = dynamic_declarations(module)
353 prefixes.update(module_prefixes)
354 plugins.update(module_plugins)
355
356 if inline:
357 prefixes.update(item['prefix'] for item in inline.get('dynamic_context_prefixes', []))
358 plugins.update(item['plugin'] for item in inline.get('dynamic_collect_plugins', []))
359
360 return prefixes, plugins
361
362
363 def resolve_prefix(prefix, all_contexts):
364 start = bisect.bisect_left(all_contexts, prefix)
365 stop = bisect.bisect_left(all_contexts, prefix + chr(0x10ffff))
366 return all_contexts[start:stop]
367
368
369 def is_context_prefix_declared(prefix, allowed_prefixes):
370 return any(prefix.startswith(allowed) for allowed in allowed_prefixes)
371
372
373 def ordered_union(*sequences):
374 seen = set()
375 result = []
376 for sequence in sequences:
377 for item in sequence:
378 if item not in seen:
379 seen.add(item)
380 result.append(item)
381 return result
382
383
384 def ordered_dict_union(*sequences):
385 seen = set()
386 result = []
387 for sequence in sequences:
388 for item in sequence:
389 key = json.dumps(item, sort_keys=True)
390 if key not in seen:
391 seen.add(key)
392 result.append(item)
393 return result
394
395
396 def node_label(node, fallback):
397 if isinstance(node, str):
398 return node
399 return node.get('id') or node.get('context') or node.get('title') or fallback
400
401
402 def validate_icons(node, path, icons, findings, label):
403 for key in ('icon', 'fallback_icon'):
404 icon = node.get(key)
405 if icon and icon not in icons:
406 findings.append(Finding('TAX028', FATAL, path, f'Item `{label}` references unknown {key}: {icon}'))
407
408
409 def validate_override(parent, path, findings):
410 if parent.get('type') == 'view_switch':
411 return
412 single_node = parent.get('single_node')
413 if single_node is None:
414 return
415 if not single_node:
416 findings.append(Finding('TAX024', WARNING, path, 'Empty `single_node:` block is equivalent to omitting it.'))
417 return
418 for key, value in single_node.items():
419 if parent.get(key) == value:
420 findings.append(Finding('TAX025', WARNING, path, f'`single_node.{key}` is identical to the top-level value.'))
421
422
423 def copy_fields(node, output, keys):
424 for key in keys:
425 if key in node:
426 value = node[key]
427 if key in SORTED_LIST_KEYS:
428 value = sorted(value)
429 output[key] = value
430
431
432 def emit_extra(node, output):
433 extras = {k: v for k, v in node.items() if k.startswith('x_')}
434 if extras:
435 output['_extra'] = extras
436
437
438 def resolve_selectors(node, known_contexts, allowed_prefixes, allowed_plugins, metadata_indexes, path, findings):
439 resolved = set()
440 explicit = node.get('contexts', [])
441 prefixes = node.get('context_prefix', [])
442 excludes = node.get('context_prefix_exclude', [])
443 collect_plugins = node.get('collect_plugin', [])
444
445 for context in explicit:
446 if context not in known_contexts:
447 findings.append(Finding('TAX003', FATAL, path, f'Unknown context for this collector: {context}'))
448 resolved.add(context)
449
450 if excludes and not prefixes:
451 findings.append(Finding('TAX029', FATAL, path, '`context_prefix_exclude:` requires `context_prefix:` on the same node.'))
452
453 for prefix in prefixes:
454 if not is_context_prefix_declared(prefix, allowed_prefixes):
455 findings.append(Finding('TAX031', FATAL, path, f'context_prefix `{prefix}` is not declared in metadata.yaml metrics.dynamic_context_prefixes.'))
456 for context in resolve_prefix(prefix, metadata_indexes['all_contexts']):
457 resolved.add(context)
458
459 for exclude in excludes if prefixes else []:
460 if not any(exclude.startswith(prefix) for prefix in prefixes):
461 findings.append(Finding('TAX029', FATAL, path, f'context_prefix_exclude `{exclude}` is not covered by context_prefix.'))
462 for context in list(resolved):
463 if context.startswith(exclude):
464 resolved.remove(context)
465
466 for plugin in collect_plugins:
467 if plugin not in allowed_plugins:
468 findings.append(Finding('TAX035', FATAL, path, f'collect_plugin `{plugin}` is not declared in metadata.yaml metrics.dynamic_collect_plugins.'))
469 resolved.update(metadata_indexes['contexts_by_plugin'].get(plugin, set()))
470
471 for context in explicit:
472 if any(context.startswith(prefix) for prefix in prefixes):
473 findings.append(Finding('TAX034', WARNING, path, f'Context `{context}` is redundant because it is covered by context_prefix.'))
474
475 return sorted(resolved)
476
477
478 def resolve_node_contexts(node, known_contexts, allowed_prefixes, allowed_plugins, metadata_indexes, path, findings):
479 return resolve_selectors(node, known_contexts, allowed_prefixes, allowed_plugins, metadata_indexes, path, findings)
480
481
482 def validate_literal_context(context, known_contexts, unresolved, path, findings):
483 known = context in known_contexts
484 if unresolved:
485 if known:
486 findings.append(Finding('TAX038', WARNING, path, f'Unresolved escape hatch is stale because context now exists: {context}'))
487 elif not known:
488 findings.append(Finding('TAX003', FATAL, path, f'Unknown context for this collector: {context}'))
489 return known
490
491
492 def resolve_context_references(
493 refs,
494 known_contexts,
495 allowed_prefixes,
496 allowed_plugins,
497 metadata_indexes,
498 path,
499 findings,
500 referenced_literals,
501 item_path):
502 referenced = []
503 unresolved_references = []
504 for ref in refs:
505 if isinstance(ref, str):
506 known = validate_literal_context(ref, known_contexts, unresolved=False, path=path, findings=findings)
507 referenced = ordered_union(referenced, [ref])
508 referenced_literals.append((ref, item_path, path, False, known))
509 continue
510
511 if 'context' in ref:
512 context = ref['context']
513 known = validate_literal_context(context, known_contexts, unresolved=True, path=path, findings=findings)
514 referenced = ordered_union(referenced, [context])
515 referenced_literals.append((context, item_path, path, True, known))
516 unresolved_references.append({
517 'context': context,
518 'reason': ref['unresolved']['reason'],
519 'owner': ref['unresolved']['owner'],
520 'expires': ref['unresolved']['expires'],
521 'item_path': item_path,
522 })
523 continue
524
525 resolved = resolve_selectors(ref, known_contexts, allowed_prefixes, allowed_plugins, metadata_indexes, path, findings)
526 referenced = ordered_union(referenced, resolved)
527
528 return referenced, unresolved_references
529
530
531 def register_ownership(contexts, ownership, current, owner_kind, path, ownership_conflicts):
532 for context in contexts:
533 previous = ownership.get(context)
534 if previous and previous['owner'] != current:
535 code = 'TAX036' if 'selector' in {previous['kind'], owner_kind} else 'TAX033'
536 owners = tuple(sorted([previous['owner'], current]))
537 ownership_conflicts[(code, context, owners)] = {
538 'path': path,
539 }
540 ownership[context] = {
541 'owner': current,
542 'kind': owner_kind,
543 }
544
545
546 def emit_ownership_conflicts(ownership_conflicts, findings):
547 for code, context, owners in sorted(ownership_conflicts):
548 findings.append(Finding(
549 code,
550 FATAL,
551 ownership_conflicts[(code, context, owners)]['path'],
552 f'Context `{context}` is owned by both {owners[0]} and {owners[1]}.',
553 ))
554
555
556 def emit_item(
557 node,
558 position,
559 known_contexts,
560 allowed_prefixes,
561 allowed_plugins,
562 metadata_indexes,
563 icons,
564 ownership,
565 ownership_conflicts,
566 referenced_literals,
567 owner_label,
568 path,
569 findings,
570 index):
571 if isinstance(node, str):
572 label = f'{owner_label}.{node}'
573 validate_literal_context(node, known_contexts, unresolved=False, path=path, findings=findings)
574 register_ownership([node], ownership, f'{relpath(path)}:{label}', 'literal', path, ownership_conflicts)
575 return {
576 'type': 'owned_context',
577 'context': node,
578 'resolved_contexts': [node],
579 'referenced_contexts': [],
580 'unresolved_references': [],
581 }
582
583 kind = node['type']
584 label = f'{owner_label}.{node_label(node, str(index))}'
585 validate_icons(node, path, icons, findings, label)
586 validate_override(node, path, findings)
587
588 output = {'type': kind}
589 copy_fields(node, output, ITEM_COPY_KEYS[kind])
590 emit_extra(node, output)
591
592 resolved_contexts = []
593 referenced_contexts = []
594 unresolved_references = []
595
596 if kind == 'owned_context':
597 context = node['context']
598 validate_literal_context(context, known_contexts, unresolved=False, path=path, findings=findings)
599 resolved_contexts = [context]
600 register_ownership(resolved_contexts, ownership, f'{relpath(path)}:{label}', 'literal', path, ownership_conflicts)
601
602 elif kind == 'selector':
603 resolved_contexts = resolve_selectors(node, known_contexts, allowed_prefixes, allowed_plugins, metadata_indexes, path, findings)
604 register_ownership(resolved_contexts, ownership, f'{relpath(path)}:{label}', 'selector', path, ownership_conflicts)
605
606 elif kind in ('group', 'flatten'):
607 children = []
608 for child_index, child in enumerate(node.get('items', [])):
609 emitted = emit_item(
610 child,
611 'structural',
612 known_contexts,
613 allowed_prefixes,
614 allowed_plugins,
615 metadata_indexes,
616 icons,
617 ownership,
618 ownership_conflicts,
619 referenced_literals,
620 label,
621 path,
622 findings,
623 child_index,
624 )
625 children.append(emitted)
626 resolved_contexts = ordered_union(resolved_contexts, emitted['resolved_contexts'])
627 referenced_contexts = ordered_union(referenced_contexts, emitted['referenced_contexts'])
628 unresolved_references = ordered_dict_union(unresolved_references, emitted['unresolved_references'])
629 output['items'] = children
630
631 elif kind == 'context':
632 referenced_contexts, unresolved_references = resolve_context_references(
633 node['contexts'],
634 known_contexts,
635 allowed_prefixes,
636 allowed_plugins,
637 metadata_indexes,
638 path,
639 findings,
640 referenced_literals,
641 label,
642 )
643
644 elif kind == 'grid':
645 children = []
646 for child_index, child in enumerate(node.get('items', [])):
647 emitted = emit_item(
648 child,
649 'display',
650 known_contexts,
651 allowed_prefixes,
652 allowed_plugins,
653 metadata_indexes,
654 icons,
655 ownership,
656 ownership_conflicts,
657 referenced_literals,
658 label,
659 path,
660 findings,
661 child_index,
662 )
663 children.append(emitted)
664 resolved_contexts = ordered_union(resolved_contexts, emitted['resolved_contexts'])
665 referenced_contexts = ordered_union(referenced_contexts, emitted['referenced_contexts'])
666 unresolved_references = ordered_dict_union(unresolved_references, emitted['unresolved_references'])
667 output['items'] = children
668
669 elif kind == 'first_available':
670 children = []
671 for child_index, child in enumerate(node.get('items', [])):
672 emitted = emit_item(
673 child,
674 'display',
675 known_contexts,
676 allowed_prefixes,
677 allowed_plugins,
678 metadata_indexes,
679 icons,
680 ownership,
681 ownership_conflicts,
682 referenced_literals,
683 label,
684 path,
685 findings,
686 child_index,
687 )
688 children.append(emitted)
689 resolved_contexts = ordered_union(resolved_contexts, emitted['resolved_contexts'])
690 referenced_contexts = ordered_union(referenced_contexts, emitted['referenced_contexts'])
691 unresolved_references = ordered_dict_union(unresolved_references, emitted['unresolved_references'])
692 output['items'] = children
693
694 elif kind == 'view_switch':
695 branch_position = 'structural' if position == 'structural' else 'display'
696 for branch in ('multi_node', 'single_node'):
697 emitted = emit_item(
698 node[branch],
699 branch_position,
700 known_contexts,
701 allowed_prefixes,
702 allowed_plugins,
703 metadata_indexes,
704 icons,
705 ownership,
706 ownership_conflicts,
707 referenced_literals,
708 f'{label}.{branch}',
709 path,
710 findings,
711 0,
712 )
713 output[branch] = emitted
714 resolved_contexts = ordered_union(resolved_contexts, emitted['resolved_contexts'])
715 referenced_contexts = ordered_union(referenced_contexts, emitted['referenced_contexts'])
716 unresolved_references = ordered_dict_union(unresolved_references, emitted['unresolved_references'])
717
718 if position != 'structural' and resolved_contexts:
719 findings.append(Finding('TAX001', FATAL, path, f'Item `{label}` owns contexts from a display-only position.'))
720
721 output['resolved_contexts'] = resolved_contexts
722 output['referenced_contexts'] = referenced_contexts
723 output['unresolved_references'] = unresolved_references
724 return output
725
726
727 def emit_referenced_only_findings(referenced_literals, ownership, findings):
728 emitted = set()
729 rows = sorted(referenced_literals, key=lambda row: (row[0], row[1], relpath(row[2])))
730 for context, item_path, path, unresolved, known in rows:
731 if unresolved or not known or context in ownership:
732 continue
733 key = (context, item_path, path)
734 if key in emitted:
735 continue
736 emitted.add(key)
737 findings.append(Finding(
738 'TAX037',
739 FATAL,
740 path,
741 f'Context `{context}` is referenced by widget `{item_path}` but is not owned by any taxonomy item.',
742 ))
743
744
745 def process_taxonomy_file(path, sections, icons, metadata_indexes, ownership, findings, referenced_literals=None, ownership_conflicts=None):
746 if referenced_literals is None:
747 referenced_literals = []
748 if ownership_conflicts is None:
749 ownership_conflicts = {}
750
751 data = load_yaml(path)
752 if not data:
753 findings.append(Finding('TAX001', FATAL, path, 'Unable to load taxonomy file.'))
754 return [], []
755
756 prescan_removed_shapes(data, path, findings)
757 if not validate_schema(COLLECTOR_TAXONOMY_VALIDATOR, data, path, 'TAX001', findings):
758 return [], []
759
760 metadata_path = path.with_name('metadata.yaml')
761 identity = (metadata_path, data['plugin_name'], data['module_name'])
762 modules = metadata_indexes['by_path_module'].get(identity, [])
763 inline = data.get('inline_dynamic_declarations')
764
765 if modules and inline:
766 findings.append(Finding('TAX029', FATAL, path, '`inline_dynamic_declarations:` is allowed only for collectors without metadata.yaml.'))
767
768 known_contexts = merged_module_contexts(modules)
769 allowed_prefixes, allowed_plugins = merged_dynamic_declarations(modules, inline)
770 ids = collector_ids(modules) if modules else [f'{data["plugin_name"]}-{data["module_name"]}']
771
772 if 'taxonomy_optout' in data:
773 return [], [{
774 'collector_ids': ids,
775 'plugin_name': data['plugin_name'],
776 'module_name': data['module_name'],
777 'source_path': relpath(path),
778 'reason': data['taxonomy_optout']['reason'],
779 }]
780
781 if not modules and not inline:
782 findings.append(Finding('TAX001', FATAL, path, 'No matching metadata.yaml module found and no inline_dynamic_declarations provided.'))
783
784 emitted = []
785 placement_keys = set()
786
787 for placement in data['placements']:
788 placement_key = (placement['section_id'], placement['id'])
789 if placement_key in placement_keys:
790 findings.append(Finding('TAX006', FATAL, path, f'Duplicate placement in collector taxonomy: {placement["section_id"]}.{placement["id"]}'))
791 placement_keys.add(placement_key)
792
793 section = sections.get(placement['section_id'])
794 if not section:
795 findings.append(Finding('TAX028', FATAL, path, f'Unknown section_id: {placement["section_id"]}'))
796 section_path = placement['section_id']
797 else:
798 section_entry, section_path = section
799 if section_entry['status'] == 'deprecated':
800 findings.append(Finding('TAX028', FATAL, path, f'New placements cannot target deprecated section_id: {placement["section_id"]}'))
801
802 validate_icons(placement, path, icons, findings, placement['id'])
803 validate_override(placement, path, findings)
804
805 items = []
806 resolved_contexts = []
807 referenced_contexts = []
808 unresolved_references = []
809 for index, child in enumerate(placement['items']):
810 emitted_child = emit_item(
811 child,
812 'structural',
813 known_contexts,
814 allowed_prefixes,
815 allowed_plugins,
816 metadata_indexes,
817 icons,
818 ownership,
819 ownership_conflicts,
820 referenced_literals,
821 placement['id'],
822 path,
823 findings,
824 index,
825 )
826 items.append(emitted_child)
827 resolved_contexts = ordered_union(resolved_contexts, emitted_child['resolved_contexts'])
828 referenced_contexts = ordered_union(referenced_contexts, emitted_child['referenced_contexts'])
829 unresolved_references = ordered_dict_union(unresolved_references, emitted_child['unresolved_references'])
830
831 item = {
832 'collector_ids': ids,
833 'plugin_name': data['plugin_name'],
834 'module_name': data['module_name'],
835 'source_path': relpath(path),
836 'id': placement['id'],
837 'section_id': placement['section_id'],
838 'section_path': section_path,
839 'title': placement['title'],
840 'items': items,
841 'resolved_contexts': resolved_contexts,
842 'referenced_contexts': referenced_contexts,
843 'unresolved_references': unresolved_references,
844 }
845 copy_fields(placement, item, PLACEMENT_COPY_KEYS)
846 emit_extra(placement, item)
847 emitted.append(item)
848
849 return emitted, []
850
851
852 def build_taxonomy():
853 findings = []
854 icons = load_icons(findings)
855 section_entries, sections = load_sections(findings, icons)
856 metadata_indexes = build_metadata_indexes(findings)
857 ownership = {}
858 ownership_conflicts = {}
859 referenced_literals = []
860 placements = []
861 opted_out_collectors = []
862
863 for path in discover_taxonomy_files():
864 new_placements, new_optouts = process_taxonomy_file(path, sections, icons, metadata_indexes, ownership, findings, referenced_literals, ownership_conflicts)
865 placements.extend(new_placements)
866 opted_out_collectors.extend(new_optouts)
867
868 emit_ownership_conflicts(ownership_conflicts, findings)
869 emit_referenced_only_findings(referenced_literals, ownership, findings)
870
871 placements.sort(key=lambda item: (
872 sections.get(item['section_id'], ({'section_order': 100000}, item['section_path']))[0]['section_order'],
873 item.get('priority', 1000),
874 normalize_title(item['title']),
875 item['id'],
876 item['source_path'],
877 ))
878
879 taxonomy = {
880 'taxonomy_schema_version': 1,
881 'source': source_info(),
882 'sections': section_entries,
883 'placements': placements,
884 'opted_out_collectors': sorted(opted_out_collectors, key=lambda item: (item['plugin_name'], item['module_name'], item['source_path'])),
885 }
886
887 validate_schema(OUTPUT_VALIDATOR, taxonomy, OUTPUT_PATH, 'TAX001', findings)
888 return taxonomy, findings
889
890
891 def write_json(path, data):
892 path.write_text(json.dumps(data, indent=2, sort_keys=True) + '\n')
893
894
895 def main():
896 parser = argparse.ArgumentParser(description='Generate Netdata collector taxonomy artifact.')
897 parser.add_argument('--check-only', action='store_true', help='Validate taxonomy sources without writing taxonomy.json.')
898 parser.add_argument('--output', type=Path, default=OUTPUT_PATH, help='Output JSON path.')
899 parser.add_argument('--findings-json', type=Path, help='Optional path for machine-readable findings.')
900 args = parser.parse_args()
901
902 taxonomy, findings = build_taxonomy()
903
904 for finding in findings:
905 print(finding.render(), file=sys.stderr)
906
907 if args.findings_json:
908 write_json(args.findings_json, [
909 {
910 'code': finding.code,
911 'severity': finding.severity,
912 'path': relpath(finding.path),
913 'line': finding.line,
914 'message': finding.message,
915 }
916 for finding in findings
917 ])
918
919 if any(finding.severity == FATAL for finding in findings):
920 return 1
921
922 if not args.check_only:
923 write_json(args.output, taxonomy)
924
925 return 0
926
927
928 if __name__ == '__main__':
929 sys.exit(main())