nthlayer-workers 1.0.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 (175) hide show
  1. nthlayer_workers/__init__.py +5 -0
  2. nthlayer_workers/cli.py +234 -0
  3. nthlayer_workers/correlate/__init__.py +1 -0
  4. nthlayer_workers/correlate/cli.py +847 -0
  5. nthlayer_workers/correlate/config.py +111 -0
  6. nthlayer_workers/correlate/correlation/__init__.py +1 -0
  7. nthlayer_workers/correlate/correlation/changes.py +87 -0
  8. nthlayer_workers/correlate/correlation/dedup.py +62 -0
  9. nthlayer_workers/correlate/correlation/engine.py +244 -0
  10. nthlayer_workers/correlate/correlation/temporal.py +79 -0
  11. nthlayer_workers/correlate/correlation/topology.py +104 -0
  12. nthlayer_workers/correlate/ingestion/__init__.py +1 -0
  13. nthlayer_workers/correlate/ingestion/protocol.py +10 -0
  14. nthlayer_workers/correlate/ingestion/severity.py +18 -0
  15. nthlayer_workers/correlate/ingestion/webhook.py +197 -0
  16. nthlayer_workers/correlate/notifications.py +85 -0
  17. nthlayer_workers/correlate/prometheus.py +234 -0
  18. nthlayer_workers/correlate/reasoning.py +375 -0
  19. nthlayer_workers/correlate/session.py +189 -0
  20. nthlayer_workers/correlate/snapshot/__init__.py +1 -0
  21. nthlayer_workers/correlate/snapshot/generator.py +170 -0
  22. nthlayer_workers/correlate/snapshot/model.py +177 -0
  23. nthlayer_workers/correlate/snapshot/token.py +14 -0
  24. nthlayer_workers/correlate/state.py +88 -0
  25. nthlayer_workers/correlate/store/__init__.py +5 -0
  26. nthlayer_workers/correlate/store/protocol.py +48 -0
  27. nthlayer_workers/correlate/store/sqlite.py +443 -0
  28. nthlayer_workers/correlate/summary.py +180 -0
  29. nthlayer_workers/correlate/traces/__init__.py +1 -0
  30. nthlayer_workers/correlate/traces/protocol.py +120 -0
  31. nthlayer_workers/correlate/traces/tempo.py +667 -0
  32. nthlayer_workers/correlate/traces/topology.py +39 -0
  33. nthlayer_workers/correlate/types.py +77 -0
  34. nthlayer_workers/correlate/worker.py +630 -0
  35. nthlayer_workers/learn/__init__.py +5 -0
  36. nthlayer_workers/learn/__main__.py +5 -0
  37. nthlayer_workers/learn/cli.py +164 -0
  38. nthlayer_workers/learn/retrospective.py +381 -0
  39. nthlayer_workers/learn/trends.py +102 -0
  40. nthlayer_workers/learn/worker.py +366 -0
  41. nthlayer_workers/measure/__init__.py +3 -0
  42. nthlayer_workers/measure/__main__.py +5 -0
  43. nthlayer_workers/measure/_parsing.py +15 -0
  44. nthlayer_workers/measure/adapters/__init__.py +0 -0
  45. nthlayer_workers/measure/adapters/_util.py +24 -0
  46. nthlayer_workers/measure/adapters/devin.py +119 -0
  47. nthlayer_workers/measure/adapters/gastown.py +88 -0
  48. nthlayer_workers/measure/adapters/prometheus.py +277 -0
  49. nthlayer_workers/measure/adapters/protocol.py +20 -0
  50. nthlayer_workers/measure/adapters/webhook.py +161 -0
  51. nthlayer_workers/measure/api/__init__.py +0 -0
  52. nthlayer_workers/measure/api/normalise.py +50 -0
  53. nthlayer_workers/measure/api/queue.py +243 -0
  54. nthlayer_workers/measure/api/response.py +51 -0
  55. nthlayer_workers/measure/api/server.py +504 -0
  56. nthlayer_workers/measure/calibration/__init__.py +0 -0
  57. nthlayer_workers/measure/calibration/loop.py +62 -0
  58. nthlayer_workers/measure/calibration/slos.py +212 -0
  59. nthlayer_workers/measure/calibration/verdict_calibration.py +31 -0
  60. nthlayer_workers/measure/cli.py +753 -0
  61. nthlayer_workers/measure/config.py +191 -0
  62. nthlayer_workers/measure/detection/__init__.py +6 -0
  63. nthlayer_workers/measure/detection/detector.py +82 -0
  64. nthlayer_workers/measure/detection/protocol.py +29 -0
  65. nthlayer_workers/measure/governance/__init__.py +0 -0
  66. nthlayer_workers/measure/governance/engine.py +163 -0
  67. nthlayer_workers/measure/manifest.py +77 -0
  68. nthlayer_workers/measure/notifications.py +53 -0
  69. nthlayer_workers/measure/pipeline/__init__.py +0 -0
  70. nthlayer_workers/measure/pipeline/evaluator.py +155 -0
  71. nthlayer_workers/measure/pipeline/router.py +160 -0
  72. nthlayer_workers/measure/store/__init__.py +0 -0
  73. nthlayer_workers/measure/store/protocol.py +38 -0
  74. nthlayer_workers/measure/store/sqlite.py +276 -0
  75. nthlayer_workers/measure/telemetry.py +116 -0
  76. nthlayer_workers/measure/tiering/__init__.py +0 -0
  77. nthlayer_workers/measure/tiering/classifier.py +58 -0
  78. nthlayer_workers/measure/tiering/promotion.py +118 -0
  79. nthlayer_workers/measure/trends/__init__.py +0 -0
  80. nthlayer_workers/measure/trends/tracker.py +72 -0
  81. nthlayer_workers/measure/types.py +75 -0
  82. nthlayer_workers/measure/worker.py +439 -0
  83. nthlayer_workers/observe/__init__.py +25 -0
  84. nthlayer_workers/observe/__main__.py +5 -0
  85. nthlayer_workers/observe/api/__init__.py +1 -0
  86. nthlayer_workers/observe/assessment.py +95 -0
  87. nthlayer_workers/observe/cli.py +737 -0
  88. nthlayer_workers/observe/config.py +11 -0
  89. nthlayer_workers/observe/db/__init__.py +1 -0
  90. nthlayer_workers/observe/decision_records.py +220 -0
  91. nthlayer_workers/observe/dependencies/__init__.py +18 -0
  92. nthlayer_workers/observe/dependencies/discovery.py +294 -0
  93. nthlayer_workers/observe/dependencies/providers/__init__.py +48 -0
  94. nthlayer_workers/observe/dependencies/providers/backstage.py +467 -0
  95. nthlayer_workers/observe/dependencies/providers/base.py +76 -0
  96. nthlayer_workers/observe/dependencies/providers/consul.py +518 -0
  97. nthlayer_workers/observe/dependencies/providers/etcd.py +360 -0
  98. nthlayer_workers/observe/dependencies/providers/kubernetes.py +682 -0
  99. nthlayer_workers/observe/dependencies/providers/prometheus.py +368 -0
  100. nthlayer_workers/observe/dependencies/providers/zookeeper.py +399 -0
  101. nthlayer_workers/observe/deployments/__init__.py +1 -0
  102. nthlayer_workers/observe/discovery/__init__.py +14 -0
  103. nthlayer_workers/observe/discovery/classifier.py +66 -0
  104. nthlayer_workers/observe/discovery/client.py +189 -0
  105. nthlayer_workers/observe/discovery/models.py +53 -0
  106. nthlayer_workers/observe/drift/__init__.py +26 -0
  107. nthlayer_workers/observe/drift/analyzer.py +383 -0
  108. nthlayer_workers/observe/drift/models.py +174 -0
  109. nthlayer_workers/observe/drift/patterns.py +88 -0
  110. nthlayer_workers/observe/explanation.py +118 -0
  111. nthlayer_workers/observe/gate/__init__.py +39 -0
  112. nthlayer_workers/observe/gate/conditions.py +92 -0
  113. nthlayer_workers/observe/gate/correlator.py +154 -0
  114. nthlayer_workers/observe/gate/evaluator.py +192 -0
  115. nthlayer_workers/observe/gate/policies.py +226 -0
  116. nthlayer_workers/observe/gate_adapter.py +40 -0
  117. nthlayer_workers/observe/incident.py +36 -0
  118. nthlayer_workers/observe/portfolio/__init__.py +17 -0
  119. nthlayer_workers/observe/portfolio/aggregator.py +168 -0
  120. nthlayer_workers/observe/portfolio/scorer.py +13 -0
  121. nthlayer_workers/observe/slo/__init__.py +19 -0
  122. nthlayer_workers/observe/slo/collector.py +235 -0
  123. nthlayer_workers/observe/slo/spec_loader.py +40 -0
  124. nthlayer_workers/observe/sqlite_store.py +152 -0
  125. nthlayer_workers/observe/store.py +92 -0
  126. nthlayer_workers/observe/verification/__init__.py +22 -0
  127. nthlayer_workers/observe/verification/exporter_guidance.py +146 -0
  128. nthlayer_workers/observe/verification/extractor.py +127 -0
  129. nthlayer_workers/observe/verification/models.py +101 -0
  130. nthlayer_workers/observe/verification/verifier.py +111 -0
  131. nthlayer_workers/observe/worker.py +332 -0
  132. nthlayer_workers/respond/__init__.py +2 -0
  133. nthlayer_workers/respond/__main__.py +4 -0
  134. nthlayer_workers/respond/agents/__init__.py +0 -0
  135. nthlayer_workers/respond/agents/base.py +556 -0
  136. nthlayer_workers/respond/agents/communication.py +115 -0
  137. nthlayer_workers/respond/agents/investigation.py +124 -0
  138. nthlayer_workers/respond/agents/remediation.py +219 -0
  139. nthlayer_workers/respond/agents/triage.py +132 -0
  140. nthlayer_workers/respond/cli.py +772 -0
  141. nthlayer_workers/respond/config.py +135 -0
  142. nthlayer_workers/respond/context_store.py +256 -0
  143. nthlayer_workers/respond/coordinator.py +487 -0
  144. nthlayer_workers/respond/metrics.py +104 -0
  145. nthlayer_workers/respond/notification_backends/__init__.py +1 -0
  146. nthlayer_workers/respond/notification_backends/ntfy_backend.py +158 -0
  147. nthlayer_workers/respond/notification_backends/protocol.py +59 -0
  148. nthlayer_workers/respond/notification_backends/slack_backend.py +203 -0
  149. nthlayer_workers/respond/notification_backends/stdout_backend.py +56 -0
  150. nthlayer_workers/respond/notifications.py +247 -0
  151. nthlayer_workers/respond/oncall/__init__.py +1 -0
  152. nthlayer_workers/respond/oncall/escalation.py +103 -0
  153. nthlayer_workers/respond/oncall/runner.py +193 -0
  154. nthlayer_workers/respond/oncall/schedule.py +243 -0
  155. nthlayer_workers/respond/safe_actions/__init__.py +0 -0
  156. nthlayer_workers/respond/safe_actions/actions.py +139 -0
  157. nthlayer_workers/respond/safe_actions/registry.py +171 -0
  158. nthlayer_workers/respond/safe_actions/webhook.py +194 -0
  159. nthlayer_workers/respond/server.py +357 -0
  160. nthlayer_workers/respond/sre/__init__.py +1 -0
  161. nthlayer_workers/respond/sre/brief.py +175 -0
  162. nthlayer_workers/respond/sre/delegation.py +101 -0
  163. nthlayer_workers/respond/sre/post_incident.py +146 -0
  164. nthlayer_workers/respond/sre/shift_report.py +129 -0
  165. nthlayer_workers/respond/sre/suppression.py +91 -0
  166. nthlayer_workers/respond/types.py +109 -0
  167. nthlayer_workers/respond/verdict_submission.py +56 -0
  168. nthlayer_workers/respond/worker.py +533 -0
  169. nthlayer_workers/respond/worker_helpers.py +140 -0
  170. nthlayer_workers/runner.py +198 -0
  171. nthlayer_workers-1.0.0.dist-info/METADATA +19 -0
  172. nthlayer_workers-1.0.0.dist-info/RECORD +175 -0
  173. nthlayer_workers-1.0.0.dist-info/WHEEL +5 -0
  174. nthlayer_workers-1.0.0.dist-info/entry_points.txt +2 -0
  175. nthlayer_workers-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,682 @@
