kubectl-mcp-server 1.15.0__py3-none-any.whl → 1.17.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/METADATA +34 -13
  2. kubectl_mcp_server-1.17.0.dist-info/RECORD +75 -0
  3. kubectl_mcp_tool/__init__.py +1 -1
  4. kubectl_mcp_tool/cli/cli.py +83 -9
  5. kubectl_mcp_tool/cli/output.py +14 -0
  6. kubectl_mcp_tool/config/__init__.py +46 -0
  7. kubectl_mcp_tool/config/loader.py +386 -0
  8. kubectl_mcp_tool/config/schema.py +184 -0
  9. kubectl_mcp_tool/crd_detector.py +247 -0
  10. kubectl_mcp_tool/k8s_config.py +19 -0
  11. kubectl_mcp_tool/mcp_server.py +246 -8
  12. kubectl_mcp_tool/observability/__init__.py +59 -0
  13. kubectl_mcp_tool/observability/metrics.py +223 -0
  14. kubectl_mcp_tool/observability/stats.py +255 -0
  15. kubectl_mcp_tool/observability/tracing.py +335 -0
  16. kubectl_mcp_tool/prompts/__init__.py +43 -0
  17. kubectl_mcp_tool/prompts/builtin.py +695 -0
  18. kubectl_mcp_tool/prompts/custom.py +298 -0
  19. kubectl_mcp_tool/prompts/prompts.py +180 -4
  20. kubectl_mcp_tool/safety.py +155 -0
  21. kubectl_mcp_tool/tools/__init__.py +20 -0
  22. kubectl_mcp_tool/tools/backup.py +881 -0
  23. kubectl_mcp_tool/tools/capi.py +727 -0
  24. kubectl_mcp_tool/tools/certs.py +709 -0
  25. kubectl_mcp_tool/tools/cilium.py +582 -0
  26. kubectl_mcp_tool/tools/cluster.py +384 -0
  27. kubectl_mcp_tool/tools/gitops.py +552 -0
  28. kubectl_mcp_tool/tools/keda.py +464 -0
  29. kubectl_mcp_tool/tools/kiali.py +652 -0
  30. kubectl_mcp_tool/tools/kubevirt.py +803 -0
  31. kubectl_mcp_tool/tools/policy.py +554 -0
  32. kubectl_mcp_tool/tools/rollouts.py +790 -0
  33. tests/test_browser.py +2 -2
  34. tests/test_config.py +386 -0
  35. tests/test_ecosystem.py +331 -0
  36. tests/test_mcp_integration.py +251 -0
  37. tests/test_observability.py +521 -0
  38. tests/test_prompts.py +716 -0
  39. tests/test_safety.py +218 -0
  40. tests/test_tools.py +70 -8
  41. kubectl_mcp_server-1.15.0.dist-info/RECORD +0 -49
  42. {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/WHEEL +0 -0
  43. {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/entry_points.txt +0 -0
  44. {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/licenses/LICENSE +0 -0
  45. {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,521 @@
1
+ """Unit tests for the observability module."""
2
+
3
+ import pytest
4
+ import time
5
+ import threading
6
+ from unittest.mock import patch, MagicMock
7
+
8
+
9
+ class TestStatsCollector:
10
+ """Tests for StatsCollector class."""
11
+
12
+ @pytest.mark.unit
13
+ def test_singleton_pattern(self):
14
+ """Test that StatsCollector is a singleton."""
15
+ from kubectl_mcp_tool.observability.stats import StatsCollector
16
+
17
+ instance1 = StatsCollector()
18
+ instance2 = StatsCollector()
19
+
20
+ assert instance1 is instance2
21
+
22
+ @pytest.mark.unit
23
+ def test_get_stats_collector(self):
24
+ """Test get_stats_collector function."""
25
+ from kubectl_mcp_tool.observability.stats import get_stats_collector
26
+
27
+ collector = get_stats_collector()
28
+ assert collector is not None
29
+
30
+ # Should return same instance
31
+ collector2 = get_stats_collector()
32
+ assert collector is collector2
33
+
34
+ @pytest.mark.unit
35
+ def test_record_tool_call_success(self):
36
+ """Test recording a successful tool call."""
37
+ from kubectl_mcp_tool.observability.stats import get_stats_collector
38
+
39
+ collector = get_stats_collector()
40
+ collector.reset()
41
+
42
+ collector.record_tool_call("test_tool", success=True, duration=0.5)
43
+
44
+ assert collector.tool_calls_total == 1
45
+ assert collector.tool_errors_total == 0
46
+
47
+ stats = collector.get_tool_stats("test_tool")
48
+ assert stats["calls"] == 1
49
+ assert stats["errors"] == 0
50
+ assert stats["total_duration_seconds"] == 0.5
51
+
52
+ @pytest.mark.unit
53
+ def test_record_tool_call_error(self):
54
+ """Test recording a failed tool call."""
55
+ from kubectl_mcp_tool.observability.stats import get_stats_collector
56
+
57
+ collector = get_stats_collector()
58
+ collector.reset()
59
+
60
+ collector.record_tool_call("test_tool", success=False, duration=0.1)
61
+
62
+ assert collector.tool_calls_total == 1
63
+ assert collector.tool_errors_total == 1
64
+
65
+ stats = collector.get_tool_stats("test_tool")
66
+ assert stats["calls"] == 1
67
+ assert stats["errors"] == 1
68
+ assert stats["error_rate"] == 1.0
69
+
70
+ @pytest.mark.unit
71
+ def test_record_tool_error(self):
72
+ """Test record_tool_error shorthand."""
73
+ from kubectl_mcp_tool.observability.stats import get_stats_collector
74
+
75
+ collector = get_stats_collector()
76
+ collector.reset()
77
+
78
+ collector.record_tool_error("error_tool")
79
+
80
+ assert collector.tool_calls_total == 1
81
+ assert collector.tool_errors_total == 1
82
+
83
+ @pytest.mark.unit
84
+ def test_record_http_request(self):
85
+ """Test recording HTTP requests."""
86
+ from kubectl_mcp_tool.observability.stats import get_stats_collector
87
+
88
+ collector = get_stats_collector()
89
+ collector.reset()
90
+
91
+ collector.record_http_request("/stats", "GET")
92
+ collector.record_http_request("/metrics", "GET")
93
+ collector.record_http_request("/mcp", "POST")
94
+
95
+ assert collector.http_requests_total == 3
96
+
97
+ stats = collector.get_stats()
98
+ assert stats["http_requests_by_endpoint"]["/stats"] == 1
99
+ assert stats["http_requests_by_endpoint"]["/metrics"] == 1
100
+ assert stats["http_requests_by_endpoint"]["/mcp"] == 1
101
+ assert stats["http_requests_by_method"]["GET"] == 2
102
+ assert stats["http_requests_by_method"]["POST"] == 1
103
+
104
+ @pytest.mark.unit
105
+ def test_uptime(self):
106
+ """Test uptime property."""
107
+ from kubectl_mcp_tool.observability.stats import get_stats_collector
108
+
109
+ collector = get_stats_collector()
110
+ collector.reset()
111
+
112
+ time.sleep(0.1)
113
+ uptime = collector.uptime
114
+
115
+ assert uptime >= 0.1
116
+ assert uptime < 1.0
117
+
118
+ @pytest.mark.unit
119
+ def test_get_stats(self):
120
+ """Test get_stats returns complete statistics."""
121
+ from kubectl_mcp_tool.observability.stats import get_stats_collector
122
+
123
+ collector = get_stats_collector()
124
+ collector.reset()
125
+
126
+ collector.record_tool_call("tool_a", success=True, duration=0.1)
127
+ collector.record_tool_call("tool_a", success=True, duration=0.2)
128
+ collector.record_tool_call("tool_b", success=False, duration=0.3)
129
+
130
+ stats = collector.get_stats()
131
+
132
+ assert "uptime_seconds" in stats
133
+ assert stats["tool_calls_total"] == 3
134
+ assert stats["tool_errors_total"] == 1
135
+ assert stats["unique_tools_called"] == 2
136
+ assert "tool_calls_by_name" in stats
137
+ assert "tool_a" in stats["tool_calls_by_name"]
138
+ assert "tool_b" in stats["tool_calls_by_name"]
139
+
140
+ @pytest.mark.unit
141
+ def test_get_tool_stats_nonexistent(self):
142
+ """Test get_tool_stats returns None for nonexistent tool."""
143
+ from kubectl_mcp_tool.observability.stats import get_stats_collector
144
+
145
+ collector = get_stats_collector()
146
+ collector.reset()
147
+
148
+ stats = collector.get_tool_stats("nonexistent_tool")
149
+ assert stats is None
150
+
151
+ @pytest.mark.unit
152
+ def test_reset(self):
153
+ """Test reset clears all statistics."""
154
+ from kubectl_mcp_tool.observability.stats import get_stats_collector
155
+
156
+ collector = get_stats_collector()
157
+
158
+ collector.record_tool_call("test_tool", success=True)
159
+ collector.record_http_request("/test", "GET")
160
+
161
+ collector.reset()
162
+
163
+ assert collector.tool_calls_total == 0
164
+ assert collector.tool_errors_total == 0
165
+ assert collector.http_requests_total == 0
166
+
167
+ @pytest.mark.unit
168
+ def test_thread_safety(self):
169
+ """Test that StatsCollector is thread-safe."""
170
+ from kubectl_mcp_tool.observability.stats import get_stats_collector
171
+
172
+ collector = get_stats_collector()
173
+ collector.reset()
174
+
175
+ def record_calls():
176
+ for i in range(100):
177
+ collector.record_tool_call(f"tool_{i % 10}", success=True)
178
+
179
+ threads = [threading.Thread(target=record_calls) for _ in range(10)]
180
+
181
+ for t in threads:
182
+ t.start()
183
+ for t in threads:
184
+ t.join()
185
+
186
+ assert collector.tool_calls_total == 1000
187
+
188
+
189
+ class TestPrometheusMetrics:
190
+ """Tests for Prometheus metrics module."""
191
+
192
+ @pytest.mark.unit
193
+ def test_is_prometheus_available(self):
194
+ """Test is_prometheus_available function."""
195
+ from kubectl_mcp_tool.observability.metrics import is_prometheus_available
196
+
197
+ # Should return bool regardless of prometheus_client installation
198
+ result = is_prometheus_available()
199
+ assert isinstance(result, bool)
200
+
201
+ @pytest.mark.unit
202
+ def test_get_metrics(self):
203
+ """Test get_metrics returns Prometheus format."""
204
+ from kubectl_mcp_tool.observability.metrics import get_metrics, is_prometheus_available
205
+
206
+ metrics = get_metrics()
207
+
208
+ assert isinstance(metrics, str)
209
+
210
+ if is_prometheus_available():
211
+ # Should have some metric content
212
+ assert len(metrics) > 0
213
+ else:
214
+ # Should return informative message
215
+ assert "not available" in metrics
216
+
217
+ @pytest.mark.unit
218
+ def test_record_tool_call_metric(self):
219
+ """Test record_tool_call_metric function."""
220
+ from kubectl_mcp_tool.observability.metrics import (
221
+ record_tool_call_metric,
222
+ is_prometheus_available,
223
+ )
224
+
225
+ # Should not raise even if prometheus_client is not installed
226
+ record_tool_call_metric("test_tool", success=True, duration=0.5)
227
+ record_tool_call_metric("test_tool", success=False, duration=0.1)
228
+
229
+ @pytest.mark.unit
230
+ def test_record_tool_error_metric(self):
231
+ """Test record_tool_error_metric function."""
232
+ from kubectl_mcp_tool.observability.metrics import record_tool_error_metric
233
+
234
+ # Should not raise even if prometheus_client is not installed
235
+ record_tool_error_metric("test_tool", error_type="validation")
236
+ record_tool_error_metric("test_tool", error_type="timeout")
237
+
238
+ @pytest.mark.unit
239
+ def test_record_tool_duration_metric(self):
240
+ """Test record_tool_duration_metric function."""
241
+ from kubectl_mcp_tool.observability.metrics import record_tool_duration_metric
242
+
243
+ # Should not raise even if prometheus_client is not installed
244
+ record_tool_duration_metric("test_tool", 0.5)
245
+ record_tool_duration_metric("test_tool", 1.5)
246
+
247
+ @pytest.mark.unit
248
+ def test_record_http_request_metric(self):
249
+ """Test record_http_request_metric function."""
250
+ from kubectl_mcp_tool.observability.metrics import record_http_request_metric
251
+
252
+ # Should not raise even if prometheus_client is not installed
253
+ record_http_request_metric("/stats", "GET", 200)
254
+ record_http_request_metric("/metrics", "GET", 500)
255
+
256
+ @pytest.mark.unit
257
+ def test_set_server_info(self):
258
+ """Test set_server_info function."""
259
+ from kubectl_mcp_tool.observability.metrics import set_server_info
260
+
261
+ # Should not raise even if prometheus_client is not installed
262
+ set_server_info("1.16.0", "stdio")
263
+
264
+ @pytest.mark.unit
265
+ def test_get_metrics_content_type(self):
266
+ """Test get_metrics_content_type function."""
267
+ from kubectl_mcp_tool.observability.metrics import get_metrics_content_type
268
+
269
+ content_type = get_metrics_content_type()
270
+ assert isinstance(content_type, str)
271
+ assert "text" in content_type
272
+
273
+
274
+ class TestTracing:
275
+ """Tests for OpenTelemetry tracing module."""
276
+
277
+ @pytest.mark.unit
278
+ def test_is_tracing_available(self):
279
+ """Test is_tracing_available function."""
280
+ from kubectl_mcp_tool.observability.tracing import is_tracing_available
281
+
282
+ # Should return bool regardless of opentelemetry installation
283
+ result = is_tracing_available()
284
+ assert isinstance(result, bool)
285
+
286
+ @pytest.mark.unit
287
+ def test_get_tracer_before_init(self):
288
+ """Test get_tracer returns None before initialization."""
289
+ from kubectl_mcp_tool.observability.tracing import get_tracer, shutdown_tracing
290
+
291
+ # Ensure clean state
292
+ shutdown_tracing()
293
+
294
+ tracer = get_tracer()
295
+ # May be None if not initialized, or a tracer if previously initialized
296
+ # Just verify it doesn't raise
297
+
298
+ @pytest.mark.unit
299
+ def test_traced_tool_call_no_op(self):
300
+ """Test traced_tool_call works as no-op when tracing unavailable."""
301
+ from kubectl_mcp_tool.observability.tracing import traced_tool_call, shutdown_tracing
302
+
303
+ shutdown_tracing()
304
+
305
+ with traced_tool_call("test_tool", {"key": "value"}) as span:
306
+ # Should work without raising
307
+ result = 1 + 1
308
+
309
+ assert result == 2
310
+
311
+ @pytest.mark.unit
312
+ def test_traced_tool_call_with_exception(self):
313
+ """Test traced_tool_call propagates exceptions."""
314
+ from kubectl_mcp_tool.observability.tracing import traced_tool_call
315
+
316
+ with pytest.raises(ValueError, match="test error"):
317
+ with traced_tool_call("test_tool") as span:
318
+ raise ValueError("test error")
319
+
320
+ @pytest.mark.unit
321
+ def test_add_span_attribute(self):
322
+ """Test add_span_attribute function."""
323
+ from kubectl_mcp_tool.observability.tracing import add_span_attribute
324
+
325
+ # Should not raise even if tracing is not available
326
+ add_span_attribute("test_key", "test_value")
327
+ add_span_attribute("test_int", 42)
328
+ add_span_attribute("test_float", 3.14)
329
+ add_span_attribute("test_bool", True)
330
+
331
+ @pytest.mark.unit
332
+ def test_record_span_exception(self):
333
+ """Test record_span_exception function."""
334
+ from kubectl_mcp_tool.observability.tracing import record_span_exception
335
+
336
+ # Should not raise even if tracing is not available
337
+ record_span_exception(ValueError("test error"))
338
+
339
+ @pytest.mark.unit
340
+ def test_shutdown_tracing(self):
341
+ """Test shutdown_tracing function."""
342
+ from kubectl_mcp_tool.observability.tracing import shutdown_tracing
343
+
344
+ # Should not raise even if tracing was not initialized
345
+ shutdown_tracing()
346
+ shutdown_tracing() # Multiple calls should be safe
347
+
348
+ @pytest.mark.unit
349
+ def test_init_tracing_without_endpoint(self):
350
+ """Test init_tracing without OTLP endpoint."""
351
+ from kubectl_mcp_tool.observability.tracing import (
352
+ init_tracing,
353
+ is_tracing_available,
354
+ shutdown_tracing,
355
+ )
356
+
357
+ shutdown_tracing()
358
+
359
+ if is_tracing_available():
360
+ # Clear any OTLP endpoint
361
+ with patch.dict("os.environ", {}, clear=True):
362
+ result = init_tracing(service_name="test-service")
363
+ assert result is True # Should initialize with no exporter
364
+ shutdown_tracing()
365
+ else:
366
+ result = init_tracing()
367
+ assert result is False # OpenTelemetry not available
368
+
369
+
370
+ class TestObservabilityModule:
371
+ """Tests for observability module exports."""
372
+
373
+ @pytest.mark.unit
374
+ def test_module_imports(self):
375
+ """Test that all observability functions can be imported."""
376
+ from kubectl_mcp_tool.observability import (
377
+ StatsCollector,
378
+ get_stats_collector,
379
+ get_metrics,
380
+ record_tool_call_metric,
381
+ record_tool_error_metric,
382
+ record_tool_duration_metric,
383
+ is_prometheus_available,
384
+ init_tracing,
385
+ traced_tool_call,
386
+ get_tracer,
387
+ is_tracing_available,
388
+ shutdown_tracing,
389
+ )
390
+
391
+ assert StatsCollector is not None
392
+ assert get_stats_collector is not None
393
+ assert get_metrics is not None
394
+ assert init_tracing is not None
395
+ assert traced_tool_call is not None
396
+
397
+ @pytest.mark.unit
398
+ def test_stats_and_metrics_integration(self):
399
+ """Test stats and metrics work together."""
400
+ from kubectl_mcp_tool.observability import (
401
+ get_stats_collector,
402
+ record_tool_call_metric,
403
+ get_metrics,
404
+ )
405
+
406
+ collector = get_stats_collector()
407
+ collector.reset()
408
+
409
+ # Record calls in both stats and metrics
410
+ for i in range(5):
411
+ collector.record_tool_call("integration_tool", success=True, duration=0.1)
412
+ record_tool_call_metric("integration_tool", success=True, duration=0.1)
413
+
414
+ stats = collector.get_stats()
415
+ metrics = get_metrics()
416
+
417
+ assert stats["tool_calls_total"] == 5
418
+ assert isinstance(metrics, str)
419
+
420
+
421
+ class TestSamplerConfiguration:
422
+ """Tests for OpenTelemetry sampler configuration."""
423
+
424
+ @pytest.mark.unit
425
+ def test_sampler_always_on(self):
426
+ """Test OTEL_TRACES_SAMPLER=always_on."""
427
+ from kubectl_mcp_tool.observability.tracing import is_tracing_available
428
+
429
+ if not is_tracing_available():
430
+ pytest.skip("OpenTelemetry not available")
431
+
432
+ from kubectl_mcp_tool.observability.tracing import _get_sampler
433
+
434
+ with patch.dict("os.environ", {"OTEL_TRACES_SAMPLER": "always_on"}):
435
+ sampler = _get_sampler()
436
+ assert sampler is not None
437
+
438
+ @pytest.mark.unit
439
+ def test_sampler_always_off(self):
440
+ """Test OTEL_TRACES_SAMPLER=always_off."""
441
+ from kubectl_mcp_tool.observability.tracing import is_tracing_available
442
+
443
+ if not is_tracing_available():
444
+ pytest.skip("OpenTelemetry not available")
445
+
446
+ from kubectl_mcp_tool.observability.tracing import _get_sampler
447
+
448
+ with patch.dict("os.environ", {"OTEL_TRACES_SAMPLER": "always_off"}):
449
+ sampler = _get_sampler()
450
+ assert sampler is not None
451
+
452
+ @pytest.mark.unit
453
+ def test_sampler_trace_id_ratio(self):
454
+ """Test OTEL_TRACES_SAMPLER=traceidratio."""
455
+ from kubectl_mcp_tool.observability.tracing import is_tracing_available
456
+
457
+ if not is_tracing_available():
458
+ pytest.skip("OpenTelemetry not available")
459
+
460
+ from kubectl_mcp_tool.observability.tracing import _get_sampler
461
+
462
+ with patch.dict("os.environ", {
463
+ "OTEL_TRACES_SAMPLER": "traceidratio",
464
+ "OTEL_TRACES_SAMPLER_ARG": "0.5"
465
+ }):
466
+ sampler = _get_sampler()
467
+ assert sampler is not None
468
+
469
+ @pytest.mark.unit
470
+ def test_sampler_invalid_ratio(self):
471
+ """Test invalid OTEL_TRACES_SAMPLER_ARG defaults to 1.0."""
472
+ from kubectl_mcp_tool.observability.tracing import is_tracing_available
473
+
474
+ if not is_tracing_available():
475
+ pytest.skip("OpenTelemetry not available")
476
+
477
+ from kubectl_mcp_tool.observability.tracing import _get_sampler
478
+
479
+ with patch.dict("os.environ", {
480
+ "OTEL_TRACES_SAMPLER": "traceidratio",
481
+ "OTEL_TRACES_SAMPLER_ARG": "invalid"
482
+ }):
483
+ # Should not raise, defaults to 1.0
484
+ sampler = _get_sampler()
485
+ assert sampler is not None
486
+
487
+
488
+ class TestToolStatsDataclass:
489
+ """Tests for ToolStats dataclass."""
490
+
491
+ @pytest.mark.unit
492
+ def test_tool_stats_defaults(self):
493
+ """Test ToolStats default values."""
494
+ from kubectl_mcp_tool.observability.stats import ToolStats
495
+
496
+ stats = ToolStats()
497
+
498
+ assert stats.calls == 0
499
+ assert stats.errors == 0
500
+ assert stats.total_duration == 0.0
501
+ assert stats.last_call_time is None
502
+ assert stats.last_error_time is None
503
+
504
+ @pytest.mark.unit
505
+ def test_tool_stats_custom_values(self):
506
+ """Test ToolStats with custom values."""
507
+ from kubectl_mcp_tool.observability.stats import ToolStats
508
+
509
+ now = time.time()
510
+ stats = ToolStats(
511
+ calls=10,
512
+ errors=2,
513
+ total_duration=5.5,
514
+ last_call_time=now,
515
+ last_error_time=now - 100
516
+ )
517
+
518
+ assert stats.calls == 10
519
+ assert stats.errors == 2
520
+ assert stats.total_duration == 5.5
521
+ assert stats.last_call_time == now