/// Módulo para descubrir servicios systemd existentes use std::fs; use std::path::Path; use crate::logger::get_logger; use crate::config::{get_config_manager, MonitoredApp}; const SYSTEMD_DIR: &str = "/etc/systemd/system"; const SERVICE_PREFIX: &str = "siax-app-"; /// Descubre servicios systemd existentes con prefijo siax-app-* pub fn discover_services() -> Vec { let logger = get_logger(); logger.info("Discovery", &format!("🔍 Escaneando servicios systemd en: {}", SYSTEMD_DIR)); println!("🔍 Discovery: Buscando servicios en {}", SYSTEMD_DIR); let mut services = Vec::new(); // Leer directorio de systemd let entries = match fs::read_dir(SYSTEMD_DIR) { Ok(entries) => { logger.info("Discovery", &format!("✅ Directorio {} accesible", SYSTEMD_DIR)); println!("✅ Discovery: Directorio {} accesible", SYSTEMD_DIR); entries }, Err(e) => { logger.error("Discovery", &format!("❌ No se pudo leer directorio {}", SYSTEMD_DIR), Some(&e.to_string())); println!("❌ Discovery: ERROR - No se pudo leer {}: {}", SYSTEMD_DIR, e); return services; } }; // Buscar archivos siax-app-*.service let mut total_files = 0; let mut siax_files = 0; for entry in entries.flatten() { total_files += 1; let path = entry.path(); if let Some(filename) = path.file_name() { let filename_str = filename.to_string_lossy(); // Verificar que sea un archivo .service con nuestro prefijo if filename_str.starts_with(SERVICE_PREFIX) && filename_str.ends_with(".service") { siax_files += 1; logger.info("Discovery", &format!("✅ Encontrado: {}", filename_str)); println!("✅ Discovery: Servicio detectado: {}", filename_str); // Extraer nombre de la app let app_name = extract_app_name(&filename_str); // Leer configuración del servicio if let Some(service) = parse_service_file(&path, &app_name) { services.push(service); } else { logger.warning("Discovery", &format!("⚠️ No se pudo parsear: {}", filename_str), None); println!("⚠️ Discovery: No se pudo parsear {}", filename_str); } } } } logger.info("Discovery", &format!("📊 Escaneados {} archivos, {} con prefijo '{}', {} parseados exitosamente", total_files, siax_files, SERVICE_PREFIX, services.len())); println!("📊 Discovery: Archivos totales: {}, siax-app-*: {}, parseados: {}", total_files, siax_files, services.len()); services } /// Extrae el nombre de la app desde el nombre del archivo /// Ejemplo: "siax-app-IDEAS.service" -> "app_IDEAS" fn extract_app_name(filename: &str) -> String { // Remover "siax-app-" del inicio y ".service" del final filename .trim_start_matches(SERVICE_PREFIX) .trim_end_matches(".service") .to_string() } /// Servicio descubierto en systemd #[derive(Debug, Clone)] pub struct DiscoveredService { pub app_name: String, pub service_file: String, pub working_directory: Option, pub user: Option, pub exec_start: Option, pub port: Option, pub node_env: String, pub entry_point: Option, pub node_bin: Option, } /// Parsea un archivo .service para extraer configuración completa fn parse_service_file(path: &Path, app_name: &str) -> Option { let logger = get_logger(); let content = match fs::read_to_string(path) { Ok(content) => content, Err(e) => { logger.error("Discovery", &format!("Error leyendo {}", path.display()), Some(&e.to_string())); return None; } }; let mut service = DiscoveredService { app_name: app_name.to_string(), service_file: path.to_string_lossy().to_string(), working_directory: None, user: None, exec_start: None, port: None, node_env: String::from("production"), entry_point: None, node_bin: None, }; // Parsear líneas del archivo for line in content.lines() { let line = line.trim(); // WorkingDirectory if line.starts_with("WorkingDirectory=") { service.working_directory = Some(line.trim_start_matches("WorkingDirectory=").to_string()); } // User if line.starts_with("User=") { service.user = Some(line.trim_start_matches("User=").to_string()); } // ExecStart if line.starts_with("ExecStart=") { let exec_start = line.trim_start_matches("ExecStart=").to_string(); // Extraer node_bin y entry_point del ExecStart // Ejemplo: /home/user/.nvm/versions/node/v24.12.0/bin/node server.js let parts: Vec<&str> = exec_start.split_whitespace().collect(); if !parts.is_empty() { service.node_bin = Some(parts[0].to_string()); // Buscar el archivo .js como entry_point for part in &parts[1..] { if part.ends_with(".js") { service.entry_point = Some(part.to_string()); break; } } } service.exec_start = Some(exec_start); } // Environment con PORT if line.starts_with("Environment=") && line.contains("PORT") { if let Some(port) = extract_port_from_env(line) { service.port = Some(port); } } // Environment con NODE_ENV if line.starts_with("Environment=") && line.contains("NODE_ENV") { if let Some(env) = extract_env_value(line, "NODE_ENV") { service.node_env = env; } } } logger.info("Discovery", &format!(" App: {}, User: {:?}, WorkDir: {:?}, Env: {}, EntryPoint: {:?}", service.app_name, service.user, service.working_directory, service.node_env, service.entry_point )); Some(service) } /// Extrae el puerto de una línea Environment /// Ejemplo: Environment="PORT=3000" -> Some(3000) fn extract_port_from_env(line: &str) -> Option { // Buscar PORT=número if let Some(start) = line.find("PORT=") { let after_port = &line[start + 5..]; // Extraer números let port_str: String = after_port.chars() .take_while(|c| c.is_numeric()) .collect(); port_str.parse::().ok() } else { None } } /// Extrae un valor de variable de entorno de una línea Environment /// Ejemplo: Environment="NODE_ENV=production" -> Some("production") fn extract_env_value(line: &str, var_name: &str) -> Option { let pattern = format!("{}=", var_name); if let Some(start) = line.find(&pattern) { let after_var = &line[start + pattern.len()..]; // Extraer hasta espacios, comillas o fin de línea let value: String = after_var.chars() .take_while(|c| !c.is_whitespace() && *c != '"') .collect(); if !value.is_empty() { Some(value) } else { None } } else { None } } /// Sincroniza los servicios descubiertos con monitored_apps.json pub fn sync_discovered_services(services: Vec) { let logger = get_logger(); let config_manager = get_config_manager(); logger.info("Discovery", &format!("🔄 Sincronizando {} servicios descubiertos...", services.len())); println!("🔄 Discovery: Sincronizando {} servicios con monitored_apps.json", services.len()); let mut added_count = 0; let mut skipped_count = 0; for service in services { // Intentar detectar el puerto si no se encontró en Environment let port = service.port.unwrap_or_else(|| { detect_port_from_name(&service.app_name) }); // Verificar si ya existe en la configuración let existing_apps = config_manager.get_apps(); let already_exists = existing_apps.iter().any(|app| app.name == service.app_name); if already_exists { logger.info("Discovery", &format!("⏭️ {} ya existe en configuración", service.app_name)); println!("⏭️ Discovery: {} ya existe, omitiendo", service.app_name); skipped_count += 1; continue; } // Crear MonitoredApp con información completa let service_name = format!("siax-app-{}.service", service.app_name); let registered_at = chrono::Local::now().to_rfc3339(); let app = MonitoredApp { name: service.app_name.clone(), service_name, path: service.working_directory.unwrap_or_default(), port, entry_point: service.entry_point.unwrap_or_default(), node_bin: service.node_bin.unwrap_or_default(), mode: service.node_env, service_file_path: service.service_file.clone(), registered_at, systemd_service: None, created_at: None, }; // Agregar a monitored_apps.json logger.info("Discovery", &format!("➕ Agregando {} (puerto: {}, entry: {})", app.name, app.port, app.entry_point)); match config_manager.add_app_full(app) { Ok(_) => { logger.info("Discovery", &format!("✅ {} agregado exitosamente", service.app_name)); println!("✅ Discovery: {} agregado a monitored_apps.json", service.app_name); added_count += 1; } Err(e) => { logger.error("Discovery", &format!("Error agregando {}", service.app_name), Some(&e)); println!("❌ Discovery: Error agregando {}: {}", service.app_name, e); } } } logger.info("Discovery", &format!("📊 Resumen: {} agregadas, {} ya existían", added_count, skipped_count)); println!("📊 Discovery: Resumen final - {} apps nuevas, {} existentes", added_count, skipped_count); } /// Intenta detectar el puerto desde el nombre de la app /// Esto es un fallback simple si no se encuentra en el .service fn detect_port_from_name(app_name: &str) -> i32 { // Algunos puertos conocidos por nombre match app_name.to_lowercase().as_str() { name if name.contains("tareas") => 3000, name if name.contains("fidelizacion") => 3001, name if name.contains("ideas") => 2000, _ => 8080, // Puerto por defecto genérico } } #[cfg(test)] mod tests { use super::*; #[test] fn test_extract_app_name() { assert_eq!(extract_app_name("siax-app-IDEAS.service"), "IDEAS"); assert_eq!(extract_app_name("siax-app-TAREAS.service"), "TAREAS"); assert_eq!(extract_app_name("siax-app-fidelizacion.service"), "fidelizacion"); } #[test] fn test_extract_port_from_env() { assert_eq!(extract_port_from_env("Environment=PORT=3000"), Some(3000)); assert_eq!(extract_port_from_env("Environment=\"PORT=8080\""), Some(8080)); assert_eq!(extract_port_from_env("Environment=NODE_ENV=production"), None); } #[test] fn test_detect_port_from_name() { assert_eq!(detect_port_from_name("app_tareas"), 3000); assert_eq!(detect_port_from_name("IDEAS"), 2000); assert_eq!(detect_port_from_name("unknown_app"), 8080); } }