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 super::error::{ErrorCategory, format_error_for_llm};
18use crate::agent::ui::prometheus_display::{ConnectionMode, PrometheusConnectionDisplay};
19use crate::analyzer::k8s_optimize::{PrometheusAuth, PrometheusClient};
20use rig::completion::ToolDefinition;
21use rig::tool::Tool;
22use serde::Deserialize;
23use serde_json::json;
24use std::sync::Arc;
25
26/// Arguments for the prometheus_connect tool
27#[derive(Debug, Deserialize)]
28pub struct PrometheusConnectArgs {
29    /// Service name from discovery (for port-forward)
30    #[serde(default)]
31    pub service: Option<String>,
32
33    /// Namespace (for port-forward)
34    #[serde(default)]
35    pub namespace: Option<String>,
36
37    /// External URL (alternative to service discovery)
38    #[serde(default)]
39    pub url: Option<String>,
40
41    /// Port (default: 9090)
42    #[serde(default)]
43    pub port: Option<u16>,
44
45    /// Authentication type: "none", "basic", "bearer" (only for external URL)
46    #[serde(default)]
47    pub auth_type: Option<String>,
48
49    /// Username for basic auth (only for external URL)
50    #[serde(default)]
51    pub username: Option<String>,
52
53    /// Password for basic auth (only for external URL)
54    #[serde(default)]
55    pub password: Option<String>,
56
57    /// Bearer token (only for external URL)
58    #[serde(default)]
59    pub token: Option<String>,
60}
61
62/// Error type for prometheus connection
63#[derive(Debug, thiserror::Error)]
64#[error("Prometheus connect error: {0}")]
65pub struct PrometheusConnectError(String);
66
67/// Tool for connecting to Prometheus
68#[derive(Clone)]
69pub struct PrometheusConnectTool {
70    bg_manager: Arc<BackgroundProcessManager>,
71}
72
73impl PrometheusConnectTool {
74    /// Create a new PrometheusConnectTool with shared background process manager
75    pub fn new(bg_manager: Arc<BackgroundProcessManager>) -> Self {
76        Self { bg_manager }
77    }
78
79    /// Validate port range (1-65535)
80    fn validate_port(port: u16) -> Result<(), String> {
81        if port == 0 {
82            return Err("Port must be between 1 and 65535 (got 0)".to_string());
83        }
84        Ok(())
85    }
86
87    /// Validate URL format (must start with http:// or https://)
88    fn validate_url(url: &str) -> Result<(), String> {
89        let url_lower = url.to_lowercase();
90        if !url_lower.starts_with("http://") && !url_lower.starts_with("https://") {
91            return Err(format!(
92                "URL must start with http:// or https:// (got '{}')",
93                url
94            ));
95        }
96        Ok(())
97    }
98
99    /// Build auth from args
100    fn build_auth(args: &PrometheusConnectArgs) -> PrometheusAuth {
101        match args.auth_type.as_deref() {
102            Some("basic") => {
103                if let (Some(username), Some(password)) = (&args.username, &args.password) {
104                    PrometheusAuth::Basic {
105                        username: username.clone(),
106                        password: password.clone(),
107                    }
108                } else {
109                    PrometheusAuth::None
110                }
111            }
112            Some("bearer") => {
113                if let Some(token) = &args.token {
114                    PrometheusAuth::Bearer(token.clone())
115                } else {
116                    PrometheusAuth::None
117                }
118            }
119            _ => PrometheusAuth::None,
120        }
121    }
122
123    /// Test if a Prometheus URL is reachable
124    async fn test_connection(url: &str, auth: PrometheusAuth) -> bool {
125        match PrometheusClient::with_auth(url, auth) {
126            Ok(client) => client.is_available().await,
127            Err(_) => false,
128        }
129    }
130}
131
132impl Tool for PrometheusConnectTool {
133    const NAME: &'static str = "prometheus_connect";
134
135    type Args = PrometheusConnectArgs;
136    type Output = String;
137    type Error = PrometheusConnectError;
138
139    async fn definition(&self, _prompt: String) -> ToolDefinition {
140        ToolDefinition {
141            name: Self::NAME.to_string(),
142            description: r#"Connect to Prometheus for K8s optimization analysis.
143
144**Use after prometheus_discover or when user provides a URL.**
145
146**Connection Methods (in order of preference):**
147
1481. **Port-forward** (recommended) - NO authentication needed
149   - Provide: service, namespace, port
150   - Starts kubectl port-forward in background
151   - Direct pod connection bypasses auth
152
1532. **External URL** - May require authentication
154   - Provide: url
155   - Optional: auth_type, username/password or token
156
157**Examples:**
158
159Port-forward (no auth):
160```json
161{"service": "prometheus-server", "namespace": "monitoring", "port": 9090}
162```
163
164External URL without auth:
165```json
166{"url": "http://prometheus.example.com"}
167```
168
169External URL with basic auth:
170```json
171{"url": "https://prometheus.example.com", "auth_type": "basic", "username": "admin", "password": "secret"}
172```
173
174**Returns:**
175- Connection URL for use with k8s_optimize
176- Connection mode (port-forward or direct)
177- Local port (if port-forward)"#
178                .to_string(),
179            parameters: json!({
180                "type": "object",
181                "properties": {
182                    "service": {
183                        "type": "string",
184                        "description": "Kubernetes service name (for port-forward)"
185                    },
186                    "namespace": {
187                        "type": "string",
188                        "description": "Kubernetes namespace (for port-forward)"
189                    },
190                    "url": {
191                        "type": "string",
192                        "description": "External Prometheus URL (alternative to port-forward)"
193                    },
194                    "port": {
195                        "type": "integer",
196                        "description": "Target port (default: 9090)"
197                    },
198                    "auth_type": {
199                        "type": "string",
200                        "description": "Authentication type for external URL: 'none', 'basic', 'bearer'",
201                        "enum": ["none", "basic", "bearer"]
202                    },
203                    "username": {
204                        "type": "string",
205                        "description": "Username for basic auth (only for external URL)"
206                    },
207                    "password": {
208                        "type": "string",
209                        "description": "Password for basic auth (only for external URL)"
210                    },
211                    "token": {
212                        "type": "string",
213                        "description": "Bearer token (only for external URL)"
214                    }
215                }
216            }),
217        }
218    }
219
220    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
221        // Validate port if provided
222        if let Some(port) = args.port {
223            if let Err(e) = Self::validate_port(port) {
224                return Ok(format_error_for_llm(
225                    "prometheus_connect",
226                    ErrorCategory::ValidationFailed,
227                    &e,
228                    Some(vec![
229                        "Port must be a valid TCP port between 1 and 65535",
230                        "Common Prometheus port is 9090 (default if not specified)",
231                    ]),
232                ));
233            }
234        }
235
236        // Validate URL format if provided
237        if let Some(ref url) = args.url {
238            if let Err(e) = Self::validate_url(url) {
239                return Ok(format_error_for_llm(
240                    "prometheus_connect",
241                    ErrorCategory::ValidationFailed,
242                    &e,
243                    Some(vec![
244                        "URL must start with http:// or https://",
245                        "Example: http://prometheus.example.com or https://prometheus.example.com",
246                    ]),
247                ));
248            }
249        }
250
251        let target_port = args.port.unwrap_or(9090);
252
253        // PREFERRED: Port-forward (no auth needed)
254        if let (Some(service), Some(namespace)) = (&args.service, &args.namespace) {
255            let resource = format!("svc/{}", service);
256            let display = PrometheusConnectionDisplay::new(ConnectionMode::PortForward);
257            let target = format!("{}/{}", namespace, service);
258            display.start(&target);
259
260            // Start port-forward in background
261            match self
262                .bg_manager
263                .start_port_forward("prometheus-port-forward", &resource, namespace, target_port)
264                .await
265            {
266                Ok(local_port) => {
267                    let url = format!("http://localhost:{}", local_port);
268                    display.port_forward_established(local_port, service, namespace);
269
270                    // Wait for port-forward to fully establish (tunnel needs time)
271                    tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
272                    display.testing_connection();
273
274                    // Test connection with retries (port-forward can take time to be ready)
275                    let mut connected = false;
276                    for attempt in 0..6 {
277                        if Self::test_connection(&url, PrometheusAuth::None).await {
278                            connected = true;
279                            break;
280                        }
281                        // Backoff: 1s, 1s, 2s, 2s, 3s
282                        let delay = match attempt {
283                            0 | 1 => 1000,
284                            2 | 3 => 2000,
285                            _ => 3000,
286                        };
287                        tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
288                    }
289
290                    if connected {
291                        display.connected(&url, false);
292                        display.background_process_info("prometheus-port-forward");
293                        display.ready_for_use(&url);
294
295                        let response = json!({
296                            "connected": true,
297                            "url": url,
298                            "mode": "port-forward",
299                            "local_port": local_port,
300                            "service": service,
301                            "namespace": namespace,
302                            "process_id": "prometheus-port-forward",
303                            "note": "Port-forward established. No authentication needed.",
304                            "usage": {
305                                "k8s_optimize": {
306                                    "prometheus": url
307                                }
308                            }
309                        });
310                        return Ok(serde_json::to_string_pretty(&response)
311                            .unwrap_or_else(|_| "{}".to_string()));
312                    } else {
313                        // Still can't connect - stop the failed port-forward
314                        let _ = self.bg_manager.stop("prometheus-port-forward").await;
315
316                        display.connection_failed(
317                            "Port-forward started but Prometheus not responding",
318                            &[
319                                "Verify the service is correct",
320                                "Check if Prometheus pod is running",
321                                "The service might need more time to start",
322                            ],
323                        );
324
325                        return Ok(format_error_for_llm(
326                            "prometheus_connect",
327                            ErrorCategory::NetworkError,
328                            "Port-forward started but Prometheus not responding",
329                            Some(vec![
330                                &format!(
331                                    "Verify the service is correct: kubectl get svc -n {}",
332                                    namespace
333                                ),
334                                &format!(
335                                    "Check if Prometheus pod is running: kubectl get pods -n {} | grep prometheus",
336                                    namespace
337                                ),
338                                "The service might need more time to start - try again in a few seconds",
339                            ]),
340                        ));
341                    }
342                }
343                Err(e) => {
344                    // Port-forward failed
345                    display.connection_failed(
346                        &format!("Port-forward failed: {}", e),
347                        &[
348                            "Check if kubectl is configured correctly",
349                            "Verify the service exists",
350                            "Try providing an external URL instead",
351                        ],
352                    );
353
354                    return Ok(format_error_for_llm(
355                        "prometheus_connect",
356                        ErrorCategory::ExternalCommandFailed,
357                        &format!("Port-forward failed: {}", e),
358                        Some(vec![
359                            "Check if kubectl is configured correctly: kubectl config current-context",
360                            &format!(
361                                "Verify the service exists: kubectl get svc -n {}",
362                                namespace
363                            ),
364                            "Try providing an external URL instead",
365                        ]),
366                    ));
367                }
368            }
369        }
370
371        // FALLBACK: External URL
372        if let Some(url) = &args.url {
373            let display = PrometheusConnectionDisplay::new(ConnectionMode::DirectUrl);
374            display.start(url);
375            display.testing_connection();
376
377            // First try without auth
378            if Self::test_connection(url, PrometheusAuth::None).await {
379                display.connected(url, false);
380                display.ready_for_use(url);
381
382                let response = json!({
383                    "connected": true,
384                    "url": url,
385                    "mode": "direct",
386                    "authenticated": false,
387                    "note": "Connected without authentication",
388                    "usage": {
389                        "k8s_optimize": {
390                            "prometheus": url
391                        }
392                    }
393                });
394                return Ok(
395                    serde_json::to_string_pretty(&response).unwrap_or_else(|_| "{}".to_string())
396                );
397            }
398
399            // If that fails and auth was provided, try with auth
400            let auth = Self::build_auth(&args);
401            if !matches!(auth, PrometheusAuth::None) && Self::test_connection(url, auth).await {
402                display.connected(url, true);
403                display.ready_for_use(url);
404
405                let response = json!({
406                    "connected": true,
407                    "url": url,
408                    "mode": "direct",
409                    "authenticated": true,
410                    "auth_type": args.auth_type,
411                    "note": "Connected with authentication",
412                    "usage": {
413                        "k8s_optimize": {
414                            "prometheus": url,
415                            "auth_type": args.auth_type,
416                            "username": args.username,
417                            // Don't include password/token in response for security
418                        }
419                    }
420                });
421                return Ok(
422                    serde_json::to_string_pretty(&response).unwrap_or_else(|_| "{}".to_string())
423                );
424            }
425
426            // Connection failed - show auth hint if no auth was tried
427            if args.auth_type.is_none() {
428                display.auth_required();
429
430                display.connection_failed(
431                    "Connection failed - URL may require authentication",
432                    &[
433                        "Try with auth_type='basic' and username/password",
434                        "Or try auth_type='bearer' with a token",
435                        "Verify the URL is correct and accessible",
436                    ],
437                );
438
439                let test_url_suggestion =
440                    format!("Test URL manually: curl -s {}/api/v1/status/config", url);
441                return Ok(format_error_for_llm(
442                    "prometheus_connect",
443                    ErrorCategory::NetworkError,
444                    "Connection failed - URL may require authentication",
445                    Some(vec![
446                        "Try with auth_type='basic' and username/password",
447                        "Or try auth_type='bearer' with a token",
448                        "Verify the URL is correct and accessible",
449                        &test_url_suggestion,
450                    ]),
451                ));
452            } else {
453                display.connection_failed(
454                    "Connection failed - authentication credentials may be incorrect",
455                    &[
456                        "Verify the username/password or token",
457                        "Check if the auth_type matches what the server expects",
458                        "Ensure the user has permission to access Prometheus API",
459                    ],
460                );
461
462                return Ok(format_error_for_llm(
463                    "prometheus_connect",
464                    ErrorCategory::NetworkError,
465                    "Connection failed - authentication credentials may be incorrect",
466                    Some(vec![
467                        "Verify the username/password or token",
468                        "Check if the auth_type matches what the server expects",
469                        "Ensure the user has permission to access Prometheus API",
470                    ]),
471                ));
472            }
473        }
474
475        // No service or URL provided
476        Ok(format_error_for_llm(
477            "prometheus_connect",
478            ErrorCategory::ValidationFailed,
479            "No service or URL provided",
480            Some(vec![
481                "Provide service + namespace for port-forward: {\"service\": \"prometheus-server\", \"namespace\": \"monitoring\"}",
482                "Or provide url for external Prometheus: {\"url\": \"http://prometheus.example.com\"}",
483                "Use prometheus_discover to find available Prometheus instances",
484            ]),
485        ))
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    #[test]
494    fn test_tool_name() {
495        assert_eq!(PrometheusConnectTool::NAME, "prometheus_connect");
496    }
497
498    #[test]
499    fn test_validate_port_valid() {
500        assert!(PrometheusConnectTool::validate_port(9090).is_ok());
501        assert!(PrometheusConnectTool::validate_port(1).is_ok());
502        assert!(PrometheusConnectTool::validate_port(65535).is_ok());
503    }
504
505    #[test]
506    fn test_validate_port_invalid() {
507        let result = PrometheusConnectTool::validate_port(0);
508        assert!(result.is_err());
509        assert!(
510            result
511                .unwrap_err()
512                .contains("Port must be between 1 and 65535")
513        );
514    }
515
516    #[test]
517    fn test_validate_url_valid() {
518        assert!(PrometheusConnectTool::validate_url("http://prometheus.example.com").is_ok());
519        assert!(PrometheusConnectTool::validate_url("https://prometheus.example.com").is_ok());
520        assert!(PrometheusConnectTool::validate_url("HTTP://PROMETHEUS.EXAMPLE.COM").is_ok());
521        assert!(PrometheusConnectTool::validate_url("HTTPS://prometheus.example.com").is_ok());
522    }
523
524    #[test]
525    fn test_validate_url_invalid() {
526        // Missing protocol
527        let result = PrometheusConnectTool::validate_url("prometheus.example.com");
528        assert!(result.is_err());
529        assert!(
530            result
531                .unwrap_err()
532                .contains("must start with http:// or https://")
533        );
534
535        // Wrong protocol
536        let result = PrometheusConnectTool::validate_url("ftp://prometheus.example.com");
537        assert!(result.is_err());
538
539        // Just a path
540        let result = PrometheusConnectTool::validate_url("/api/v1/query");
541        assert!(result.is_err());
542    }
543
544    #[tokio::test]
545    async fn test_missing_service_and_url_error() {
546        // Test that calling with no service and no URL returns structured error
547        let bg_manager = Arc::new(BackgroundProcessManager::new());
548        let tool = PrometheusConnectTool::new(bg_manager);
549
550        let args = PrometheusConnectArgs {
551            service: None,
552            namespace: None,
553            url: None,
554            port: None,
555            auth_type: None,
556            username: None,
557            password: None,
558            token: None,
559        };
560
561        let result = tool.call(args).await.unwrap();
562
563        // Verify the result is a structured error
564        assert!(result.contains("\"error\": true"));
565        assert!(result.contains("VALIDATION_FAILED"));
566        assert!(result.contains("No service or URL provided"));
567        assert!(result.contains("suggestions"));
568    }
569
570    #[tokio::test]
571    async fn test_invalid_port_validation() {
572        // Test that invalid port (0) returns validation error
573        let bg_manager = Arc::new(BackgroundProcessManager::new());
574        let tool = PrometheusConnectTool::new(bg_manager);
575
576        let args = PrometheusConnectArgs {
577            service: Some("prometheus".to_string()),
578            namespace: Some("monitoring".to_string()),
579            url: None,
580            port: Some(0), // Invalid port
581            auth_type: None,
582            username: None,
583            password: None,
584            token: None,
585        };
586
587        let result = tool.call(args).await.unwrap();
588
589        // Verify the result is a structured error
590        assert!(result.contains("\"error\": true"));
591        assert!(result.contains("VALIDATION_FAILED"));
592        assert!(result.contains("Port must be between 1 and 65535"));
593    }
594
595    #[tokio::test]
596    async fn test_malformed_url_validation() {
597        // Test that URL without http(s):// returns helpful error
598        let bg_manager = Arc::new(BackgroundProcessManager::new());
599        let tool = PrometheusConnectTool::new(bg_manager);
600
601        let args = PrometheusConnectArgs {
602            service: None,
603            namespace: None,
604            url: Some("prometheus.example.com".to_string()), // Missing protocol
605            port: None,
606            auth_type: None,
607            username: None,
608            password: None,
609            token: None,
610        };
611
612        let result = tool.call(args).await.unwrap();
613
614        // Verify the result is a structured error
615        assert!(result.contains("\"error\": true"));
616        assert!(result.contains("VALIDATION_FAILED"));
617        assert!(result.contains("must start with http:// or https://"));
618        assert!(result.contains("suggestions"));
619    }
620
621    #[test]
622    fn test_build_auth_none() {
623        let args = PrometheusConnectArgs {
624            service: None,
625            namespace: None,
626            url: Some("http://localhost".to_string()),
627            port: None,
628            auth_type: None,
629            username: None,
630            password: None,
631            token: None,
632        };
633
634        let auth = PrometheusConnectTool::build_auth(&args);
635        assert!(matches!(auth, PrometheusAuth::None));
636    }
637
638    #[test]
639    fn test_build_auth_basic() {
640        let args = PrometheusConnectArgs {
641            service: None,
642            namespace: None,
643            url: Some("http://localhost".to_string()),
644            port: None,
645            auth_type: Some("basic".to_string()),
646            username: Some("admin".to_string()),
647            password: Some("secret".to_string()),
648            token: None,
649        };
650
651        let auth = PrometheusConnectTool::build_auth(&args);
652        match auth {
653            PrometheusAuth::Basic { username, password } => {
654                assert_eq!(username, "admin");
655                assert_eq!(password, "secret");
656            }
657            _ => panic!("Expected Basic auth"),
658        }
659    }
660
661    #[test]
662    fn test_build_auth_bearer() {
663        let args = PrometheusConnectArgs {
664            service: None,
665            namespace: None,
666            url: Some("http://localhost".to_string()),
667            port: None,
668            auth_type: Some("bearer".to_string()),
669            username: None,
670            password: None,
671            token: Some("mytoken".to_string()),
672        };
673
674        let auth = PrometheusConnectTool::build_auth(&args);
675        match auth {
676            PrometheusAuth::Bearer(token) => {
677                assert_eq!(token, "mytoken");
678            }
679            _ => panic!("Expected Bearer auth"),
680        }
681    }
682}