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.
- nthlayer_workers/__init__.py +5 -0
- nthlayer_workers/cli.py +234 -0
- nthlayer_workers/correlate/__init__.py +1 -0
- nthlayer_workers/correlate/cli.py +847 -0
- nthlayer_workers/correlate/config.py +111 -0
- nthlayer_workers/correlate/correlation/__init__.py +1 -0
- nthlayer_workers/correlate/correlation/changes.py +87 -0
- nthlayer_workers/correlate/correlation/dedup.py +62 -0
- nthlayer_workers/correlate/correlation/engine.py +244 -0
- nthlayer_workers/correlate/correlation/temporal.py +79 -0
- nthlayer_workers/correlate/correlation/topology.py +104 -0
- nthlayer_workers/correlate/ingestion/__init__.py +1 -0
- nthlayer_workers/correlate/ingestion/protocol.py +10 -0
- nthlayer_workers/correlate/ingestion/severity.py +18 -0
- nthlayer_workers/correlate/ingestion/webhook.py +197 -0
- nthlayer_workers/correlate/notifications.py +85 -0
- nthlayer_workers/correlate/prometheus.py +234 -0
- nthlayer_workers/correlate/reasoning.py +375 -0
- nthlayer_workers/correlate/session.py +189 -0
- nthlayer_workers/correlate/snapshot/__init__.py +1 -0
- nthlayer_workers/correlate/snapshot/generator.py +170 -0
- nthlayer_workers/correlate/snapshot/model.py +177 -0
- nthlayer_workers/correlate/snapshot/token.py +14 -0
- nthlayer_workers/correlate/state.py +88 -0
- nthlayer_workers/correlate/store/__init__.py +5 -0
- nthlayer_workers/correlate/store/protocol.py +48 -0
- nthlayer_workers/correlate/store/sqlite.py +443 -0
- nthlayer_workers/correlate/summary.py +180 -0
- nthlayer_workers/correlate/traces/__init__.py +1 -0
- nthlayer_workers/correlate/traces/protocol.py +120 -0
- nthlayer_workers/correlate/traces/tempo.py +667 -0
- nthlayer_workers/correlate/traces/topology.py +39 -0
- nthlayer_workers/correlate/types.py +77 -0
- nthlayer_workers/correlate/worker.py +630 -0
- nthlayer_workers/learn/__init__.py +5 -0
- nthlayer_workers/learn/__main__.py +5 -0
- nthlayer_workers/learn/cli.py +164 -0
- nthlayer_workers/learn/retrospective.py +381 -0
- nthlayer_workers/learn/trends.py +102 -0
- nthlayer_workers/learn/worker.py +366 -0
- nthlayer_workers/measure/__init__.py +3 -0
- nthlayer_workers/measure/__main__.py +5 -0
- nthlayer_workers/measure/_parsing.py +15 -0
- nthlayer_workers/measure/adapters/__init__.py +0 -0
- nthlayer_workers/measure/adapters/_util.py +24 -0
- nthlayer_workers/measure/adapters/devin.py +119 -0
- nthlayer_workers/measure/adapters/gastown.py +88 -0
- nthlayer_workers/measure/adapters/prometheus.py +277 -0
- nthlayer_workers/measure/adapters/protocol.py +20 -0
- nthlayer_workers/measure/adapters/webhook.py +161 -0
- nthlayer_workers/measure/api/__init__.py +0 -0
- nthlayer_workers/measure/api/normalise.py +50 -0
- nthlayer_workers/measure/api/queue.py +243 -0
- nthlayer_workers/measure/api/response.py +51 -0
- nthlayer_workers/measure/api/server.py +504 -0
- nthlayer_workers/measure/calibration/__init__.py +0 -0
- nthlayer_workers/measure/calibration/loop.py +62 -0
- nthlayer_workers/measure/calibration/slos.py +212 -0
- nthlayer_workers/measure/calibration/verdict_calibration.py +31 -0
- nthlayer_workers/measure/cli.py +753 -0
- nthlayer_workers/measure/config.py +191 -0
- nthlayer_workers/measure/detection/__init__.py +6 -0
- nthlayer_workers/measure/detection/detector.py +82 -0
- nthlayer_workers/measure/detection/protocol.py +29 -0
- nthlayer_workers/measure/governance/__init__.py +0 -0
- nthlayer_workers/measure/governance/engine.py +163 -0
- nthlayer_workers/measure/manifest.py +77 -0
- nthlayer_workers/measure/notifications.py +53 -0
- nthlayer_workers/measure/pipeline/__init__.py +0 -0
- nthlayer_workers/measure/pipeline/evaluator.py +155 -0
- nthlayer_workers/measure/pipeline/router.py +160 -0
- nthlayer_workers/measure/store/__init__.py +0 -0
- nthlayer_workers/measure/store/protocol.py +38 -0
- nthlayer_workers/measure/store/sqlite.py +276 -0
- nthlayer_workers/measure/telemetry.py +116 -0
- nthlayer_workers/measure/tiering/__init__.py +0 -0
- nthlayer_workers/measure/tiering/classifier.py +58 -0
- nthlayer_workers/measure/tiering/promotion.py +118 -0
- nthlayer_workers/measure/trends/__init__.py +0 -0
- nthlayer_workers/measure/trends/tracker.py +72 -0
- nthlayer_workers/measure/types.py +75 -0
- nthlayer_workers/measure/worker.py +439 -0
- nthlayer_workers/observe/__init__.py +25 -0
- nthlayer_workers/observe/__main__.py +5 -0
- nthlayer_workers/observe/api/__init__.py +1 -0
- nthlayer_workers/observe/assessment.py +95 -0
- nthlayer_workers/observe/cli.py +737 -0
- nthlayer_workers/observe/config.py +11 -0
- nthlayer_workers/observe/db/__init__.py +1 -0
- nthlayer_workers/observe/decision_records.py +220 -0
- nthlayer_workers/observe/dependencies/__init__.py +18 -0
- nthlayer_workers/observe/dependencies/discovery.py +294 -0
- nthlayer_workers/observe/dependencies/providers/__init__.py +48 -0
- nthlayer_workers/observe/dependencies/providers/backstage.py +467 -0
- nthlayer_workers/observe/dependencies/providers/base.py +76 -0
- nthlayer_workers/observe/dependencies/providers/consul.py +518 -0
- nthlayer_workers/observe/dependencies/providers/etcd.py +360 -0
- nthlayer_workers/observe/dependencies/providers/kubernetes.py +682 -0
- nthlayer_workers/observe/dependencies/providers/prometheus.py +368 -0
- nthlayer_workers/observe/dependencies/providers/zookeeper.py +399 -0
- nthlayer_workers/observe/deployments/__init__.py +1 -0
- nthlayer_workers/observe/discovery/__init__.py +14 -0
- nthlayer_workers/observe/discovery/classifier.py +66 -0
- nthlayer_workers/observe/discovery/client.py +189 -0
- nthlayer_workers/observe/discovery/models.py +53 -0
- nthlayer_workers/observe/drift/__init__.py +26 -0
- nthlayer_workers/observe/drift/analyzer.py +383 -0
- nthlayer_workers/observe/drift/models.py +174 -0
- nthlayer_workers/observe/drift/patterns.py +88 -0
- nthlayer_workers/observe/explanation.py +118 -0
- nthlayer_workers/observe/gate/__init__.py +39 -0
- nthlayer_workers/observe/gate/conditions.py +92 -0
- nthlayer_workers/observe/gate/correlator.py +154 -0
- nthlayer_workers/observe/gate/evaluator.py +192 -0
- nthlayer_workers/observe/gate/policies.py +226 -0
- nthlayer_workers/observe/gate_adapter.py +40 -0
- nthlayer_workers/observe/incident.py +36 -0
- nthlayer_workers/observe/portfolio/__init__.py +17 -0
- nthlayer_workers/observe/portfolio/aggregator.py +168 -0
- nthlayer_workers/observe/portfolio/scorer.py +13 -0
- nthlayer_workers/observe/slo/__init__.py +19 -0
- nthlayer_workers/observe/slo/collector.py +235 -0
- nthlayer_workers/observe/slo/spec_loader.py +40 -0
- nthlayer_workers/observe/sqlite_store.py +152 -0
- nthlayer_workers/observe/store.py +92 -0
- nthlayer_workers/observe/verification/__init__.py +22 -0
- nthlayer_workers/observe/verification/exporter_guidance.py +146 -0
- nthlayer_workers/observe/verification/extractor.py +127 -0
- nthlayer_workers/observe/verification/models.py +101 -0
- nthlayer_workers/observe/verification/verifier.py +111 -0
- nthlayer_workers/observe/worker.py +332 -0
- nthlayer_workers/respond/__init__.py +2 -0
- nthlayer_workers/respond/__main__.py +4 -0
- nthlayer_workers/respond/agents/__init__.py +0 -0
- nthlayer_workers/respond/agents/base.py +556 -0
- nthlayer_workers/respond/agents/communication.py +115 -0
- nthlayer_workers/respond/agents/investigation.py +124 -0
- nthlayer_workers/respond/agents/remediation.py +219 -0
- nthlayer_workers/respond/agents/triage.py +132 -0
- nthlayer_workers/respond/cli.py +772 -0
- nthlayer_workers/respond/config.py +135 -0
- nthlayer_workers/respond/context_store.py +256 -0
- nthlayer_workers/respond/coordinator.py +487 -0
- nthlayer_workers/respond/metrics.py +104 -0
- nthlayer_workers/respond/notification_backends/__init__.py +1 -0
- nthlayer_workers/respond/notification_backends/ntfy_backend.py +158 -0
- nthlayer_workers/respond/notification_backends/protocol.py +59 -0
- nthlayer_workers/respond/notification_backends/slack_backend.py +203 -0
- nthlayer_workers/respond/notification_backends/stdout_backend.py +56 -0
- nthlayer_workers/respond/notifications.py +247 -0
- nthlayer_workers/respond/oncall/__init__.py +1 -0
- nthlayer_workers/respond/oncall/escalation.py +103 -0
- nthlayer_workers/respond/oncall/runner.py +193 -0
- nthlayer_workers/respond/oncall/schedule.py +243 -0
- nthlayer_workers/respond/safe_actions/__init__.py +0 -0
- nthlayer_workers/respond/safe_actions/actions.py +139 -0
- nthlayer_workers/respond/safe_actions/registry.py +171 -0
- nthlayer_workers/respond/safe_actions/webhook.py +194 -0
- nthlayer_workers/respond/server.py +357 -0
- nthlayer_workers/respond/sre/__init__.py +1 -0
- nthlayer_workers/respond/sre/brief.py +175 -0
- nthlayer_workers/respond/sre/delegation.py +101 -0
- nthlayer_workers/respond/sre/post_incident.py +146 -0
- nthlayer_workers/respond/sre/shift_report.py +129 -0
- nthlayer_workers/respond/sre/suppression.py +91 -0
- nthlayer_workers/respond/types.py +109 -0
- nthlayer_workers/respond/verdict_submission.py +56 -0
- nthlayer_workers/respond/worker.py +533 -0
- nthlayer_workers/respond/worker_helpers.py +140 -0
- nthlayer_workers/runner.py +198 -0
- nthlayer_workers-1.0.0.dist-info/METADATA +19 -0
- nthlayer_workers-1.0.0.dist-info/RECORD +175 -0
- nthlayer_workers-1.0.0.dist-info/WHEEL +5 -0
- nthlayer_workers-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|