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.
- agentic_ops/adapters/cac_pull_collector.py +413 -0
- agentic_ops/adapters/datadog_adapter.py +33 -0
- agentic_ops/adapters/kubernetes_adapter.py +73 -0
- agentic_ops/adapters/llm_protocol.py +366 -0
- agentic_ops/adapters/llm_provider_registry.py +510 -0
- agentic_ops/adapters/protocol.py +19 -0
- agentic_ops/adapters/registry_adapter.py +70 -0
- agentic_ops/adapters/runbook_rag.py +21 -0
- agentic_ops/adapters/slack_adapter.py +78 -0
- agentic_ops/cli/compile_taxonomy.py +66 -0
- agentic_ops/cli/main.py +3192 -0
- agentic_ops/cli/trust_ledger.py +150 -0
- agentic_ops/core/__init__.py +1 -0
- agentic_ops/core/approvals.py +137 -0
- agentic_ops/core/approvers.py +32 -0
- agentic_ops/core/circuit_breaker.py +39 -0
- agentic_ops/core/contracts.py +103 -0
- agentic_ops/core/crypto.py +37 -0
- agentic_ops/core/distributed.py +29 -0
- agentic_ops/core/evidence.py +143 -0
- agentic_ops/core/executor.py +181 -0
- agentic_ops/core/facts.py +29 -0
- agentic_ops/core/governance_schemas.py +25 -0
- agentic_ops/core/idempotency.py +52 -0
- agentic_ops/core/infra_specialist.py +52 -0
- agentic_ops/core/model_specialist.py +51 -0
- agentic_ops/core/orchestrator.py +281 -0
- agentic_ops/core/policy.py +68 -0
- agentic_ops/core/policy_engine.py +166 -0
- agentic_ops/core/prompt_guard.py +16 -0
- agentic_ops/core/remediation_agent.py +49 -0
- agentic_ops/core/specialist.py +52 -0
- agentic_ops/core/state.py +20 -0
- agentic_ops/core/telemetry.py +47 -0
- agentic_ops/entrypoints/approval_sweeper.py +6 -0
- agentic_ops/entrypoints/grafana_webhook.py +61 -0
- agentic_ops/entrypoints/mcp_server.py +1149 -0
- agentic_ops/entrypoints/slack_verify.py +24 -0
- agentic_ops/entrypoints/slack_webhook.py +59 -0
- agentic_ops/evidence_store/EP-0001.json +64 -0
- agentic_ops/evidence_store/EP-0002.json +58 -0
- agentic_ops/evidence_store/EP-0003.json +58 -0
- agentic_ops/evidence_store/EP-0004.json +64 -0
- agentic_ops/evidence_store/EP-0005.json +65 -0
- agentic_ops/evidence_store/EP-CHAOS-31c6e006.json +27 -0
- agentic_ops/evidence_store/EP-CHAOS-5950095d.json +28 -0
- agentic_ops/evidence_store/EP-CHAOS-d6c18601.json +28 -0
- agentic_ops/evidence_store/_index.json +4 -0
- agentic_ops/governance/action_catalog.yaml +26 -0
- agentic_ops/governance/approvers.yaml +7 -0
- agentic_ops/governance/autonomy_policy.yaml +23 -0
- agentic_ops/governance/risk_register.yaml +6 -0
- agentic_ops/governance/shadow_policy.yaml +23 -0
- agentic_ops/knowledge_base/runbooks/DATA-042.md +8 -0
- agentic_ops/knowledge_base/runbooks/INFRA-001.md +8 -0
- agentic_ops/schemas/action.schema.json +13 -0
- agentic_ops/schemas/diagnosis.schema.json +11 -0
- agentic_ops/schemas/evidence.schema.json +12 -0
- agentic_ops/schemas/risk.schema.json +14 -0
- agentic_ops/tools/__init__.py +0 -0
- agentic_ops/tools/base.py +80 -0
- agentic_ops/tools/data.py +77 -0
- agentic_ops/tools/github.py +30 -0
- agentic_ops/tools/infra.py +51 -0
- agentic_ops/tools/model.py +29 -0
- agentic_ops/tools/observability.py +56 -0
- agentic_ops-0.1.0.dist-info/METADATA +1489 -0
- agentic_ops-0.1.0.dist-info/RECORD +71 -0
- agentic_ops-0.1.0.dist-info/WHEEL +4 -0
- agentic_ops-0.1.0.dist-info/entry_points.txt +4 -0
- 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
|
+
)
|