1
+ """
2
+ Kubernetes dependency provider.
3
+
4
+ Discovers service dependencies from Kubernetes resources including:
5
+ - Service-to-Service relationships (via label selectors)
6
+ - Ingress-to-Service mappings
7
+ - NetworkPolicy analysis for allowed connections
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import os
14
+ import re
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from functools import partial
18
+ from typing import Any
19
+
20
+ from nthlayer_common.errors import ProviderError
21
+ from nthlayer_common.dependency_models import DependencyType, DiscoveredDependency
22
+ from nthlayer_workers.observe.dependencies.providers.base import (
23
+ BaseDepProvider,
24
+ ProviderHealth,
25
+ deduplicate_dependencies,
26
+ )
27
+
28
+ # Lazy import kubernetes to allow optional installation
29
+ _kubernetes_available: bool | None = None
30
+
31
+
32
+ def _check_kubernetes_available() -> bool:
33
+ """Check if kubernetes package is installed."""
34
+ global _kubernetes_available
35
+ if _kubernetes_available is None:
36
+ try:
37
+ import kubernetes # noqa: F401
38
+
39
+ _kubernetes_available = True
40
+ except ImportError:
41
+ _kubernetes_available = False
42
+ return _kubernetes_available
43
+
44
+
45
+ class KubernetesDepProviderError(ProviderError):
46
+ """Raised when Kubernetes dependency provider encounters an error."""
47
+
48
+
49
+ @dataclass
50
+ class KubernetesDepProvider(BaseDepProvider):
51
+ """
52
+ Discover dependencies from Kubernetes API.
53
+
54
+ Configuration:
55
+ namespace: Namespace to search (None = all namespaces)
56
+ kubeconfig: Path to kubeconfig file (optional)
57
+ context: Kubeconfig context to use (optional)
58
+ timeout: API request timeout in seconds
59
+
60
+ Environment variables:
61
+ KUBECONFIG: Standard kubeconfig path
62
+ NTHLAYER_K8S_NAMESPACE: Default namespace filter
63
+ NTHLAYER_K8S_CONTEXT: Kubeconfig context
64
+ """
65
+
66
+ namespace: str | None = field(default_factory=lambda: os.environ.get("NTHLAYER_K8S_NAMESPACE"))
67
+ kubeconfig: str | None = field(default_factory=lambda: os.environ.get("KUBECONFIG"))
68
+ context: str | None = field(default_factory=lambda: os.environ.get("NTHLAYER_K8S_CONTEXT"))
69
+ timeout: float = 30.0
70
+
71
+ # Internal state
72
+ _api_client: Any = field(default=None, repr=False, compare=False)
73
+ _initialized: bool = field(default=False, repr=False, compare=False)
74
+
75
+ @property
76
+ def name(self) -> str:
77
+ return "kubernetes"
78
+
79
+ def _ensure_initialized(self) -> None:
80
+ """Initialize Kubernetes client if not already done."""
81
+ if self._initialized:
82
+ return
83
+
84
+ if not _check_kubernetes_available():
85
+ raise KubernetesDepProviderError(
86
+ "kubernetes package not installed. "
87
+ "Install with: pip install nthlayer[kubernetes]"
88
+ )
89
+
90
+ from kubernetes import client, config
91
+
92
+ # Try in-cluster config first, then kubeconfig
93
+ try:
94
+ config.load_incluster_config()
95
+ except config.ConfigException:
96
+ try:
97
+ config.load_kube_config(
98
+ config_file=self.kubeconfig,
99
+ context=self.context,
100
+ )
101
+ except config.ConfigException as e:
102
+ raise KubernetesDepProviderError(f"Failed to load Kubernetes config: {e}") from e
103
+
104
+ self._api_client = client.ApiClient()
105
+ self._initialized = True
106
+
107
+ def _get_core_api(self) -> Any:
108
+ """Get CoreV1Api client."""
109
+ self._ensure_initialized()
110
+ from kubernetes import client
111
+
112
+ return client.CoreV1Api(self._api_client)
113
+
114
+ def _get_networking_api(self) -> Any:
115
+ """Get NetworkingV1Api client."""
116
+ self._ensure_initialized()
117
+ from kubernetes import client
118
+
119
+ return client.NetworkingV1Api(self._api_client)
120
+
121
+ async def _run_sync(self, func: Any, *args: Any, **kwargs: Any) -> Any:
122
+ """Run synchronous kubernetes API call in executor."""
123
+ loop = asyncio.get_event_loop()
124
+ return await loop.run_in_executor(None, partial(func, *args, **kwargs))
125
+
126
+ async def discover(self, service: str) -> list[DiscoveredDependency]:
127
+ """Discover dependencies for a service from Kubernetes resources."""
128
+ deps: list[DiscoveredDependency] = []
129
+
130
+ # Discover from multiple sources
131
+ deps.extend(await self._discover_from_ingress(service))
132
+ deps.extend(await self._discover_from_network_policies(service))
133
+ deps.extend(await self._discover_from_service_env(service))
134
+
135
+ return deduplicate_dependencies(deps)
136
+
137
+ async def discover_downstream(self, service: str) -> list[DiscoveredDependency]:
138
+ """Discover services that call this service (downstream dependents)."""
139
+ deps: list[DiscoveredDependency] = []
140
+
141
+ # Find services that have this service as a target in their env vars
142
+ deps.extend(await self._discover_downstream_from_env(service))
143
+
144
+ # Find ingresses that route to this service
145
+ deps.extend(await self._discover_downstream_from_ingress(service))
146
+
147
+ # Find network policies that allow traffic to this service
148
+ deps.extend(await self._discover_downstream_from_network_policies(service))
149
+
150
+ return deduplicate_dependencies(deps)
151
+
152
+ async def _discover_from_ingress(self, service: str) -> list[DiscoveredDependency]:
153
+ """Find services referenced in Ingress backends for this service."""
154
+ deps: list[DiscoveredDependency] = []
155
+
156
+ try:
157
+ networking_api = self._get_networking_api()
158
+
159
+ if self.namespace:
160
+ ingresses = await self._run_sync(
161
+ networking_api.list_namespaced_ingress,
162
+ self.namespace,
163
+ timeout_seconds=int(self.timeout),
164
+ )
165
+ else:
166
+ ingresses = await self._run_sync(
167
+ networking_api.list_ingress_for_all_namespaces,
168
+ timeout_seconds=int(self.timeout),
169
+ )
170
+
171
+ # Find ingresses that have this service as a backend
172
+ for ingress in ingresses.items:
173
+ ingress_name = ingress.metadata.name
174
+ namespace = ingress.metadata.namespace
175
+
176
+ if not ingress.spec or not ingress.spec.rules:
177
+ continue
178
+
179
+ for rule in ingress.spec.rules:
180
+ if not rule.http or not rule.http.paths:
181
+ continue
182
+
183
+ for path in rule.http.paths:
184
+ backend = path.backend
185
+ if backend and backend.service:
186
+ backend_service = backend.service.name
187
+
188
+ # If this ingress points to our service, the ingress is downstream
189
+ if backend_service == service:
190
+ deps.append(
191
+ DiscoveredDependency(
192
+ source_service=f"ingress/{ingress_name}",
193
+ target_service=service,
194
+ provider=self.name,
195
+ dep_type=DependencyType.INFRASTRUCTURE,
196
+ confidence=0.95,
197
+ metadata={
198
+ "source": "ingress",
199
+ "namespace": namespace,
200
+ "host": rule.host,
201
+ "path": path.path,
202
+ },
203
+ raw_source=ingress_name,
204
+ raw_target=backend_service,
205
+ )
206
+ )
207
+
208
+ except Exception as e:
209
+ # Log but continue - RBAC might restrict access
210
+ if "Forbidden" not in str(e):
211
+ raise KubernetesDepProviderError(f"Failed to list ingresses: {e}") from e
212
+
213
+ return deps
214
+
215
+ async def _discover_downstream_from_ingress(self, service: str) -> list[DiscoveredDependency]:
216
+ """Find ingresses that route to this service."""
217
+ # This is the same as _discover_from_ingress but returns as downstream
218
+ return await self._discover_from_ingress(service)
219
+
220
+ async def _discover_from_network_policies(self, service: str) -> list[DiscoveredDependency]:
221
+ """Discover allowed connections from NetworkPolicies."""
222
+ deps: list[DiscoveredDependency] = []
223
+
224
+ try:
225
+ networking_api = self._get_networking_api()
226
+
227
+ if self.namespace:
228
+ policies = await self._run_sync(
229
+ networking_api.list_namespaced_network_policy,
230
+ self.namespace,
231
+ timeout_seconds=int(self.timeout),
232
+ )
233
+ else:
234
+ policies = await self._run_sync(
235
+ networking_api.list_network_policy_for_all_namespaces,
236
+ timeout_seconds=int(self.timeout),
237
+ )
238
+
239
+ for policy in policies.items:
240
+ policy_name = policy.metadata.name
241
+ namespace = policy.metadata.namespace
242
+
243
+ # Check if this policy applies to our service (via pod selector)
244
+ if not policy.spec or not policy.spec.pod_selector:
245
+ continue
246
+
247
+ # Get the pod selector labels
248
+ selector_labels = policy.spec.pod_selector.match_labels or {}
249
+
250
+ # Check if selector matches service (by app label convention)
251
+ if not self._selector_matches_service(selector_labels, service):
252
+ continue
253
+
254
+ # Parse egress rules to find what this service can connect to
255
+ if policy.spec.egress:
256
+ for egress in policy.spec.egress:
257
+ if not egress.to:
258
+ continue
259
+
260
+ for to in egress.to:
261
+ if to.pod_selector and to.pod_selector.match_labels:
262
+ target = self._extract_service_from_selector(
263
+ to.pod_selector.match_labels
264
+ )
265
+ if target:
266
+ deps.append(
267
+ DiscoveredDependency(
268
+ source_service=service,
269
+ target_service=target,
270
+ provider=self.name,
271
+ dep_type=DependencyType.SERVICE,
272
+ confidence=0.85,
273
+ metadata={
274
+ "source": "network_policy_egress",
275
+ "policy": policy_name,
276
+ "namespace": namespace,
277
+ },
278
+ raw_source=service,
279
+ raw_target=target,
280
+ )
281
+ )
282
+
283
+ except Exception as e:
284
+ if "Forbidden" not in str(e):
285
+ raise KubernetesDepProviderError(f"Failed to list network policies: {e}") from e
286
+
287
+ return deps
288
+
289
+ async def _discover_downstream_from_network_policies(
290
+ self, service: str
291
+ ) -> list[DiscoveredDependency]:
292
+ """Find services allowed to connect to this service via NetworkPolicies."""
293
+ deps: list[DiscoveredDependency] = []
294
+
295
+ try:
296
+ networking_api = self._get_networking_api()
297
+
298
+ if self.namespace:
299
+ policies = await self._run_sync(
300
+ networking_api.list_namespaced_network_policy,
301
+ self.namespace,
302
+ timeout_seconds=int(self.timeout),
303
+ )
304
+ else:
305
+ policies = await self._run_sync(
306
+ networking_api.list_network_policy_for_all_namespaces,
307
+ timeout_seconds=int(self.timeout),
308
+ )
309
+
310
+ for policy in policies.items:
311
+ policy_name = policy.metadata.name
312
+ namespace = policy.metadata.namespace
313
+
314
+ if not policy.spec or not policy.spec.pod_selector:
315
+ continue
316
+
317
+ selector_labels = policy.spec.pod_selector.match_labels or {}
318
+
319
+ # Check if this policy applies to our target service
320
+ if not self._selector_matches_service(selector_labels, service):
321
+ continue
322
+
323
+ # Parse ingress rules to find who can connect to this service
324
+ if policy.spec.ingress:
325
+ for ingress_rule in policy.spec.ingress:
326
+ if not ingress_rule._from:
327
+ continue
328
+
329
+ for from_rule in ingress_rule._from:
330
+ if from_rule.pod_selector and from_rule.pod_selector.match_labels:
331
+ source = self._extract_service_from_selector(
332
+ from_rule.pod_selector.match_labels
333
+ )
334
+ if source:
335
+ deps.append(
336
+ DiscoveredDependency(
337
+ source_service=source,
338
+ target_service=service,
339
+ provider=self.name,
340
+ dep_type=DependencyType.SERVICE,
341
+ confidence=0.85,
342
+ metadata={
343
+ "source": "network_policy_ingress",
344
+ "policy": policy_name,
345
+ "namespace": namespace,
346
+ },
347
+ raw_source=source,
348
+ raw_target=service,
349
+ )
350
+ )
351
+
352
+ except Exception as e:
353
+ if "Forbidden" not in str(e):
354
+ raise KubernetesDepProviderError(f"Failed to list network policies: {e}") from e
355
+
356
+ return deps
357
+
358
+ async def _discover_from_service_env(self, service: str) -> list[DiscoveredDependency]:
359
+ """Discover dependencies from Pod environment variables."""
360
+ deps: list[DiscoveredDependency] = []
361
+
362
+ try:
363
+ core_api = self._get_core_api()
364
+
365
+ # Find pods for this service
366
+ label_selector = f"app={service}"
367
+
368
+ if self.namespace:
369
+ pods = await self._run_sync(
370
+ core_api.list_namespaced_pod,
371
+ self.namespace,
372
+ label_selector=label_selector,
373
+ timeout_seconds=int(self.timeout),
374
+ )
375
+ else:
376
+ pods = await self._run_sync(
377
+ core_api.list_pod_for_all_namespaces,
378
+ label_selector=label_selector,
379
+ timeout_seconds=int(self.timeout),
380
+ )
381
+
382
+ # Also try app.kubernetes.io/name label
383
+ if not pods.items:
384
+ label_selector = f"app.kubernetes.io/name={service}"
385
+ if self.namespace:
386
+ pods = await self._run_sync(
387
+ core_api.list_namespaced_pod,
388
+ self.namespace,
389
+ label_selector=label_selector,
390
+ timeout_seconds=int(self.timeout),
391
+ )
392
+ else:
393
+ pods = await self._run_sync(
394
+ core_api.list_pod_for_all_namespaces,
395
+ label_selector=label_selector,
396
+ timeout_seconds=int(self.timeout),
397
+ )
398
+
399
+ for pod in pods.items:
400
+ namespace = pod.metadata.namespace
401
+
402
+ if not pod.spec or not pod.spec.containers:
403
+ continue
404
+
405
+ for container in pod.spec.containers:
406
+ if not container.env:
407
+ continue
408
+
409
+ for env_var in container.env:
410
+ if not env_var.value:
411
+ continue
412
+
413
+ # Look for service references in env values
414
+ target = self._extract_service_from_env(env_var.name, env_var.value)
415
+ if target and target != service:
416
+ dep_type = self._infer_dep_type_from_env(env_var.name)
417
+ deps.append(
418
+ DiscoveredDependency(
419
+ source_service=service,
420
+ target_service=target,
421
+ provider=self.name,
422
+ dep_type=dep_type,
423
+ confidence=0.75,
424
+ metadata={
425
+ "source": "pod_env",
426
+ "env_var": env_var.name,
427
+ "namespace": namespace,
428
+ },
429
+ raw_source=service,
430
+ raw_target=target,
431
+ )
432
+ )
433
+
434
+ except Exception as e:
435
+ if "Forbidden" not in str(e):
436
+ raise KubernetesDepProviderError(f"Failed to list pods: {e}") from e
437
+
438
+ return deps
439
+
440
+ async def _discover_downstream_from_env(self, service: str) -> list[DiscoveredDependency]:
441
+ """Find services that reference this service in their env vars."""
442
+ deps: list[DiscoveredDependency] = []
443
+
444
+ try:
445
+ core_api = self._get_core_api()
446
+
447
+ # List all pods (this can be expensive in large clusters)
448
+ if self.namespace:
449
+ pods = await self._run_sync(
450
+ core_api.list_namespaced_pod,
451
+ self.namespace,
452
+ timeout_seconds=int(self.timeout),
453
+ )
454
+ else:
455
+ pods = await self._run_sync(
456
+ core_api.list_pod_for_all_namespaces,
457
+ timeout_seconds=int(self.timeout),
458
+ )
459
+
460
+ for pod in pods.items:
461
+ namespace = pod.metadata.namespace
462
+ labels = pod.metadata.labels or {}
463
+
464
+ # Get source service name from labels
465
+ source = labels.get("app") or labels.get("app.kubernetes.io/name")
466
+ if not source or source == service:
467
+ continue
468
+
469
+ if not pod.spec or not pod.spec.containers:
470
+ continue
471
+
472
+ for container in pod.spec.containers:
473
+ if not container.env:
474
+ continue
475
+
476
+ for env_var in container.env:
477
+ if not env_var.value:
478
+ continue
479
+
480
+ # Check if env value references our service
481
+ if self._env_references_service(env_var.value, service):
482
+ dep_type = self._infer_dep_type_from_env(env_var.name)
483
+ deps.append(
484
+ DiscoveredDependency(
485
+ source_service=source,
486
+ target_service=service,
487
+ provider=self.name,
488
+ dep_type=dep_type,
489
+ confidence=0.75,
490
+ metadata={
491
+ "source": "pod_env_reference",
492
+ "env_var": env_var.name,
493
+ "namespace": namespace,
494
+ },
495
+ raw_source=source,
496
+ raw_target=service,
497
+ )
498
+ )
499
+ break # One reference per source is enough
500
+
501
+ except Exception as e:
502
+ if "Forbidden" not in str(e):
503
+ raise KubernetesDepProviderError(f"Failed to list pods: {e}") from e
504
+
505
+ return deps
506
+
507
+ def _selector_matches_service(self, selector_labels: dict[str, str], service: str) -> bool:
508
+ """Check if a label selector matches a service name."""
509
+ for label in ["app", "app.kubernetes.io/name", "name"]:
510
+ if selector_labels.get(label) == service:
511
+ return True
512
+ return False
513
+
514
+ def _extract_service_from_selector(self, labels: dict[str, str]) -> str | None:
515
+ """Extract service name from pod selector labels."""
516
+ for label in ["app", "app.kubernetes.io/name", "name"]:
517
+ if label in labels:
518
+ return labels[label]
519
+ return None
520
+
521
+ def _extract_service_from_env(self, name: str, value: str) -> str | None:
522
+ """Extract service reference from environment variable."""
523
+ # K8s service discovery pattern: SERVICE_NAME_SERVICE_HOST
524
+ if name.endswith("_SERVICE_HOST"):
525
+ service_name = name.replace("_SERVICE_HOST", "").lower().replace("_", "-")
526
+ return service_name
527
+
528
+ # URL patterns
529
+ url_patterns = [
530
+ r"://([a-z0-9-]+)(?:\.[a-z0-9-]+)?(?::\d+)?", # protocol://service.namespace:port
531
+ r"://([a-z0-9-]+):\d+", # protocol://service:port
532
+ ]
533
+
534
+ for pattern in url_patterns:
535
+ match = re.search(pattern, value.lower())
536
+ if match:
537
+ service_name = match.group(1)
538
+ # Filter out common non-service values
539
+ if service_name not in ["localhost", "127", "0"]:
540
+ return service_name
541
+
542
+ return None
543
+
544
+ def _env_references_service(self, value: str, service: str) -> bool:
545
+ """Check if an environment variable value references a service."""
546
+ value_lower = value.lower()
547
+ service_lower = service.lower()
548
+
549
+ # Check for direct service name in URL
550
+ patterns = [
551
+ f"://{service_lower}.",
552
+ f"://{service_lower}:",
553
+ f"://{service_lower}/",
554
+ f"://{service_lower} ",
555
+ ]
556
+
557
+ for pattern in patterns:
558
+ if pattern in value_lower:
559
+ return True
560
+
561
+ # Check K8s service discovery env var format
562
+ service_env = service_lower.replace("-", "_")
563
+ if f"{service_env}_service_host" in value_lower:
564
+ return True
565
+
566
+ return False
567
+
568
+ def _infer_dep_type_from_env(self, env_name: str) -> DependencyType:
569
+ """Infer dependency type from environment variable name."""
570
+ name_lower = env_name.lower()
571
+
572
+ if any(x in name_lower for x in ["database", "db", "postgres", "mysql", "mongo"]):
573
+ return DependencyType.DATASTORE
574
+ if any(x in name_lower for x in ["redis", "cache", "memcache"]):
575
+ return DependencyType.DATASTORE
576
+ if any(x in name_lower for x in ["kafka", "rabbitmq", "queue", "mq", "nats"]):
577
+ return DependencyType.QUEUE
578
+ if any(x in name_lower for x in ["api_key", "external", "third_party"]):
579
+ return DependencyType.EXTERNAL
580
+
581
+ return DependencyType.SERVICE
582
+
583
+ async def list_services(self) -> list[str]:
584
+ """List all Kubernetes services."""
585
+ services: set[str] = set()
586
+
587
+ try:
588
+ core_api = self._get_core_api()
589
+
590
+ if self.namespace:
591
+ svc_list = await self._run_sync(
592
+ core_api.list_namespaced_service,
593
+ self.namespace,
594
+ timeout_seconds=int(self.timeout),
595
+ )
596
+ else:
597
+ svc_list = await self._run_sync(
598
+ core_api.list_service_for_all_namespaces,
599
+ timeout_seconds=int(self.timeout),
600
+ )
601
+
602
+ for svc in svc_list.items:
603
+ # Skip kubernetes system services
604
+ if svc.metadata.namespace == "kube-system":
605
+ continue
606
+ services.add(svc.metadata.name)
607
+
608
+ except Exception as e:
609
+ raise KubernetesDepProviderError(f"Failed to list services: {e}") from e
610
+
611
+ return sorted(services)
612
+
613
+ async def health_check(self) -> ProviderHealth:
614
+ """Check Kubernetes API connectivity."""
615
+ start = time.time()
616
+
617
+ try:
618
+ self._ensure_initialized()
619
+ core_api = self._get_core_api()
620
+
621
+ # Try to get API server version
622
+ await self._run_sync(core_api.get_api_versions)
623
+ latency = (time.time() - start) * 1000
624
+
625
+ return ProviderHealth(
626
+ healthy=True,
627
+ message="Connected to Kubernetes API",
628
+ latency_ms=latency,
629
+ )
630
+
631
+ except KubernetesDepProviderError as e:
632
+ return ProviderHealth(
633
+ healthy=False,
634
+ message=str(e),
635
+ )
636
+ except Exception as e:
637
+ return ProviderHealth(
638
+ healthy=False,
639
+ message=f"Kubernetes connection failed: {e}",
640
+ )
641
+
642
+ async def get_service_attributes(self, service: str) -> dict:
643
+ """Get service attributes from Kubernetes labels and annotations."""
644
+ attributes: dict[str, Any] = {}
645
+
646
+ try:
647
+ core_api = self._get_core_api()
648
+
649
+ if self.namespace:
650
+ svc_list = await self._run_sync(
651
+ core_api.list_namespaced_service,
652
+ self.namespace,
653
+ field_selector=f"metadata.name={service}",
654
+ timeout_seconds=int(self.timeout),
655
+ )
656
+ else:
657
+ svc_list = await self._run_sync(
658
+ core_api.list_service_for_all_namespaces,
659
+ field_selector=f"metadata.name={service}",
660
+ timeout_seconds=int(self.timeout),
661
+ )
662
+
663
+ if svc_list.items:
664
+ svc = svc_list.items[0]
665
+ labels = svc.metadata.labels or {}
666
+ annotations = svc.metadata.annotations or {}
667
+
668
+ # Extract useful attributes
669
+ for key in ["team", "owner", "tier", "environment", "version"]:
670
+ for source in [labels, annotations]:
671
+ for prefix in ["", "app.kubernetes.io/", "nthlayer.io/"]:
672
+ full_key = f"{prefix}{key}"
673
+ if full_key in source:
674
+ attributes[key] = source[full_key]
675
+ break
676
+
677
+ attributes["namespace"] = svc.metadata.namespace
678
+
679
+ except Exception:
680
+ pass
681
+
682
+ return attributes