omnyssh_core/ssh/discovery.rs
1//! Service discovery orchestrator.
2//!
3//! Runs a Quick Scan to detect services on remote servers.
4//!
5//! Architecture:
6//! - Quick Scan runs once per connection, discovers which services exist
7//! - All operations are async and use the existing SSH session
8//! - Results are sent via CoreEvent to the main loop
9
10use anyhow::Result;
11use tokio::sync::mpsc;
12
13use crate::event::{CoreEvent, DetectedService};
14use crate::ssh::probe::{generate_quick_scan_script, ProbeOutput};
15use crate::ssh::services::ServiceRegistry;
16use crate::ssh::session::SshSession;
17
18/// Performs a Quick Scan on the given SSH session.
19///
20/// Quick Scan:
21/// 1. Executes the probe script (single SSH command)
22/// 2. Parses the output into sections
23/// 3. Detects which services are present using the service registry
24/// 4. Sends DiscoveryQuickScanDone event with minimal service info
25///
26/// This is fast (~2-3 seconds) and designed to run on first connection.
27///
28/// # Errors
29/// Returns an error if the SSH command fails or times out.
30/// Parsing errors are handled gracefully.
31pub async fn quick_scan(
32 session: &SshSession,
33 host_id: String,
34 tx: mpsc::Sender<CoreEvent>,
35) -> Result<()> {
36 tracing::debug!(host = %host_id, "starting quick scan");
37
38 // Execute probe script
39 let probe_script = generate_quick_scan_script();
40 let output = session.run_command(probe_script).await?;
41
42 // Parse output
43 let probe_output = ProbeOutput::parse(&output)?;
44
45 // Detect services using registry
46 let registry = ServiceRegistry::new();
47 let detected_kinds = registry.detect_services(&probe_output);
48
49 tracing::debug!(
50 host = %host_id,
51 services = ?detected_kinds,
52 "quick scan detected {} services",
53 detected_kinds.len()
54 );
55
56 // Create service info with basic quick metrics
57 let mut services = Vec::new();
58 for kind in detected_kinds {
59 // Try to extract quick metrics from probe output
60 let quick_metrics = if let Some(provider) = registry.get_provider(&kind) {
61 provider.quick_metrics(&probe_output)
62 } else {
63 Vec::new()
64 };
65
66 services.push(DetectedService {
67 kind: kind.clone(),
68 metrics: quick_metrics,
69 });
70 }
71
72 // Extract OS information and send via MetricsUpdate
73 if let Some(os_info) = probe_output.parse_os_info() {
74 use crate::event::Metrics;
75
76 // Send a partial metrics update with just OS info; the merge logic in
77 // the main loop preserves the other fields.
78 let metrics = Metrics {
79 os_info: Some(os_info),
80 ..Metrics::default()
81 };
82
83 tx.send(CoreEvent::MetricsUpdate(host_id.clone(), metrics))
84 .await
85 .ok(); // Don't fail discovery if we can't send OS info
86 }
87
88 // Send event to main loop
89 tx.send(CoreEvent::DiscoveryQuickScanDone(host_id, services))
90 .await
91 .map_err(|_| anyhow::anyhow!("event channel closed"))?;
92
93 Ok(())
94}
95
96// Note: Quick Scan is called directly from pool.rs
97// It runs in a spawned tokio task there to avoid blocking the metrics loop
98
99// ---------------------------------------------------------------------------
100// Tests
101// ---------------------------------------------------------------------------
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 #[test]
108 fn test_quick_scan_script_generation() {
109 let script = generate_quick_scan_script();
110 assert!(script.contains("===OMNYSSH:OS==="));
111 assert!(script.contains("===OMNYSSH:DOCKER==="));
112 assert!(script.contains("===OMNYSSH:SERVICES==="));
113 }
114
115 // Integration tests with mock SSH would go here
116 // For now, we rely on the probe and service provider unit tests
117}