master
py 647 lines 23 KB
Raw
1 #!/usr/bin/env python3
2
3 import sys
4 import tempfile
5 import unittest
6 import json
7 from pathlib import Path
8 from unittest.mock import patch
9
10 INTEGRATIONS_DIR = Path(__file__).resolve().parents[1]
11 sys.path.insert(0, str(INTEGRATIONS_DIR))
12
13 import check_collector_taxonomy
14 import gen_taxonomy
15
16
17 class TaxonomySchemaTest(unittest.TestCase):
18 def valid_taxonomy(self):
19 return {
20 'taxonomy_version': 1,
21 'plugin_name': 'go.d.plugin',
22 'module_name': 'apache',
23 'placements': [
24 {
25 'id': 'apache',
26 'section_id': 'applications.apache',
27 'title': 'Apache',
28 'items': ['apache.connections'],
29 },
30 ],
31 }
32
33 def test_valid_authoring_schema(self):
34 errors = list(gen_taxonomy.COLLECTOR_TAXONOMY_VALIDATOR.iter_errors(self.valid_taxonomy()))
35 self.assertEqual(errors, [])
36
37 def test_section_path_authoring_is_rejected(self):
38 data = self.valid_taxonomy()
39 data['placements'][0]['section_path'] = ['applications', 'apache']
40 errors = list(gen_taxonomy.COLLECTOR_TAXONOMY_VALIDATOR.iter_errors(data))
41 self.assertTrue(errors)
42
43 def test_old_contexts_authoring_is_rejected(self):
44 data = self.valid_taxonomy()
45 data['placements'][0]['contexts'] = data['placements'][0].pop('items')
46 errors = list(gen_taxonomy.COLLECTOR_TAXONOMY_VALIDATOR.iter_errors(data))
47 self.assertTrue(errors)
48
49 def test_grid_rejects_string_shorthand(self):
50 data = self.valid_taxonomy()
51 data['placements'][0]['items'] = [
52 {
53 'type': 'grid',
54 'id': 'apache-heads',
55 'items': ['apache.connections'],
56 },
57 ]
58 errors = list(gen_taxonomy.COLLECTOR_TAXONOMY_VALIDATOR.iter_errors(data))
59 self.assertTrue(errors)
60
61 def assert_schema_accepts_item(self, item):
62 data = self.valid_taxonomy()
63 data['placements'][0]['items'] = [item]
64 errors = list(gen_taxonomy.COLLECTOR_TAXONOMY_VALIDATOR.iter_errors(data))
65 self.assertEqual(errors, [])
66
67 def assert_schema_rejects_item(self, item):
68 data = self.valid_taxonomy()
69 data['placements'][0]['items'] = [item]
70 errors = list(gen_taxonomy.COLLECTOR_TAXONOMY_VALIDATOR.iter_errors(data))
71 self.assertTrue(errors)
72
73 def test_explicit_owned_context_is_accepted(self):
74 self.assert_schema_accepts_item({
75 'type': 'owned_context',
76 'context': 'apache.connections',
77 })
78
79 def test_group_is_accepted(self):
80 self.assert_schema_accepts_item({
81 'type': 'group',
82 'id': 'requests',
83 'title': 'Requests',
84 'items': ['apache.requests'],
85 })
86
87 def test_flatten_is_accepted(self):
88 self.assert_schema_accepts_item({
89 'type': 'flatten',
90 'id': 'apache-flat',
91 'title': 'Apache',
92 'items': ['apache.connections'],
93 })
94
95 def test_first_available_is_accepted(self):
96 self.assert_schema_accepts_item({
97 'type': 'first_available',
98 'items': [
99 {
100 'type': 'context',
101 'contexts': ['apache.requests'],
102 'chart_library': 'number',
103 },
104 ],
105 })
106
107 def test_view_switch_is_accepted(self):
108 self.assert_schema_accepts_item({
109 'type': 'view_switch',
110 'multi_node': {
111 'type': 'context',
112 'contexts': ['apache.requests'],
113 'chart_library': 'bars',
114 },
115 'single_node': {
116 'type': 'context',
117 'contexts': ['apache.requests'],
118 'chart_library': 'dygraph',
119 },
120 })
121
122 def test_grid_rejects_owned_context(self):
123 self.assert_schema_rejects_item({
124 'type': 'grid',
125 'id': 'apache-heads',
126 'items': [
127 {
128 'type': 'owned_context',
129 'context': 'apache.connections',
130 },
131 ],
132 })
133
134 def test_grid_rejects_selector(self):
135 self.assert_schema_rejects_item({
136 'type': 'grid',
137 'id': 'apache-heads',
138 'items': [
139 {
140 'type': 'selector',
141 'id': 'apache-prefix',
142 'title': 'Apache prefix',
143 'context_prefix': ['apache.'],
144 },
145 ],
146 })
147
148 def test_flatten_rejects_nested_flatten(self):
149 self.assert_schema_rejects_item({
150 'type': 'flatten',
151 'id': 'outer',
152 'title': 'Outer',
153 'items': [
154 {
155 'type': 'flatten',
156 'id': 'inner',
157 'title': 'Inner',
158 'items': ['apache.connections'],
159 },
160 ],
161 })
162
163 def test_first_available_rejects_string_shorthand(self):
164 self.assert_schema_rejects_item({
165 'type': 'first_available',
166 'items': ['apache.connections'],
167 })
168
169 def test_view_switch_rejects_string_branch(self):
170 self.assert_schema_rejects_item({
171 'type': 'view_switch',
172 'multi_node': 'apache.connections',
173 'single_node': {
174 'type': 'context',
175 'contexts': ['apache.connections'],
176 'chart_library': 'number',
177 },
178 })
179
180 def test_view_switch_rejects_flatten_branch(self):
181 self.assert_schema_rejects_item({
182 'type': 'view_switch',
183 'multi_node': {
184 'type': 'flatten',
185 'id': 'flat',
186 'title': 'Flat',
187 'items': ['apache.connections'],
188 },
189 'single_node': {
190 'type': 'context',
191 'contexts': ['apache.connections'],
192 'chart_library': 'number',
193 },
194 })
195
196 def test_view_switch_rejects_nested_view_switch(self):
197 self.assert_schema_rejects_item({
198 'type': 'view_switch',
199 'multi_node': {
200 'type': 'view_switch',
201 'multi_node': {
202 'type': 'context',
203 'contexts': ['apache.connections'],
204 'chart_library': 'number',
205 },
206 'single_node': {
207 'type': 'context',
208 'contexts': ['apache.connections'],
209 'chart_library': 'number',
210 },
211 },
212 'single_node': {
213 'type': 'context',
214 'contexts': ['apache.connections'],
215 'chart_library': 'number',
216 },
217 })
218
219 def test_renderer_allows_x_extension(self):
220 self.assert_schema_accepts_item({
221 'type': 'context',
222 'contexts': ['apache.connections'],
223 'chart_library': 'number',
224 'renderer': {
225 'x_future_renderer_option': True,
226 },
227 })
228
229 def test_renderer_rejects_unknown_non_extension_key(self):
230 self.assert_schema_rejects_item({
231 'type': 'context',
232 'contexts': ['apache.connections'],
233 'chart_library': 'number',
234 'renderer': {
235 'latetValue': 5,
236 },
237 })
238
239 def test_renderer_fields_are_rejected_as_item_body_siblings(self):
240 self.assert_schema_rejects_item({
241 'type': 'context',
242 'contexts': ['apache.connections'],
243 'chart_library': 'number',
244 'toolbox_elements': [],
245 })
246
247 def test_prescan_rejects_multi_node(self):
248 findings = []
249 gen_taxonomy.prescan_removed_shapes({'multi_node': {'title': 'Bad'}}, Path('taxonomy.yaml'), findings)
250 self.assertEqual([finding.code for finding in findings], ['TAX022'])
251
252 def test_optout_does_not_require_metadata(self):
253 text = """taxonomy_version: 1
254 plugin_name: statsd.plugin
255 module_name: statsd
256 taxonomy_optout:
257 reason: Operator-defined statsd synthetic charts have no static collector taxonomy.
258 """
259 with tempfile.TemporaryDirectory() as tmp:
260 path = Path(tmp) / 'taxonomy.yaml'
261 path.write_text(text)
262 findings = []
263 placements, optouts = gen_taxonomy.process_taxonomy_file(
264 path,
265 sections={},
266 icons=set(),
267 metadata_indexes={
268 'by_path_module': {},
269 'all_contexts': [],
270 'contexts_by_plugin': {},
271 },
272 ownership={},
273 findings=findings,
274 )
275 self.assertEqual(placements, [])
276 self.assertEqual(optouts[0]['collector_ids'], ['statsd.plugin-statsd'])
277 self.assertEqual([finding.code for finding in findings], [])
278
279
280 class TaxonomyResolverTest(unittest.TestCase):
281 def test_path_segment_uses_last_id_component(self):
282 self.assertEqual(gen_taxonomy.path_segment({'id': 'applications.postgres'}), 'postgres')
283
284 def test_resolve_prefix_uses_sorted_contexts(self):
285 contexts = ['apache.requests', 'snmp.ifaces.in', 'snmp.ifaces.out', 'zfs.pool']
286 self.assertEqual(
287 gen_taxonomy.resolve_prefix('snmp.', contexts),
288 ['snmp.ifaces.in', 'snmp.ifaces.out'],
289 )
290
291 def test_context_prefix_can_narrow_declared_dynamic_namespace(self):
292 findings = []
293 contexts = ['snmp.device_prof_ifTraffic', 'snmp.license.state']
294 resolved = gen_taxonomy.resolve_node_contexts(
295 {'context_prefix': ['snmp.device_prof_']},
296 known_contexts=set(),
297 allowed_prefixes={'snmp.'},
298 allowed_plugins=set(),
299 metadata_indexes={'all_contexts': contexts, 'contexts_by_plugin': {}},
300 path=Path('taxonomy.yaml'),
301 findings=findings,
302 )
303 self.assertEqual(resolved, ['snmp.device_prof_ifTraffic'])
304 self.assertEqual([finding.code for finding in findings], [])
305
306 def test_context_prefix_exclude_requires_prefix(self):
307 findings = []
308 gen_taxonomy.resolve_node_contexts(
309 {'context_prefix_exclude': ['snmp.license.']},
310 known_contexts=set(),
311 allowed_prefixes=set(),
312 allowed_plugins=set(),
313 metadata_indexes={'all_contexts': [], 'contexts_by_plugin': {}},
314 path=Path('taxonomy.yaml'),
315 findings=findings,
316 )
317 self.assertEqual([finding.code for finding in findings], ['TAX029'])
318
319 def test_metadata_loader_warnings_are_taxonomy_findings(self):
320 original_len = len(gen_taxonomy.WARNINGS)
321
322 def fake_load_collectors():
323 gen_taxonomy.WARNINGS.append(('metadata.yaml', 'invalid metadata'))
324 return []
325
326 try:
327 findings = []
328 with patch.object(gen_taxonomy, 'load_collectors', side_effect=fake_load_collectors):
329 indexes = gen_taxonomy.build_metadata_indexes(findings)
330 self.assertEqual(indexes['modules'], [])
331 self.assertEqual([finding.code for finding in findings], ['TAX001'])
332 self.assertEqual(findings[0].message, 'invalid metadata')
333 finally:
334 del gen_taxonomy.WARNINGS[original_len:]
335
336
337 class TaxonomyOwnershipTest(unittest.TestCase):
338 def metadata_indexes(self, tmp, context='apache.connections', contexts=None, dynamic_prefixes=None):
339 metadata_path = Path(tmp) / 'metadata.yaml'
340 if contexts is None:
341 contexts = [context]
342 metrics = {
343 'scopes': [
344 {
345 'metrics': [
346 {'name': name}
347 for name in contexts
348 ],
349 },
350 ],
351 }
352 if dynamic_prefixes:
353 metrics['dynamic_context_prefixes'] = [
354 {
355 'prefix': prefix,
356 'reason': 'test dynamic contexts',
357 }
358 for prefix in dynamic_prefixes
359 ]
360 module = {
361 '_src_path': str(metadata_path),
362 'meta': {
363 'plugin_name': 'go.d.plugin',
364 'module_name': 'apache',
365 },
366 'metrics': metrics,
367 }
368 return {
369 'by_path_module': {
370 (metadata_path, 'go.d.plugin', 'apache'): [module],
371 },
372 'all_contexts': sorted(contexts),
373 'contexts_by_plugin': {},
374 }
375
376 def write_taxonomy(self, tmp, item):
377 path = Path(tmp) / 'taxonomy.yaml'
378 path.write_text("""taxonomy_version: 1
379 plugin_name: go.d.plugin
380 module_name: apache
381 placements:
382 - id: apache
383 section_id: applications.apache
384 title: Apache
385 items:
386 """)
387 with path.open('a') as fp:
388 fp.write(item)
389 return path
390
391 def test_referenced_context_without_owner_is_fatal(self):
392 with tempfile.TemporaryDirectory() as tmp:
393 path = self.write_taxonomy(tmp, """ - type: context
394 contexts: [apache.connections]
395 chart_library: number
396 """)
397 ownership = {}
398 referenced_literals = []
399 findings = []
400 gen_taxonomy.process_taxonomy_file(
401 path,
402 sections={'applications.apache': ({'status': 'active', 'section_order': 1}, 'applications.apache')},
403 icons=set(),
404 metadata_indexes=self.metadata_indexes(tmp),
405 ownership=ownership,
406 findings=findings,
407 referenced_literals=referenced_literals,
408 )
409 gen_taxonomy.emit_referenced_only_findings(referenced_literals, ownership, findings)
410 self.assertEqual([finding.code for finding in findings], ['TAX037'])
411
412 def test_referenced_context_owned_elsewhere_is_allowed(self):
413 with tempfile.TemporaryDirectory() as tmp:
414 path = self.write_taxonomy(tmp, """ - apache.connections
415 - type: context
416 contexts: [apache.connections]
417 chart_library: number
418 """)
419 ownership = {}
420 referenced_literals = []
421 findings = []
422 gen_taxonomy.process_taxonomy_file(
423 path,
424 sections={'applications.apache': ({'status': 'active', 'section_order': 1}, 'applications.apache')},
425 icons=set(),
426 metadata_indexes=self.metadata_indexes(tmp),
427 ownership=ownership,
428 findings=findings,
429 referenced_literals=referenced_literals,
430 )
431 gen_taxonomy.emit_referenced_only_findings(referenced_literals, ownership, findings)
432 self.assertEqual([finding.code for finding in findings], [])
433
434 def test_unknown_literal_context_is_tax003(self):
435 with tempfile.TemporaryDirectory() as tmp:
436 path = self.write_taxonomy(tmp, """ - apache.unknown
437 """)
438 findings = []
439 gen_taxonomy.process_taxonomy_file(
440 path,
441 sections={'applications.apache': ({'status': 'active', 'section_order': 1}, 'applications.apache')},
442 icons=set(),
443 metadata_indexes=self.metadata_indexes(tmp),
444 ownership={},
445 findings=findings,
446 )
447 self.assertEqual([finding.code for finding in findings], ['TAX003'])
448
449 def test_selector_overlap_uses_tax036(self):
450 with tempfile.TemporaryDirectory() as tmp:
451 path = self.write_taxonomy(tmp, """ - apache.connections
452 - type: selector
453 id: apache-prefix
454 title: Apache prefix
455 context_prefix: [apache.]
456 """)
457 ownership = {}
458 ownership_conflicts = {}
459 referenced_literals = []
460 findings = []
461 gen_taxonomy.process_taxonomy_file(
462 path,
463 sections={'applications.apache': ({'status': 'active', 'section_order': 1}, 'applications.apache')},
464 icons=set(),
465 metadata_indexes=self.metadata_indexes(tmp, dynamic_prefixes=['apache.']),
466 ownership=ownership,
467 findings=findings,
468 referenced_literals=referenced_literals,
469 ownership_conflicts=ownership_conflicts,
470 )
471 gen_taxonomy.emit_ownership_conflicts(ownership_conflicts, findings)
472 self.assertEqual([finding.code for finding in findings], ['TAX036'])
473
474 def test_duplicate_literal_ownership_uses_tax033_once(self):
475 with tempfile.TemporaryDirectory() as tmp:
476 path = self.write_taxonomy(tmp, """ - apache.connections
477 - type: group
478 id: duplicate
479 title: Duplicate
480 items:
481 - apache.connections
482 """)
483 ownership = {}
484 ownership_conflicts = {}
485 findings = []
486 gen_taxonomy.process_taxonomy_file(
487 path,
488 sections={'applications.apache': ({'status': 'active', 'section_order': 1}, 'applications.apache')},
489 icons=set(),
490 metadata_indexes=self.metadata_indexes(tmp),
491 ownership=ownership,
492 findings=findings,
493 ownership_conflicts=ownership_conflicts,
494 )
495 gen_taxonomy.emit_ownership_conflicts(ownership_conflicts, findings)
496 self.assertEqual([finding.code for finding in findings], ['TAX033'])
497
498 def test_stale_unresolved_reference_warns(self):
499 with tempfile.TemporaryDirectory() as tmp:
500 path = self.write_taxonomy(tmp, """ - type: context
501 contexts:
502 - context: apache.connections
503 unresolved:
504 reason: staged rename
505 owner: cloud-frontend
506 expires: "2026-08-01"
507 chart_library: number
508 """)
509 ownership = {}
510 referenced_literals = []
511 findings = []
512 gen_taxonomy.process_taxonomy_file(
513 path,
514 sections={'applications.apache': ({'status': 'active', 'section_order': 1}, 'applications.apache')},
515 icons=set(),
516 metadata_indexes=self.metadata_indexes(tmp),
517 ownership=ownership,
518 findings=findings,
519 referenced_literals=referenced_literals,
520 )
521 gen_taxonomy.emit_referenced_only_findings(referenced_literals, ownership, findings)
522 self.assertEqual([finding.code for finding in findings], ['TAX038'])
523
524 def test_unresolved_reference_payload_is_emitted(self):
525 with tempfile.TemporaryDirectory() as tmp:
526 path = self.write_taxonomy(tmp, """ - type: context
527 contexts:
528 - context: apache.future
529 unresolved:
530 reason: staged rename
531 owner: cloud-frontend
532 expires: "2026-08-01"
533 chart_library: number
534 """)
535 findings = []
536 placements, _ = gen_taxonomy.process_taxonomy_file(
537 path,
538 sections={'applications.apache': ({'status': 'active', 'section_order': 1}, 'applications.apache')},
539 icons=set(),
540 metadata_indexes=self.metadata_indexes(tmp),
541 ownership={},
542 findings=findings,
543 )
544 self.assertEqual([finding.code for finding in findings], [])
545 unresolved = placements[0]['unresolved_references']
546 self.assertEqual(unresolved, [
547 {
548 'context': 'apache.future',
549 'reason': 'staged rename',
550 'owner': 'cloud-frontend',
551 'expires': '2026-08-01',
552 'item_path': 'apache.0',
553 },
554 ])
555 self.assertEqual(placements[0]['items'][0]['unresolved_references'], unresolved)
556
557
558 class TouchedCollectorGateTest(unittest.TestCase):
559 def test_metadata_metrics_spans_ignore_overview_blocks(self):
560 text = """plugin_name: go.d.plugin
561 modules:
562 - meta:
563 module_name: demo
564 overview:
565 data_collection:
566 metrics_description: demo
567 metrics:
568 folding:
569 title: Metrics
570 scopes: []
571 setup:
572 configuration: {}
573 """
574 with tempfile.TemporaryDirectory() as tmp:
575 path = Path(tmp) / 'metadata.yaml'
576 path.write_text(text)
577 self.assertEqual(check_collector_taxonomy.metadata_metrics_spans(path), [(8, 11)])
578
579 def test_metadata_metrics_spans_do_not_depend_on_four_space_indent(self):
580 text = """plugin_name: go.d.plugin
581 modules:
582 - meta:
583 module_name: demo
584 metrics:
585 folding:
586 title: Metrics
587 scopes: []
588 setup:
589 configuration: {}
590 """
591 with tempfile.TemporaryDirectory() as tmp:
592 path = Path(tmp) / 'metadata.yaml'
593 path.write_text(text)
594 self.assertEqual(check_collector_taxonomy.metadata_metrics_spans(path), [(5, 8)])
595
596 def test_range_intersection(self):
597 spans = [(7, 10)]
598 self.assertFalse(check_collector_taxonomy.range_intersects_spans(3, 1, spans))
599 self.assertTrue(check_collector_taxonomy.range_intersects_spans(8, 1, spans))
600
601 def test_missing_metrics_block_with_diff_is_touched(self):
602 text = """plugin_name: go.d.plugin
603 modules:
604 - meta:
605 module_name: demo
606 setup:
607 configuration: {}
608 """
609 with tempfile.TemporaryDirectory() as tmp:
610 path = Path(tmp) / 'metadata.yaml'
611 path.write_text(text)
612 with patch.object(check_collector_taxonomy, 'run_git', return_value='@@ -8,4 +0,0 @@\n- metrics:\n'):
613 self.assertTrue(check_collector_taxonomy.metadata_metrics_touched('base...head', path))
614
615 def test_missing_metrics_block_without_diff_is_not_touched(self):
616 text = """plugin_name: go.d.plugin
617 modules:
618 - meta:
619 module_name: demo
620 """
621 with tempfile.TemporaryDirectory() as tmp:
622 path = Path(tmp) / 'metadata.yaml'
623 path.write_text(text)
624 with patch.object(check_collector_taxonomy, 'run_git', return_value=''):
625 self.assertFalse(check_collector_taxonomy.metadata_metrics_touched('base...head', path))
626
627 def test_deleted_collector_does_not_require_taxonomy(self):
628 with tempfile.TemporaryDirectory() as tmp:
629 collector_dir = Path(tmp) / 'demo'
630 collector_dir.mkdir()
631 with patch.object(check_collector_taxonomy, 'touched_collectors', return_value=[collector_dir]):
632 findings = check_collector_taxonomy.check_touched_coverage('base...head')
633 self.assertEqual(findings, [])
634
635
636 class TaxonomyDeterminismTest(unittest.TestCase):
637 def test_build_taxonomy_is_byte_identical_for_ten_runs(self):
638 outputs = []
639 for _ in range(10):
640 taxonomy, findings = gen_taxonomy.build_taxonomy()
641 self.assertEqual([finding for finding in findings if finding.severity == gen_taxonomy.FATAL], [])
642 outputs.append(json.dumps(taxonomy, indent=2, sort_keys=True) + '\n')
643 self.assertEqual(len(set(outputs)), 1)
644
645
646 if __name__ == '__main__':
647 unittest.main()