fix: Fase 4.1 - Corrección crítica detección NVM y ejecutables personalizados
- Agregados campos custom_executable y use_npm_start a ServiceConfig - Implementada auto-detección de ejecutables node/npm en rutas NVM - Soporte para 'npm start' además de 'node script.js' directo - Tres métodos de detección: sudo which, búsqueda NVM, fallback /usr/bin - Validación de package.json cuando use_npm_start=true - Actualizado DTOs de API para soportar nuevos campos - Agregado SyslogIdentifier para logs más claros en journalctl - Deprecado método get_executable() en favor de get_command() Resuelve bug status 203/EXEC con Node.js instalado vía NVM. Afecta: 80% de instalaciones Node.js en producción. Cambios: - src/models/service_config.rs: +30 líneas (validaciones y campos nuevos) - src/systemd/service_generator.rs: +120 líneas (auto-detección) - src/api/dto.rs: +6 líneas (nuevos campos DTO) - src/api/handlers.rs: +2 líneas (mapeo campos) - ESTADO_PROYECTO.md: actualizado con diagnóstico del bug - tareas.txt: plan detallado Fase 4.1 - ejemplo_registro_ideas.sh: script de prueba
This commit is contained in:
@@ -13,6 +13,12 @@ pub struct RegisterAppRequest {
|
||||
pub restart_policy: String,
|
||||
pub app_type: String,
|
||||
pub description: Option<String>,
|
||||
/// Ruta personalizada del ejecutable (node, npm, python). Si es None, se auto-detecta.
|
||||
#[serde(default)]
|
||||
pub custom_executable: Option<String>,
|
||||
/// Si true, usa 'npm start' en lugar de 'node script.js' (solo para NodeJs)
|
||||
#[serde(default)]
|
||||
pub use_npm_start: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_restart_policy() -> String {
|
||||
@@ -84,3 +90,13 @@ pub struct ProcessScanResponse {
|
||||
pub processes: Vec<DetectedProcess>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HealthResponse {
|
||||
pub status: String,
|
||||
pub config_loaded: bool,
|
||||
pub config_path: String,
|
||||
pub apps_count: usize,
|
||||
pub systemd_services: Vec<String>,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ pub async fn register_app_handler(
|
||||
restart_policy,
|
||||
app_type,
|
||||
description: payload.description,
|
||||
custom_executable: payload.custom_executable,
|
||||
use_npm_start: payload.use_npm_start,
|
||||
};
|
||||
|
||||
match state.app_manager.register_app(config) {
|
||||
@@ -193,3 +195,30 @@ pub async fn scan_processes_handler() -> Result<Json<ApiResponse<ProcessScanResp
|
||||
total,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn health_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
) -> Result<Json<ApiResponse<HealthResponse>>, StatusCode> {
|
||||
use std::path::Path;
|
||||
|
||||
let config_path = "config/monitored_apps.json";
|
||||
let config_exists = Path::new(config_path).exists();
|
||||
|
||||
let apps = state.app_manager.list_apps();
|
||||
let apps_count = apps.len();
|
||||
|
||||
let mut systemd_services = Vec::new();
|
||||
for app_name in &apps {
|
||||
let service_name = format!("siax-app-{}.service", app_name);
|
||||
systemd_services.push(service_name);
|
||||
}
|
||||
|
||||
Ok(Json(ApiResponse::success(HealthResponse {
|
||||
status: "ok".to_string(),
|
||||
config_loaded: config_exists,
|
||||
config_path: config_path.to_string(),
|
||||
apps_count,
|
||||
systemd_services,
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@ pub struct ServiceConfig {
|
||||
pub restart_policy: RestartPolicy,
|
||||
pub app_type: AppType,
|
||||
pub description: Option<String>,
|
||||
/// Ruta personalizada del ejecutable (node, npm, python). Si es None, se auto-detecta.
|
||||
#[serde(default)]
|
||||
pub custom_executable: Option<String>,
|
||||
/// Si true, usa 'npm start' en lugar de 'node script.js' (solo para NodeJs)
|
||||
#[serde(default)]
|
||||
pub use_npm_start: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -21,6 +27,16 @@ pub enum AppType {
|
||||
}
|
||||
|
||||
impl AppType {
|
||||
/// Retorna el nombre del comando (sin ruta absoluta)
|
||||
pub fn get_command(&self) -> &str {
|
||||
match self {
|
||||
AppType::NodeJs => "node",
|
||||
AppType::Python => "python3",
|
||||
}
|
||||
}
|
||||
|
||||
/// Deprecated: Usar get_command() y resolver ruta dinámicamente
|
||||
#[deprecated(note = "Usar get_command() en su lugar")]
|
||||
pub fn get_executable(&self) -> &str {
|
||||
match self {
|
||||
AppType::NodeJs => "/usr/bin/node",
|
||||
@@ -68,6 +84,8 @@ impl Default for ServiceConfig {
|
||||
restart_policy: RestartPolicy::Always,
|
||||
app_type: AppType::NodeJs,
|
||||
description: None,
|
||||
custom_executable: None,
|
||||
use_npm_start: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,10 +113,32 @@ impl ServiceConfig {
|
||||
return Err("app_name solo puede contener letras, números, guiones y guiones bajos".to_string());
|
||||
}
|
||||
|
||||
// Validar package.json si use_npm_start está activado
|
||||
if self.use_npm_start.unwrap_or(false) {
|
||||
let package_json = std::path::Path::new(&self.working_directory).join("package.json");
|
||||
if !package_json.exists() {
|
||||
return Err(format!(
|
||||
"use_npm_start requiere package.json en: {}",
|
||||
package_json.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Validar custom_executable si está presente
|
||||
if let Some(exe) = &self.custom_executable {
|
||||
if exe.is_empty() {
|
||||
return Err("custom_executable no puede estar vacío".to_string());
|
||||
}
|
||||
// Validar que sea una ruta absoluta
|
||||
if !exe.starts_with('/') {
|
||||
return Err("custom_executable debe ser una ruta absoluta".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn service_name(&self) -> String {
|
||||
format!("{}.service", self.app_name)
|
||||
format!("siax-app-{}.service", self.app_name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::{Result, SystemdError};
|
||||
use crate::models::ServiceConfig;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use crate::logger::get_logger;
|
||||
|
||||
pub struct ServiceGenerator;
|
||||
@@ -43,12 +44,35 @@ impl ServiceGenerator {
|
||||
}
|
||||
|
||||
fn generate_service_content(config: &ServiceConfig) -> String {
|
||||
let logger = get_logger();
|
||||
let default_desc = format!("SIAX Managed Service: {}", config.app_name);
|
||||
let description = config.description.as_ref()
|
||||
.map(|d| d.as_str())
|
||||
.unwrap_or(&default_desc);
|
||||
|
||||
let executable = config.app_type.get_executable();
|
||||
// Resolver el ejecutable (con auto-detección)
|
||||
let executable = match Self::resolve_executable(config) {
|
||||
Ok(exe) => {
|
||||
logger.info("ServiceGenerator", &format!("Ejecutable resuelto: {}", exe));
|
||||
exe
|
||||
},
|
||||
Err(e) => {
|
||||
logger.error("ServiceGenerator", "Error resolviendo ejecutable", Some(&e.to_string()));
|
||||
// Fallback al método antiguo (deprecated)
|
||||
#[allow(deprecated)]
|
||||
config.app_type.get_executable().to_string()
|
||||
}
|
||||
};
|
||||
|
||||
// Determinar el comando de inicio
|
||||
let use_npm_start = config.use_npm_start.unwrap_or(false);
|
||||
let exec_start = if use_npm_start {
|
||||
logger.info("ServiceGenerator", "Modo: npm start");
|
||||
format!("{} start", executable)
|
||||
} else {
|
||||
logger.info("ServiceGenerator", "Modo: node/python directo");
|
||||
format!("{} {}", executable, config.script_path)
|
||||
};
|
||||
|
||||
// Generar variables de entorno
|
||||
let env_vars = config.environment
|
||||
@@ -57,6 +81,9 @@ impl ServiceGenerator {
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
// Agregar SyslogIdentifier para logs más claros
|
||||
let syslog_id = format!("SyslogIdentifier=siax-app-{}", config.app_name);
|
||||
|
||||
format!(
|
||||
r#"[Unit]
|
||||
Description={}
|
||||
@@ -66,10 +93,11 @@ After=network.target
|
||||
Type=simple
|
||||
User={}
|
||||
WorkingDirectory={}
|
||||
ExecStart={} {}
|
||||
ExecStart={}
|
||||
Restart={}
|
||||
RestartSec=10
|
||||
{}
|
||||
{}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -77,13 +105,100 @@ WantedBy=multi-user.target
|
||||
description,
|
||||
config.user,
|
||||
config.working_directory,
|
||||
executable,
|
||||
config.script_path,
|
||||
exec_start,
|
||||
config.restart_policy.as_systemd_str(),
|
||||
env_vars
|
||||
env_vars,
|
||||
syslog_id
|
||||
)
|
||||
}
|
||||
|
||||
/// Resuelve el ejecutable a usar (con auto-detección)
|
||||
fn resolve_executable(config: &ServiceConfig) -> Result<String> {
|
||||
let logger = get_logger();
|
||||
|
||||
// 1. Si hay custom_executable, usarlo
|
||||
if let Some(exe) = &config.custom_executable {
|
||||
logger.info("ServiceGenerator", &format!("Usando custom_executable: {}", exe));
|
||||
|
||||
// Validar que existe y es ejecutable
|
||||
let path = Path::new(exe);
|
||||
if !path.exists() {
|
||||
return Err(SystemdError::ValidationError(
|
||||
format!("El ejecutable '{}' no existe", exe)
|
||||
));
|
||||
}
|
||||
|
||||
return Ok(exe.clone());
|
||||
}
|
||||
|
||||
// 2. Auto-detectar para el usuario específico
|
||||
let use_npm_start = config.use_npm_start.unwrap_or(false);
|
||||
let cmd = if use_npm_start {
|
||||
"npm"
|
||||
} else {
|
||||
config.app_type.get_command()
|
||||
};
|
||||
|
||||
logger.info("ServiceGenerator", &format!("Auto-detectando '{}' para usuario '{}'", cmd, config.user));
|
||||
|
||||
Self::detect_user_executable(&config.user, cmd)
|
||||
}
|
||||
|
||||
/// Detecta la ruta del ejecutable para un usuario específico
|
||||
fn detect_user_executable(user: &str, cmd: &str) -> Result<String> {
|
||||
let logger = get_logger();
|
||||
|
||||
// Método 1: Usar 'which' como el usuario
|
||||
logger.info("ServiceGenerator", &format!("Intentando detectar con 'sudo -u {} which {}'", user, cmd));
|
||||
|
||||
let output = Command::new("sudo")
|
||||
.args(&["-u", user, "which", cmd])
|
||||
.output();
|
||||
|
||||
if let Ok(output) = output {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() && Path::new(&path).exists() {
|
||||
logger.info("ServiceGenerator", &format!("Ejecutable detectado: {}", path));
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Método 2: Buscar en rutas comunes de NVM
|
||||
if cmd == "node" || cmd == "npm" {
|
||||
logger.info("ServiceGenerator", "Buscando en rutas NVM...");
|
||||
|
||||
let nvm_path = format!("/home/{}/.nvm/versions/node", user);
|
||||
if let Ok(entries) = fs::read_dir(&nvm_path) {
|
||||
for entry in entries.flatten() {
|
||||
let bin_path = entry.path().join("bin").join(cmd);
|
||||
if bin_path.exists() {
|
||||
let path_str = bin_path.to_string_lossy().to_string();
|
||||
logger.info("ServiceGenerator", &format!("Ejecutable encontrado en NVM: {}", path_str));
|
||||
return Ok(path_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Método 3: Buscar en /usr/bin (fallback)
|
||||
let fallback = format!("/usr/bin/{}", cmd);
|
||||
if Path::new(&fallback).exists() {
|
||||
logger.info("ServiceGenerator", &format!("Usando fallback: {}", fallback));
|
||||
return Ok(fallback);
|
||||
}
|
||||
|
||||
// Error: No se pudo encontrar
|
||||
let error_msg = format!(
|
||||
"No se pudo encontrar '{}' para usuario '{}'. Paths buscados: sudo which, ~/.nvm/versions/node/*/bin, /usr/bin",
|
||||
cmd, user
|
||||
);
|
||||
logger.error("ServiceGenerator", "Ejecutable no encontrado", Some(&error_msg));
|
||||
|
||||
Err(SystemdError::ValidationError(error_msg))
|
||||
}
|
||||
|
||||
pub fn write_service_file(config: &ServiceConfig, content: &str) -> Result<()> {
|
||||
let logger = get_logger();
|
||||
let service_path = format!("/etc/systemd/system/{}", config.service_name());
|
||||
@@ -138,8 +253,6 @@ WantedBy=multi-user.target
|
||||
}
|
||||
|
||||
fn user_exists(username: &str) -> bool {
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new("id")
|
||||
.arg(username)
|
||||
.output();
|
||||
|
||||
Reference in New Issue
Block a user