- Agregados campos deleted, deleted_at, deleted_reason a MonitoredApp - Implementado soft_delete_app() y restore_app() en ConfigManager - Modificado get_apps() para filtrar apps eliminadas por defecto - Agregados métodos get_all_apps() y get_deleted_apps() - Actualizado unregister_app() para usar soft delete en lugar de hard delete - Creados endpoints: * GET /api/apps/deleted - Ver historial de apps eliminadas * POST /api/apps/:name/restore - Restaurar app eliminada - Agregada sección de Historial en index.html con UI completa - Botón de restaurar para cada app eliminada - El servicio systemd se elimina físicamente, solo el JSON mantiene historial - Permite auditoría y recuperación de apps eliminadas accidentalmente
325 lines
12 KiB
Rust
325 lines
12 KiB
Rust
/// 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<DiscoveredService> {
|
||
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<String>,
|
||
pub user: Option<String>,
|
||
pub exec_start: Option<String>,
|
||
pub port: Option<i32>,
|
||
pub node_env: String,
|
||
pub entry_point: Option<String>,
|
||
pub node_bin: Option<String>,
|
||
}
|
||
|
||
/// Parsea un archivo .service para extraer configuración completa
|
||
fn parse_service_file(path: &Path, app_name: &str) -> Option<DiscoveredService> {
|
||
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<i32> {
|
||
// 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::<i32>().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<String> {
|
||
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<DiscoveredService>) {
|
||
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,
|
||
deleted: false,
|
||
deleted_at: None,
|
||
deleted_reason: None,
|
||
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);
|
||
}
|
||
}
|