agentic-ops 0.1.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 (71) hide show
  1. agentic_ops/adapters/cac_pull_collector.py +413 -0
  2. agentic_ops/adapters/datadog_adapter.py +33 -0
  3. agentic_ops/adapters/kubernetes_adapter.py +73 -0
  4. agentic_ops/adapters/llm_protocol.py +366 -0
  5. agentic_ops/adapters/llm_provider_registry.py +510 -0
  6. agentic_ops/adapters/protocol.py +19 -0
  7. agentic_ops/adapters/registry_adapter.py +70 -0
  8. agentic_ops/adapters/runbook_rag.py +21 -0
  9. agentic_ops/adapters/slack_adapter.py +78 -0
  10. agentic_ops/cli/compile_taxonomy.py +66 -0
  11. agentic_ops/cli/main.py +3192 -0
  12. agentic_ops/cli/trust_ledger.py +150 -0
  13. agentic_ops/core/__init__.py +1 -0
  14. agentic_ops/core/approvals.py +137 -0
  15. agentic_ops/core/approvers.py +32 -0
  16. agentic_ops/core/circuit_breaker.py +39 -0
  17. agentic_ops/core/contracts.py +103 -0
  18. agentic_ops/core/crypto.py +37 -0
  19. agentic_ops/core/distributed.py +29 -0
  20. agentic_ops/core/evidence.py +143 -0
  21. agentic_ops/core/executor.py +181 -0
  22. agentic_ops/core/facts.py +29 -0
  23. agentic_ops/core/governance_schemas.py +25 -0
  24. agentic_ops/core/idempotency.py +52 -0
  25. agentic_ops/core/infra_specialist.py +52 -0
  26. agentic_ops/core/model_specialist.py +51 -0
  27. agentic_ops/core/orchestrator.py +281 -0
  28. agentic_ops/core/policy.py +68 -0
  29. agentic_ops/core/policy_engine.py +166 -0
  30. agentic_ops/core/prompt_guard.py +16 -0
  31. agentic_ops/core/remediation_agent.py +49 -0
  32. agentic_ops/core/specialist.py +52 -0
  33. agentic_ops/core/state.py +20 -0
  34. agentic_ops/core/telemetry.py +47 -0
  35. agentic_ops/entrypoints/approval_sweeper.py +6 -0
  36. agentic_ops/entrypoints/grafana_webhook.py +61 -0
  37. agentic_ops/entrypoints/mcp_server.py +1149 -0
  38. agentic_ops/entrypoints/slack_verify.py +24 -0
  39. agentic_ops/entrypoints/slack_webhook.py +59 -0
  40. agentic_ops/evidence_store/EP-0001.json +64 -0
  41. agentic_ops/evidence_store/EP-0002.json +58 -0
  42. agentic_ops/evidence_store/EP-0003.json +58 -0
  43. agentic_ops/evidence_store/EP-0004.json +64 -0
  44. agentic_ops/evidence_store/EP-0005.json +65 -0
  45. agentic_ops/evidence_store/EP-CHAOS-31c6e006.json +27 -0
  46. agentic_ops/evidence_store/EP-CHAOS-5950095d.json +28 -0
  47. agentic_ops/evidence_store/EP-CHAOS-d6c18601.json +28 -0
  48. agentic_ops/evidence_store/_index.json +4 -0
  49. agentic_ops/governance/action_catalog.yaml +26 -0
  50. agentic_ops/governance/approvers.yaml +7 -0
  51. agentic_ops/governance/autonomy_policy.yaml +23 -0
  52. agentic_ops/governance/risk_register.yaml +6 -0
  53. agentic_ops/governance/shadow_policy.yaml +23 -0
  54. agentic_ops/knowledge_base/runbooks/DATA-042.md +8 -0
  55. agentic_ops/knowledge_base/runbooks/INFRA-001.md +8 -0
  56. agentic_ops/schemas/action.schema.json +13 -0
  57. agentic_ops/schemas/diagnosis.schema.json +11 -0
  58. agentic_ops/schemas/evidence.schema.json +12 -0
  59. agentic_ops/schemas/risk.schema.json +14 -0
  60. agentic_ops/tools/__init__.py +0 -0
  61. agentic_ops/tools/base.py +80 -0
  62. agentic_ops/tools/data.py +77 -0
  63. agentic_ops/tools/github.py +30 -0
  64. agentic_ops/tools/infra.py +51 -0
  65. agentic_ops/tools/model.py +29 -0
  66. agentic_ops/tools/observability.py +56 -0
  67. agentic_ops-0.1.0.dist-info/METADATA +1489 -0
  68. agentic_ops-0.1.0.dist-info/RECORD +71 -0
  69. agentic_ops-0.1.0.dist-info/WHEEL +4 -0
  70. agentic_ops-0.1.0.dist-info/entry_points.txt +4 -0
  71. agentic_ops-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,413 @@
