syncable_cli/agent/tools/
prometheus_connect.rs

1//! Prometheus Connect Tool
2//!
3//! Establishes a connection to Prometheus via port-forward (preferred) or direct URL.
4//! Used after prometheus_discover to set up the connection for k8s_optimize.
5//!
6//! # Connection Methods
7//!
8//! 1. **Port-forward (preferred)** - No authentication needed
9//!    - Connects directly to the pod, bypassing ingress/auth
10//!    - Works with any in-cluster Prometheus
11//!
12//! 2. **Direct URL** - May require authentication
13//!    - For externally exposed Prometheus
14//!    - Supports Basic auth and Bearer token
15
16use super::background::BackgroundProcessManager;
17use crate::agent::ui::prometheus_display::{ConnectionMode, PrometheusConnectionDisplay};
18use crate::analyzer::k8s_optimize::{PrometheusAuth, PrometheusClient};
19use rig::completion::ToolDefinition;
20use rig::tool::Tool;
21use serde::Deserialize;
22use serde_json::json;
23use std::sync::Arc;
24
25/// Arguments for the prometheus_connect tool
26#[derive(Debug, Deserialize)]
27pub struct PrometheusConnectArgs {
28    /// Service name from discovery (for port-forward)
29    #[serde(default)]
30    pub service: Option<String>,
31
32    /// Namespace (for port-forward)
33    #[serde(default)]
34    pub namespace: Option<String>,
35
36    /// External URL (alternative to service discovery)
37    #[serde(default)]
38    pub url: Option<String>,
39
40    /// Port (default: 9090)
41    #[serde(default)]
42    pub port: Option<u16>,
43
44    /// Authentication type: "none", "basic", "bearer" (only for external URL)
45    #[serde(default)]
46    pub auth_type: Option<String>,
47
48    /// Username for basic auth (only for external URL)
49    #[serde(default)]
50    pub username: Option<String>,
51
52    /// Password for basic auth (only for external URL)
53    #[serde(default)]
54    pub password: Option<String>,
55
56    /// Bearer token (only for external URL)
57    #[serde(default)]
58    pub token: Option<String>,
59}
60
61/// Error type for prometheus connection
62#[derive(Debug, thiserror::Error)]
63#[error("Prometheus connect error: {0}")]
64pub struct PrometheusConnectError(String);
65
66/// Tool for connecting to Prometheus
67#[derive(Clone)]
68pub struct PrometheusConnectTool {
69    bg_manager: Arc<BackgroundProcessManager>,
70}
71
72impl PrometheusConnectTool {
73    /// Create a new PrometheusConnectTool with shared background process manager
74    pub fn new(bg_manager: Arc<BackgroundProcessManager>) -> Self {
75        Self { bg_manager }
76    }
77
78    /// Build auth from args
79    fn build_auth(args: &PrometheusConnectArgs) -> PrometheusAuth {
80        match args.auth_type.as_deref() {
81            Some("basic") => {
82                if let (Some(username), Some(password)) = (&args.username, &args.password) {
83                    PrometheusAuth::Basic {
84                        username: username.clone(),
85                        password: password.clone(),
86                    }
87                } else {
88                    PrometheusAuth::None
89                }
90            }
91            Some("bearer") => {
92                if let Some(token) = &args.token {
93                    PrometheusAuth::Bearer(token.clone())
94                } else {
95                    PrometheusAuth::None
96                }
97            }
98            _ => PrometheusAuth::None,
99        }
100    }
101
102    /// Test if a Prometheus URL is reachable
103    async fn test_connection(url: &str, auth: PrometheusAuth) -> bool {
104        match PrometheusClient::with_auth(url, auth) {
105            Ok(client) => client.is_available().await,
106            Err(_) => false,
107        }
108    }
109}
110
111impl Tool for PrometheusConnectTool {
112    const NAME: &'static str = "prometheus_connect";
113
114    type Args = PrometheusConnectArgs;
115    type Output = String;
116    type Error = PrometheusConnectError;
117
118    async fn definition(&self, _prompt: String) -> ToolDefinition {
119        ToolDefinition {
120            name: Self::NAME.to_string(),
121            description: r#"Connect to Prometheus for K8s optimization analysis.
122
123**Use after prometheus_discover or when user provides a URL.**
124
125**Connection Methods (in order of preference):**
126
1271. **Port-forward** (recommended) - NO authentication needed
128   - Provide: service, namespace, port
129   - Starts kubectl port-forward in background
130   - Direct pod connection bypasses auth
131
1322. **External URL** - May require authentication
133   - Provide: url
134   - Optional: auth_type, username/password or token
135
136**Examples:**
137
138Port-forward (no auth):
139```json
140{"service": "prometheus-server", "namespace": "monitoring", "port": 9090}
141```
142
143External URL without auth:
144```json
145{"url": "http://prometheus.example.com"}
146```
147
148External URL with basic auth:
149```json
150{"url": "https://prometheus.example.com", "auth_type": "basic", "username": "admin", "password": "secret"}
151```
152
153**Returns:**
154- Connection URL for use with k8s_optimize
155- Connection mode (port-forward or direct)
156- Local port (if port-forward)"#
157                .to_string(),
158            parameters: json!({
159                "type": "object",
160                "properties": {
161                    "service": {
162                        "type": "string",
163                        "description": "Kubernetes service name (for port-forward)"
164                    },
165                    "namespace": {
166                        "type": "string",
167                        "description": "Kubernetes namespace (for port-forward)"
168                    },
169                    "url": {
170                        "type": "string",
171                        "description": "External Prometheus URL (alternative to port-forward)"
172                    },
173                    "port": {
174                        "type": "integer",
175                        "description": "Target port (default: 9090)"
176                    },
177                    "auth_type": {
178                        "type": "string",
179                        "description": "Authentication type for external URL: 'none', 'basic', 'bearer'",
180                        "enum": ["none", "basic", "bearer"]
181                    },
182                    "username": {
183                        "type": "string",
184                        "description": "Username for basic auth (only for external URL)"
185                    },
186                    "password": {
187                        "type": "string",
188                        "description": "Password for basic auth (only for external URL)"
189                    },
190                    "token": {
191                        "type": "string",
192                        "description": "Bearer token (only for external URL)"
193                    }
194                }
195            }),
196        }
197    }
198
199    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
200        let target_port = args.port.unwrap_or(9090);
201
202        // PREFERRED: Port-forward (no auth needed)
203        if let (Some(service), Some(namespace)) = (&args.service, &args.namespace) {
204            let resource = format!("svc/{}", service);
205            let display = PrometheusConnectionDisplay::new(ConnectionMode::PortForward);
206            let target = format!("{}/{}", namespace, service);
207            display.start(&target);
208
209            // Start port-forward in background
210            match self
211                .bg_manager
212                .start_port_forward("prometheus-port-forward", &resource, namespace, target_port)
213                .await
214            {
215                Ok(local_port) => {
216                    let url = format!("http://localhost:{}", local_port);
217                    display.port_forward_established(local_port, service, namespace);
218
219                    // Wait for port-forward to fully establish (tunnel needs time)
220                    tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
221                    display.testing_connection();
222
223                    // Test connection with retries (port-forward can take time to be ready)
224                    let mut connected = false;
225                    for attempt in 0..6 {
226                        if Self::test_connection(&url, PrometheusAuth::None).await {
227                            connected = true;
228                            break;
229                        }
230                        // Backoff: 1s, 1s, 2s, 2s, 3s
231                        let delay = match attempt {
232                            0 | 1 => 1000,
233                            2 | 3 => 2000,
234                            _ => 3000,
235                        };
236                        tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
237                    }
238
239                    if connected {
240                        display.connected(&url, false);
241                        display.background_process_info("prometheus-port-forward");
242                        display.ready_for_use(&url);
243
244                        let response = json!({
245                            "connected": true,
246                            "url": url,
247                            "mode": "port-forward",
248                            "local_port": local_port,
249                            "service": service,
250                            "namespace": namespace,
251                            "process_id": "prometheus-port-forward",
252                            "note": "Port-forward established. No authentication needed.",
253                            "usage": {
254                                "k8s_optimize": {
255                                    "prometheus": url
256                                }
257                            }
258                        });
259                        return Ok(serde_json::to_string_pretty(&response)
260                            .unwrap_or_else(|_| "{}".to_string()));
261                    } else {
262                        // Still can't connect - stop the failed port-forward
263                        let _ = self.bg_manager.stop("prometheus-port-forward").await;
264
265                        display.connection_failed(
266                            "Port-forward started but Prometheus not responding",
267                            &[
268                                "Verify the service is correct",
269                                "Check if Prometheus pod is running",
270                                "The service might need more time to start",
271                            ],
272                        );
273
274                        let response = json!({
275                            "connected": false,
276                            "url": url,
277                            "mode": "port-forward",
278                            "local_port": local_port,
279                            "error": "Port-forward started but Prometheus not responding",
280                            "suggestions": [
281                                format!("Verify the service is correct with: kubectl get svc -n {}", namespace),
282                                format!("Check if Prometheus pod is running: kubectl get pods -n {} | grep prometheus", namespace),
283                                "The service might need more time to start".to_string()
284                            ]
285                        });
286                        return Ok(serde_json::to_string_pretty(&response)
287                            .unwrap_or_else(|_| "{}".to_string()));
288                    }
289                }
290                Err(e) => {
291                    // Port-forward failed
292                    display.connection_failed(
293                        &format!("Port-forward failed: {}", e),
294                        &[
295                            "Check if kubectl is configured correctly",
296                            "Verify the service exists",
297                            "Try providing an external URL instead",
298                        ],
299                    );
300
301                    let response = json!({
302                        "connected": false,
303                        "mode": "port-forward",
304                        "error": format!("Port-forward failed: {}", e),
305                        "suggestions": [
306                            "Check if kubectl is configured correctly",
307                            format!("Verify the service exists: kubectl get svc -n {}", namespace),
308                            "Try providing an external URL instead"
309                        ]
310                    });
311                    return Ok(serde_json::to_string_pretty(&response)
312                        .unwrap_or_else(|_| "{}".to_string()));
313                }
314            }
315        }
316
317        // FALLBACK: External URL
318        if let Some(url) = &args.url {
319            let display = PrometheusConnectionDisplay::new(ConnectionMode::DirectUrl);
320            display.start(url);
321            display.testing_connection();
322
323            // First try without auth
324            if Self::test_connection(url, PrometheusAuth::None).await {
325                display.connected(url, false);
326                display.ready_for_use(url);
327
328                let response = json!({
329                    "connected": true,
330                    "url": url,
331                    "mode": "direct",
332                    "authenticated": false,
333                    "note": "Connected without authentication",
334                    "usage": {
335                        "k8s_optimize": {
336                            "prometheus": url
337                        }
338                    }
339                });
340                return Ok(
341                    serde_json::to_string_pretty(&response).unwrap_or_else(|_| "{}".to_string())
342                );
343            }
344
345            // If that fails and auth was provided, try with auth
346            let auth = Self::build_auth(&args);
347            if !matches!(auth, PrometheusAuth::None) {
348                if Self::test_connection(url, auth).await {
349                    display.connected(url, true);
350                    display.ready_for_use(url);
351
352                    let response = json!({
353                        "connected": true,
354                        "url": url,
355                        "mode": "direct",
356                        "authenticated": true,
357                        "auth_type": args.auth_type,
358                        "note": "Connected with authentication",
359                        "usage": {
360                            "k8s_optimize": {
361                                "prometheus": url,
362                                "auth_type": args.auth_type,
363                                "username": args.username,
364                                // Don't include password/token in response for security
365                            }
366                        }
367                    });
368                    return Ok(serde_json::to_string_pretty(&response)
369                        .unwrap_or_else(|_| "{}".to_string()));
370                }
371            }
372
373            // Connection failed - show auth hint if no auth was tried
374            if args.auth_type.is_none() {
375                display.auth_required();
376            }
377
378            display.connection_failed(
379                "Connection failed",
380                if args.auth_type.is_none() {
381                    &[
382                        "The URL might require authentication",
383                        "Try with auth_type='basic' or 'bearer'",
384                        "Verify the URL is correct and accessible",
385                    ]
386                } else {
387                    &[
388                        "Authentication credentials might be incorrect",
389                        "Verify the username/password or token",
390                        "Check if the auth_type matches what the server expects",
391                    ]
392                },
393            );
394
395            let response = json!({
396                "connected": false,
397                "url": url,
398                "mode": "direct",
399                "error": "Connection failed",
400                "suggestions": if args.auth_type.is_none() {
401                    vec![
402                        "The URL might require authentication",
403                        "Try with auth_type='basic' and username/password",
404                        "Or try auth_type='bearer' with a token",
405                        "Verify the URL is correct and accessible"
406                    ]
407                } else {
408                    vec![
409                        "Authentication credentials might be incorrect",
410                        "Verify the username/password or token",
411                        "Check if the auth_type matches what the server expects"
412                    ]
413                }
414            });
415            return Ok(serde_json::to_string_pretty(&response).unwrap_or_else(|_| "{}".to_string()));
416        }
417
418        // No service or URL provided
419        let response = json!({
420            "connected": false,
421            "error": "No service or URL provided",
422            "hint": "Either provide service+namespace for port-forward, or provide a URL",
423            "examples": [
424                {
425                    "port-forward": {
426                        "service": "prometheus-server",
427                        "namespace": "monitoring",
428                        "port": 9090
429                    }
430                },
431                {
432                    "external": {
433                        "url": "http://prometheus.example.com"
434                    }
435                }
436            ]
437        });
438        Ok(serde_json::to_string_pretty(&response).unwrap_or_else(|_| "{}".to_string()))
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn test_tool_name() {
448        assert_eq!(PrometheusConnectTool::NAME, "prometheus_connect");
449    }
450
451    #[test]
452    fn test_build_auth_none() {
453        let args = PrometheusConnectArgs {
454            service: None,
455            namespace: None,
456            url: Some("http://localhost".to_string()),
457            port: None,
458            auth_type: None,
459            username: None,
460            password: None,
461            token: None,
462        };
463
464        let auth = PrometheusConnectTool::build_auth(&args);
465        assert!(matches!(auth, PrometheusAuth::None));
466    }
467
468    #[test]
469    fn test_build_auth_basic() {
470        let args = PrometheusConnectArgs {
471            service: None,
472            namespace: None,
473            url: Some("http://localhost".to_string()),
474            port: None,
475            auth_type: Some("basic".to_string()),
476            username: Some("admin".to_string()),
477            password: Some("secret".to_string()),
478            token: None,
479        };
480
481        let auth = PrometheusConnectTool::build_auth(&args);
482        match auth {
483            PrometheusAuth::Basic { username, password } => {
484                assert_eq!(username, "admin");
485                assert_eq!(password, "secret");
486            }
487            _ => panic!("Expected Basic auth"),
488        }
489    }
490
491    #[test]
492    fn test_build_auth_bearer() {
493        let args = PrometheusConnectArgs {
494            service: None,
495            namespace: None,
496            url: Some("http://localhost".to_string()),
497            port: None,
498            auth_type: Some("bearer".to_string()),
499            username: None,
500            password: None,
501            token: Some("mytoken".to_string()),
502        };
503
504        let auth = PrometheusConnectTool::build_auth(&args);
505        match auth {
506            PrometheusAuth::Bearer(token) => {
507                assert_eq!(token, "mytoken");
508            }
509            _ => panic!("Expected Bearer auth"),
510        }
511    }
512}