1
+ """
2
+ CacPullCollector — Adaptateur de lecture seule vers la plateforme distante CAC MLOps.
3
+
4
+ Ce module implémente un collecteur en mode "pull" (lecture seule, GET uniquement)
5
+ qui interroge les services distants de la plateforme cac_mlops via le tunnel
6
+ Tailscale (proxy SOCKS5 local sur 127.0.0.1:1055).
7
+
8
+ Architecture :
9
+ - CacRemoteConfig : Configuration des endpoints et du proxy.
10
+ - RemoteServiceStatus : Enum décrivant l'état de disponibilité du serveur.
11
+ - CacCollectedSnapshot: Pydantic model agrégant les données récupérées.
12
+ - CacPullCollector : Client HTTP principal avec les 5 méthodes de collecte.
13
+
14
+ Règle d'or (Contrat d'interfaçage Read-Only) :
15
+ Lecture seule UNIQUEMENT. Aucun appel POST/PUT/DELETE vers l'infrastructure cible.
16
+ Ne jamais appeler : /predict, /loki/api/v1/push,
17
+ POST registered-models/alias, POST /api/deployments/.../create_flow_run.
18
+
19
+ Résilience :
20
+ Si le VPS scw-jovial-dubinsky (100.117.99.62) est hors-ligne, le collecteur
21
+ renvoie un CacCollectedSnapshot avec status=REMOTE_TARGET_OFFLINE sans lever
22
+ d'exception, ce qui permet à l'orchestrateur de continuer à fonctionner.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ from datetime import datetime, timezone
29
+ from enum import Enum
30
+ from typing import Any, Dict, List, Optional
31
+ from urllib.parse import quote
32
+
33
+ import httpx
34
+ from pydantic import BaseModel, Field
35
+
36
+ logger = logging.getLogger("agentic_ops.cac_pull_collector")
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Configuration
40
+ # ---------------------------------------------------------------------------
41
+
42
+ CAC_VPS_IP = "100.117.99.62"
43
+
44
+ # Adresses par service (plateforme cible CAC MLOps)
45
+ CAC_LOKI_BASE = f"http://{CAC_VPS_IP}:3100"
46
+ CAC_PROMETHEUS_BASE = f"http://{CAC_VPS_IP}:9090"
47
+ CAC_MLFLOW_BASE = f"http://{CAC_VPS_IP}:5001"
48
+ CAC_PREFECT_BASE = f"http://{CAC_VPS_IP}:4200"
49
+ CAC_DRIFT_REPORT_URL = "https://mlops.jakat-inc.fr/reports/drift/latest_summary.json"
50
+
51
+ # Proxy SOCKS5 du démon tailscaled en mode userspace-networking
52
+ TAILSCALE_SOCKS5_PROXY = "socks5://127.0.0.1:1055"
53
+
54
+ # Timeout global par appel (15-30s)
55
+ DEFAULT_TIMEOUT_S = 20.0
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Types de données
60
+ # ---------------------------------------------------------------------------
61
+
62
+ class RemoteServiceStatus(str, Enum):
63
+ """Statut de connectivité vers le VPS cible CAC MLOps."""
64
+ ONLINE = "ONLINE"
65
+ REMOTE_TARGET_OFFLINE = "REMOTE_TARGET_OFFLINE"
66
+ PARTIAL = "PARTIAL"
67
+ ERROR = "ERROR"
68
+
69
+
70
+ class LokiAlert(BaseModel):
71
+ """Représentation d'une alerte Grafana lue depuis Loki."""
72
+ timestamp: datetime
73
+ labels: Dict[str, str] = Field(default_factory=dict)
74
+ line: str
75
+
76
+
77
+ class PrometheusSnapshot(BaseModel):
78
+ """Métriques-clés extraites de Prometheus."""
79
+ drift_share: Optional[float] = None # cac_mlops_drift_share
80
+ model_in_production: Optional[str] = None # mlops_model_info{alias="Production"}
81
+ api_request_total: Optional[float] = None # http_requests_total
82
+ collected_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
83
+
84
+
85
+ class MlflowModelInfo(BaseModel):
86
+ """Informations du modèle actif récupéré depuis MLflow."""
87
+ name: Optional[str] = None
88
+ alias: Optional[str] = None
89
+ version: Optional[str] = None
90
+ run_id: Optional[str] = None
91
+ metrics: Dict[str, Any] = Field(default_factory=dict)
92
+ tags: Dict[str, str] = Field(default_factory=dict)
93
+ collected_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
94
+
95
+
96
+ class PrefectFlowRunSummary(BaseModel):
97
+ """Résumé des derniers flow runs lus depuis Prefect."""
98
+ id: str
99
+ name: Optional[str] = None
100
+ state_name: Optional[str] = None
101
+ state_type: Optional[str] = None
102
+ start_time: Optional[datetime] = None
103
+ end_time: Optional[datetime] = None
104
+
105
+
106
+ class DriftReportSnapshot(BaseModel):
107
+ """Rapport de dérive public Evidently."""
108
+ report_date: Optional[str] = None
109
+ drift_share: Optional[float] = None
110
+ drifted_columns: List[str] = Field(default_factory=list)
111
+ raw: Dict[str, Any] = Field(default_factory=dict)
112
+ collected_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
113
+
114
+
115
+ class CacCollectedSnapshot(BaseModel):
116
+ """
117
+ Agrégat complet d'une collecte vers la plateforme cible CAC MLOps.
118
+ Chaque champ est optionnel : si le service est indisponible, le champ
119
+ reste à None et status indique REMOTE_TARGET_OFFLINE ou PARTIAL.
120
+ """
121
+ status: RemoteServiceStatus
122
+ collected_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
123
+ errors: Dict[str, str] = Field(default_factory=dict)
124
+
125
+ loki_alerts: Optional[List[LokiAlert]] = None
126
+ prometheus: Optional[PrometheusSnapshot] = None
127
+ mlflow_model: Optional[MlflowModelInfo] = None
128
+ prefect_flow_runs: Optional[List[PrefectFlowRunSummary]] = None
129
+ drift_report: Optional[DriftReportSnapshot] = None
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Collecteur principal
134
+ # ---------------------------------------------------------------------------
135
+
136
+ class CacPullCollector:
137
+ """
138
+ Collecteur en lecture seule vers la plateforme distante CAC MLOps.
139
+
140
+ Utilise httpx avec un transport SOCKS5 pour que toutes les requêtes
141
+ vers 100.117.99.62 transitent par le tunnel Tailscale chiffré.
142
+
143
+ Usage minimal :
144
+ collector = CacPullCollector()
145
+ snapshot = collector.collect_all()
146
+ if snapshot.status == RemoteServiceStatus.ONLINE:
147
+ print(snapshot.prometheus.drift_share)
148
+ """
149
+
150
+ def __init__(
151
+ self,
152
+ socks5_proxy: str = TAILSCALE_SOCKS5_PROXY,
153
+ timeout: float = DEFAULT_TIMEOUT_S,
154
+ use_proxy_for_vps: bool = True,
155
+ ) -> None:
156
+ self._timeout = timeout
157
+ self._use_proxy = use_proxy_for_vps
158
+
159
+ # Transport SOCKS5 pour les requêtes vers le VPS (via Tailscale)
160
+ if use_proxy_for_vps:
161
+ self._proxy_transport = httpx.HTTPTransport(
162
+ proxy=httpx.Proxy(socks5_proxy)
163
+ )
164
+ else:
165
+ self._proxy_transport = None
166
+
167
+ # Transport direct pour les appels HTTPS publics (rapport de dérive)
168
+ self._direct_transport = httpx.HTTPTransport()
169
+
170
+ # -----------------------------------------------------------------------
171
+ # API publique
172
+ # -----------------------------------------------------------------------
173
+
174
+ def collect_all(self) -> CacCollectedSnapshot:
175
+ """
176
+ Lance la collecte sur les 5 sources et retourne un snapshot agrégé.
177
+ N'élève jamais d'exception : les erreurs sont loguées et consignées
178
+ dans snapshot.errors.
179
+ """
180
+ errors: Dict[str, str] = {}
181
+ collected: Dict[str, Any] = {}
182
+
183
+ sources = {
184
+ "loki_alerts": self._fetch_loki_alerts,
185
+ "prometheus": self._fetch_prometheus,
186
+ "mlflow_model": self._fetch_mlflow_model,
187
+ "prefect_flow_runs": self._fetch_prefect_flow_runs,
188
+ "drift_report": self._fetch_drift_report,
189
+ }
190
+
191
+ for key, fn in sources.items():
192
+ try:
193
+ collected[key] = fn()
194
+ except httpx.ConnectError as exc:
195
+ logger.warning("CacPullCollector | %s | ConnectError : %s", key, exc)
196
+ errors[key] = f"ConnectError: {exc}"
197
+ except httpx.TimeoutException as exc:
198
+ logger.warning("CacPullCollector | %s | Timeout après %.0fs", key, self._timeout)
199
+ errors[key] = f"Timeout: {exc}"
200
+ except Exception as exc:
201
+ logger.error("CacPullCollector | %s | Erreur inattendue : %s", key, exc)
202
+ errors[key] = f"Error: {exc}"
203
+
204
+ # Calcul du statut global
205
+ if not errors:
206
+ status = RemoteServiceStatus.ONLINE
207
+ elif len(errors) == len(sources):
208
+ status = RemoteServiceStatus.REMOTE_TARGET_OFFLINE
209
+ else:
210
+ status = RemoteServiceStatus.PARTIAL
211
+
212
+ return CacCollectedSnapshot(
213
+ status=status,
214
+ errors=errors,
215
+ loki_alerts=collected.get("loki_alerts"),
216
+ prometheus=collected.get("prometheus"),
217
+ mlflow_model=collected.get("mlflow_model"),
218
+ prefect_flow_runs=collected.get("prefect_flow_runs"),
219
+ drift_report=collected.get("drift_report"),
220
+ )
221
+
222
+ # -----------------------------------------------------------------------
223
+ # Méthodes de collecte par service
224
+ # -----------------------------------------------------------------------
225
+
226
+ def _fetch_loki_alerts(self) -> List[LokiAlert]:
227
+ """
228
+ Interroge Loki en LogQL pour récupérer les alertes MLOps structurées.
229
+ Requête : {service="prefect-worker"} |= "event=alert" sur les 6 dernières heures.
230
+ """
231
+ query = '{service="prefect-worker"} |= "event=alert"'
232
+ end_ns = int(datetime.now(timezone.utc).timestamp() * 1e9)
233
+ start_ns = end_ns - int(6 * 3600 * 1e9) # 6 heures
234
+
235
+ params = {
236
+ "query": query,
237
+ "start": str(start_ns),
238
+ "end": str(end_ns),
239
+ "limit": "100",
240
+ "direction": "backward",
241
+ }
242
+
243
+ with self._vps_client() as client:
244
+ resp = client.get(
245
+ f"{CAC_LOKI_BASE}/loki/api/v1/query_range",
246
+ params=params,
247
+ )
248
+ resp.raise_for_status()
249
+ data = resp.json()
250
+
251
+ alerts: List[LokiAlert] = []
252
+ for stream in data.get("data", {}).get("result", []):
253
+ labels = stream.get("stream", {})
254
+ for ts_ns_str, line in stream.get("values", []):
255
+ ts = datetime.fromtimestamp(int(ts_ns_str) / 1e9, tz=timezone.utc)
256
+ alerts.append(LokiAlert(timestamp=ts, labels=labels, line=line))
257
+
258
+ logger.info("CacPullCollector | Loki | %d alertes récupérées", len(alerts))
259
+ return alerts
260
+
261
+ def _fetch_prometheus(self) -> PrometheusSnapshot:
262
+ """
263
+ Interroge Prometheus pour les métriques-clés de la plateforme cible.
264
+ Métriques ciblées (lecture seule, aucune modification) :
265
+ - cac_mlops_drift_share : taux de colonnes en dérive
266
+ - mlops_model_info{alias="Production"} : modèle actuellement en prod
267
+ - http_requests_total : volume de trafic de l'API
268
+ """
269
+ def instant_query(metric_expr: str) -> Optional[Any]:
270
+ with self._vps_client() as client:
271
+ resp = client.get(
272
+ f"{CAC_PROMETHEUS_BASE}/api/v1/query",
273
+ params={"query": metric_expr},
274
+ )
275
+ resp.raise_for_status()
276
+ result = resp.json().get("data", {}).get("result", [])
277
+ if result:
278
+ return result[0].get("value", [None, None])[1]
279
+ return None
280
+
281
+ drift_share = instant_query("cac_mlops_drift_share")
282
+ model_in_prod_raw = instant_query('mlops_model_info{alias="Production"}')
283
+ api_requests = instant_query("http_requests_total")
284
+
285
+ snap = PrometheusSnapshot(
286
+ drift_share=float(drift_share) if drift_share is not None else None,
287
+ model_in_production=str(model_in_prod_raw) if model_in_prod_raw else None,
288
+ api_request_total=float(api_requests) if api_requests is not None else None,
289
+ )
290
+ logger.info("CacPullCollector | Prometheus | drift_share=%s", snap.drift_share)
291
+ return snap
292
+
293
+ def _fetch_mlflow_model(self) -> MlflowModelInfo:
294
+ """
295
+ Interroge MLflow pour récupérer les métadonnées du modèle actif.
296
+ Endpoint : GET /api/2.0/mlflow/registered-models/alias
297
+ Modèle ciblé : lgbm_accidents, alias : Production
298
+ INTERDIT : ne jamais appeler POST/DELETE sur cet endpoint.
299
+ """
300
+ with self._vps_client() as client:
301
+ # Récupération de l'alias Production
302
+ resp = client.get(
303
+ f"{CAC_MLFLOW_BASE}/api/2.0/mlflow/registered-models/alias",
304
+ params={"name": "lgbm_accidents", "alias": "Production"},
305
+ )
306
+ resp.raise_for_status()
307
+ mv = resp.json().get("model_version", {})
308
+
309
+ # Récupération des métriques du run associé (GET uniquement)
310
+ metrics: Dict[str, Any] = {}
311
+ run_id = mv.get("run_id")
312
+ if run_id:
313
+ with self._vps_client() as client:
314
+ run_resp = client.get(
315
+ f"{CAC_MLFLOW_BASE}/api/2.0/mlflow/runs/get",
316
+ params={"run_id": run_id},
317
+ )
318
+ if run_resp.status_code == 200:
319
+ run_data = run_resp.json().get("run", {})
320
+ for m in run_data.get("data", {}).get("metrics", []):
321
+ metrics[m["key"]] = m["value"]
322
+
323
+ info = MlflowModelInfo(
324
+ name=mv.get("name"),
325
+ alias="Production",
326
+ version=mv.get("version"),
327
+ run_id=run_id,
328
+ metrics=metrics,
329
+ tags={t["key"]: t["value"] for t in mv.get("tags", [])},
330
+ )
331
+ logger.info(
332
+ "CacPullCollector | MLflow | modèle=%s v%s run=%s",
333
+ info.name, info.version, info.run_id
334
+ )
335
+ return info
336
+
337
+ def _fetch_prefect_flow_runs(self) -> List[PrefectFlowRunSummary]:
338
+ """
339
+ Récupère les 10 derniers flow runs depuis Prefect (GET uniquement).
340
+ INTERDIT : POST /api/deployments/{id}/create_flow_run et set_state.
341
+ """
342
+ with self._vps_client() as client:
343
+ resp = client.get(
344
+ f"{CAC_PREFECT_BASE}/api/flow_runs",
345
+ params={"limit": 10, "sort": "START_TIME_DESC"},
346
+ )
347
+ resp.raise_for_status()
348
+ raw_runs = resp.json()
349
+
350
+ runs: List[PrefectFlowRunSummary] = []
351
+ for r in raw_runs:
352
+ runs.append(PrefectFlowRunSummary(
353
+ id=r.get("id", ""),
354
+ name=r.get("name"),
355
+ state_name=r.get("state_name"),
356
+ state_type=r.get("state_type"),
357
+ start_time=_parse_dt(r.get("start_time")),
358
+ end_time=_parse_dt(r.get("end_time")),
359
+ ))
360
+
361
+ logger.info("CacPullCollector | Prefect | %d flow runs récupérés", len(runs))
362
+ return runs
363
+
364
+ def _fetch_drift_report(self) -> DriftReportSnapshot:
365
+ """
366
+ Télécharge le rapport de dérive public Evidently (HTTPS, sans proxy Tailscale).
367
+ URL : https://mlops.jakat-inc.fr/reports/drift/latest_summary.json
368
+ Ce rapport est public et ne dépend pas du VPS Scaleway.
369
+ """
370
+ with httpx.Client(
371
+ transport=self._direct_transport,
372
+ timeout=self._timeout,
373
+ ) as client:
374
+ resp = client.get(CAC_DRIFT_REPORT_URL)
375
+ resp.raise_for_status()
376
+ raw = resp.json()
377
+
378
+ snap = DriftReportSnapshot(
379
+ report_date=raw.get("report_date") or raw.get("date"),
380
+ drift_share=raw.get("drift_share") or raw.get("share_of_drifted_columns"),
381
+ drifted_columns=raw.get("drifted_columns", []),
382
+ raw=raw,
383
+ )
384
+ logger.info(
385
+ "CacPullCollector | DriftReport | date=%s drift_share=%s",
386
+ snap.report_date, snap.drift_share
387
+ )
388
+ return snap
389
+
390
+ # -----------------------------------------------------------------------
391
+ # Helpers internes
392
+ # -----------------------------------------------------------------------
393
+
394
+ def _vps_client(self) -> httpx.Client:
395
+ """Retourne un client httpx configuré avec le proxy SOCKS5 Tailscale."""
396
+ return httpx.Client(
397
+ transport=self._proxy_transport,
398
+ timeout=self._timeout,
399
+ )
400
+
401
+
402
+ # ---------------------------------------------------------------------------
403
+ # Utilitaires
404
+ # ---------------------------------------------------------------------------
405
+
406
+ def _parse_dt(value: Optional[str]) -> Optional[datetime]:
407
+ """Parse une date ISO 8601 en datetime UTC. Retourne None si None ou invalide."""
408
+ if not value:
409
+ return None
410
+ try:
411
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
412
+ except (ValueError, AttributeError):
413
+ return None
@@ -0,0 +1,33 @@
1
+ import datetime
2
+ from agentic_ops.core.contracts import Signal
3
+ from agentic_ops.adapters.protocol import ObservabilityAdapter
4
+
5
+ class DatadogObservabilityAdapter(ObservabilityAdapter):
6
+ """
7
+ Adapter pour récupérer les alertes et les métriques via l'API Datadog.
8
+ Transforme un monitor alert ou un event Datadog en 'Signal' canonique.
9
+ """
10
+ def __init__(self, api_key: str, app_key: str):
11
+ self.api_key = api_key
12
+ self.app_key = app_key
13
+ # En production, on initialiserait le client Datadog ici :
14
+ # from datadog_api_client import ApiClient, Configuration
15
+ # ...
16
+
17
+ def get_signal(self, signal_id: str) -> Signal:
18
+ # Simulation d'un appel réseau vers GET /api/v1/events/{signal_id}
19
+ print(f"[DatadogAdapter] Fetching Datadog event: {signal_id}")
20
+
21
+ # Données factices renvoyées par l'API
22
+ return Signal(
23
+ signal_id=signal_id,
24
+ source="datadog_monitor",
25
+ observed_at=datetime.datetime.utcnow(),
26
+ metadata={
27
+ "monitor_name": "API High Latency",
28
+ "status": "Alert",
29
+ "metric_value": "850ms",
30
+ "threshold": "500ms",
31
+ "tags": ["env:production", "service:fastapi-serving"]
32
+ }
33
+ )
@@ -0,0 +1,73 @@
1
+ from agentic_ops.core.contracts import ActionProposal, ExecutionResult, ActionType
2
+ from agentic_ops.adapters.protocol import ExecutionAdapter
3
+ import logging
4
+
5
+ logger = logging.getLogger("KubernetesAdapter")
6
+
7
+ # [ALERTE MLOPS] White-List Stricte pour éviter l'Élévation de Privilèges (Audit Scenario 1)
8
+ ALLOWED_PATCH_PATHS = [
9
+ "/spec/template/metadata/annotations/kubectl.kubernetes.io~1restartedAt",
10
+ "/spec/replicas"
11
+ ]
12
+
13
+ class KubernetesExecutionAdapter(ExecutionAdapter):
14
+ """
15
+ Adapter Kubernetes avec validation stricte d'admission intégrée.
16
+ Bloque toute tentative du LLM de modifier l'identité (ServiceAccount)
17
+ ou de monter des volumes non autorisés.
18
+ """
19
+ def __init__(self, kubeconfig_path: str = "~/.kube/config", context: str = "default"):
20
+ self.kubeconfig_path = kubeconfig_path
21
+ self.context = context
22
+
23
+ def _validate_patch(self, action: ActionProposal):
24
+ """
25
+ Garantit que le LLM ne tente pas une attaque par escalade de privilèges.
26
+ Ex: patcher `serviceAccountName: cluster-admin`.
27
+ """
28
+ if "patch_body" in action.parameters:
29
+ patch = action.parameters["patch_body"]
30
+ # Simulation d'une vérification de JSON Patch
31
+ if isinstance(patch, list):
32
+ for op in patch:
33
+ if op.get("path") not in ALLOWED_PATCH_PATHS:
34
+ logger.critical(f"🚨 TENTATIVE D'ÉLÉVATION DE PRIVILÈGES : Accès au path {op.get('path')} refusé.")
35
+ raise PermissionError(f"Le chemin K8s {op.get('path')} est strictement interdit par sécurité.")
36
+
37
+ return True
38
+
39
+ def execute(self, action: ActionProposal) -> ExecutionResult:
40
+ print(f"[KubernetesAdapter] Executing action {action.type.value} for risk {action.risk_id}")
41
+
42
+ target_deployment = action.parameters.get("deployment_name", "fastapi-serving")
43
+ target_namespace = action.parameters.get("namespace", "mlops-prod")
44
+
45
+ try:
46
+ self._validate_patch(action)
47
+ except PermissionError as e:
48
+ return ExecutionResult(
49
+ status="FAILED",
50
+ executor="kubernetes_adapter",
51
+ details=str(e)
52
+ )
53
+
54
+ if action.type == ActionType.RESTART_POD:
55
+ details = f"Redémarrage ordonné (rolling update sécurisé via annotation) pour {target_deployment} dans {target_namespace}."
56
+ elif action.type == ActionType.SCALE_UP:
57
+ replicas = action.parameters.get("replicas", 3)
58
+ details = f"Mise à l'échelle de {target_deployment} à {replicas} replicas."
59
+ elif action.type == ActionType.ROLLBACK:
60
+ details = f"Rollback de {target_deployment} vers la version précédente."
61
+ else:
62
+ return ExecutionResult(
63
+ status="FAILED",
64
+ executor="kubernetes_adapter",
65
+ details=f"L'action {action.type.value} n'est pas supportée par Kubernetes."
66
+ )
67
+
68
+ return ExecutionResult(
69
+ status="SUCCESS",
70
+ executor="kubernetes_adapter",
71
+ result_ref=f"k8s://{target_namespace}/{target_deployment}",
72
+ details=details
73
+ )