defmodule Argos.Command do @moduledoc """ Sistema de ejecución de comandos shell con soporte para diferentes modos. Proporciona macros y funciones para ejecutar comandos del sistema con control granular sobre la salida, el modo de ejecución y el manejo de errores. ## Características - Múltiples modos de ejecución (normal, silencioso, interactivo, sudo) - Resultados estructurados con duración y códigos de salida - Logging automático de comandos ejecutados - Soporte para timeouts configurables - Manejo de procesos y señales - Ejecución segura de comandos con validación ## Modos de Ejecución ### Normal (`exec!/2`) Ejecuta un comando y devuelve un struct CommandResult con toda la información. %{result: result, success?: success?} = exec!("ls -la") if success? do IO.puts("Salida: output") end ### Raw (`exec_raw!/2`) Ejecuta un comando y devuelve una tupla {output, exit_code} sin logging. {output, code} = exec_raw!("git status") ### Silencioso (`exec_silent!/2`) Ejecuta un comando redirigiendo toda la salida a /dev/null. code = exec_silent!("some_quiet_command") ### Interactivo (`exec_interactive!/2`) Ejecuta un comando permitiendo interacción en tiempo real. result = exec_interactive!("vim archivo.txt") ### Sudo (`exec_sudo!/2`) Ejecuta un comando con privilegios de superusuario. result = exec_sudo!("systemctl restart nginx") ## Opciones Comunes - `:timeout` - Timeout en milisegundos (default: 30_000) - `:stderr_to_stdout` - Redirige stderr a stdout (default: true) - `:halt` - Termina el programa si el comando falla (default: false) - `:interactive` - Modo interactivo para sudo (default: false) ## Gestión de Procesos # Matar procesos por nombre Argos.Command.kill_process("my_app") # Matar múltiples procesos results = Argos.Command.kill_processes_by_name(["app1", "app2"]) ## Configuración El shell usado por defecto se configura en config.exs: config :argos, shell: "/bin/zsh" ## Struct CommandResult Todos los comandos (excepto raw) devuelven un %CommandResult{}: - `command` - Comando ejecutado - `args` - Argumentos del comando - `output` - Salida capturada - `exit_code` - Código de salida - `duration` - Duración en milisegundos - `success?` - true si exit_code == 0 - `error` - Mensaje de error si lo hay """ require Logger alias Argos.Structs.CommandResult @shell_timeout 30_000 @shell Application.compile_env(:argos, :shell, "/bin/zsh") # ---------------- Macros (maintaining backward compatibility) ---------------- defmacro exec_raw!(command, opts \\ []) do caller = Macro.escape(__CALLER__) quote bind_quoted: [command: command, opts: opts, caller: caller] do opts = Keyword.put_new(opts, :stderr_to_stdout, true) Argos.Command.__exec__(:raw, command, opts, caller) end end defmacro exec!(command, opts \\ []) do caller = Macro.escape(__CALLER__) quote bind_quoted: [command: command, opts: opts, caller: caller] do opts = Keyword.put_new(opts, :stderr_to_stdout, true) Argos.Command.__exec__(:normal, command, opts, caller) end end defmacro exec_silent!(command, opts \\ []) do caller = Macro.escape(__CALLER__) quote bind_quoted: [command: command, opts: opts, caller: caller] do opts = Keyword.put_new(opts, :stderr_to_stdout, true) Argos.Command.__exec__(:silent, command, opts, caller) end end defmacro exec_interactive!(command, opts \\ []) do caller = Macro.escape(__CALLER__) quote bind_quoted: [command: command, opts: opts, caller: caller] do opts = Keyword.put_new(opts, :interactive, true) Argos.Command.__exec__(:interactive, command, opts, caller) end end defmacro exec_sudo!(command, opts \\ []) do caller = Macro.escape(__CALLER__) quote bind_quoted: [command: command, opts: opts, caller: caller] do opts = Keyword.put_new(opts, :stderr_to_stdout, true) Argos.Command.__exec__(:sudo, command, opts, caller) end end # ---------------- Exec (maintaining original behavior for backward compatibility) ---------------- def __exec__(:raw, command, opts, _caller) do case command do cmd_list when is_list(cmd_list) and length(cmd_list) > 0 -> [cmd_head | cmd_tail] = cmd_list System.cmd(cmd_head, cmd_tail, opts) cmd_string when is_binary(cmd_string) -> System.cmd(@shell, ["-c", cmd_string], opts) _ -> System.cmd(@shell, ["-c", to_string(command)], opts) end end def __exec__(:normal, command, opts, caller) do {halt?, opts} = Keyword.pop(opts, :halt, false) start_time = System.monotonic_time(:millisecond) {output, exit_code} = __exec__(:raw, command, opts, caller) duration = System.monotonic_time(:millisecond) - start_time result = %CommandResult{ command: @shell, args: ["-c", command], output: output, exit_code: exit_code, duration: duration, success?: exit_code == 0, error: nil } __log_result__(command, output, exit_code, caller) __handle_halt__(result, halt?) result end def __exec__(:silent, command, opts, caller) do __exec__(:normal, "#{command} /dev/null 2>&1", opts, caller) |> elem(1) end def __exec__(:interactive, command, opts, caller) do halt? = Keyword.get(opts, :halt, false) start_time = System.monotonic_time(:millisecond) case System.find_executable("script") do nil -> port = Port.open({:spawn_executable, System.find_executable(@shell)}, [ :binary, {:args, ["-c", command]}, :exit_status, :stderr_to_stdout ]) result = __collect_output__(command, port, "", caller, start_time) __handle_halt__(result, halt?) script_path -> args = ["-q", "/dev/null", @shell, "-c", command] port = Port.open({:spawn_executable, script_path}, [ :binary, :stream, :exit_status, :stderr_to_stdout, {:args, args} ]) command |> __collect_output__(port, "", caller, start_time) |> __handle_halt__(halt?) end end def __exec__(:sudo, command, opts, caller) do # Si el comando requiere interacción, tratamos sudo como :interactive interactive? = Keyword.get(opts, :interactive, false) halt? = Keyword.get(opts, :halt, false) start_time = System.monotonic_time(:millisecond) Logger.warning("SUDO command execution attempted: #{command}") executable = System.find_executable("sudo") if executable do args = get_interactive_args(command, interactive?) port = Port.open({:spawn_executable, executable}, [ :binary, :stream, :exit_status, :stderr_to_stdout, {:args, args}, {:env, [{"SUDO_ASKPASS", "/usr/bin/ssh-askpass"}]} ]) command |> __collect_output__(port, "", caller, start_time) |> __handle_halt__(halt?) else duration = System.monotonic_time(:millisecond) - start_time %CommandResult{ command: command, args: [], output: "sudo command not found", exit_code: 127, duration: duration, success?: false, error: "sudo command not found" } end end defp get_interactive_args(command, false), do: ["-A", @shell, "-c", command] defp get_interactive_args(command, true) do case System.find_executable("script") do nil -> ["-A", @shell, "-c", command] script_path -> [ "-A", script_path, "-q", "/dev/null", @shell, "-c", command ] end end def halt(code \\ 0) do System.halt(code) :ok end def __handle_halt__(%CommandResult{exit_code: code, output: msg}, true) when code not in [0, 1] do Logger.error("Error ejecutando comando: #{String.trim(msg)} (code #{code})") halt() end def __handle_halt__(result, _), do: result defp __collect_output__(command, port, acc, caller, start_time) when is_integer(start_time) do actual_start_time = start_time receive do {^port, {:data, data}} -> __collect_output__(command, port, acc <> data, caller, actual_start_time) {^port, {:exit_status, code}} -> duration = System.monotonic_time(:millisecond) - actual_start_time __log_result__(command, acc, code, caller) %CommandResult{ command: command, args: [], output: acc, exit_code: code, duration: duration, success?: code == 0, error: nil } after @shell_timeout -> if is_port(port), do: Port.close(port) Logger.error("EXEC TIMEOUT -> #{String.trim(acc)}") __log_result__(command, acc, 1, caller) duration = System.monotonic_time(:millisecond) - actual_start_time %CommandResult{ command: command, args: [], output: acc, exit_code: 1, duration: duration, success?: false, error: "Timeout after #{@shell_timeout}ms" } end end defp __log_result__(command, message, code, caller) do log_command = command |> String.split(" ") |> Enum.take(2) |> Enum.join(" ") log = "Module: #{caller.module}\\nFunction #{inspect(caller.function)} -> (#{code})\\nMessage: #{String.trim(message)}" case code do -1 -> Logger.info(log_command) Logger.debug(log) 1 -> Logger.info(command) Logger.warning(log) c when c > 1 -> Logger.error(command) Logger.critical(log) _ -> :ok end end # ---------------- Process Response ---------------- def process_response(code, opts \\ []) do message = code |> get_message(opts) |> normalize_response() %{ code: code, message: message, type: get_message_type(code) } end defp normalize_response(message, default_value \\ "") do (message || default_value) |> to_string() |> String.trim() |> String.split("\\n", trim: true) end defp get_message(0, opts), do: Keyword.get(opts, :success_message, "") defp get_message(1, opts), do: Keyword.get(opts, :warning_message, "") defp get_message(_, opts), do: Keyword.get(opts, :error_message, "") defp get_message_type(0), do: :success defp get_message_type(1), do: :warning defp get_message_type(_), do: :error # ---------------- Utils ---------------- def kill_process(process_name) when is_binary(process_name) do if String.length(process_name) > 0 and String.match?(process_name, ~r/^[a-zA-Z0-9_.-]+$/) do exec!("pgrep -f #{process_name} | head -10 | xargs kill -TERM") else Logger.error("Invalid process name: #{process_name}") %CommandResult{ command: "kill", args: [process_name], output: "Invalid process name", exit_code: 1, duration: 0, success?: false, error: "Invalid process name" } end end def kill_process(_) do CommandResult.failure( "kill", [], "Process name must be a string", 1, 0, "Process name must be a string" ) end @doc """ Funciones auxiliares para el manejo robusto de procesos. """ def kill_processes_by_name(process_names) when is_list(process_names) do Logger.info("Matando procesos: #{inspect(process_names)}") Enum.map(process_names, fn process_name -> case kill_process_robust(process_name) do :ok -> {process_name, :killed} :not_found -> {process_name, :not_found} {:error, reason} -> {process_name, {:error, reason}} end end) end defp kill_process_robust(process_name) do with {:kill_term, %CommandResult{exit_code: 0}} <- do_kill_term(process_name), {:pgrep, %CommandResult{exit_code: pgrep_code}} <- do_pgrep(process_name), {:pkill, %CommandResult{exit_code: 0}} <- do_pkill(process_name, pgrep_code) do :ok else {:kill_term, %CommandResult{exit_code: 1}} -> :not_found {:kill_term, %CommandResult{output: error, exit_code: code}} -> Logger.error("Error matando proceso #{process_name}: #{error} (#{code})") {:error, :kill_term_command_failed} {:pkill, %CommandResult{exit_code: exit_code}} when exit_code != 0 -> {:error, :pkill_failed} end end defp do_kill_term(process_name) do result = exec_raw!("pkill -TERM #{process_name}", timeout: 5000) {:kill_term, result} end defp do_pgrep(process_name) do Process.sleep(2000) result = exec_raw!("pgrep #{process_name}", timeout: 2000) {:pgrep, result} end defp do_pkill(_process_name, 1), do: {:pkill, %CommandResult{exit_code: 0}} defp do_pkill(process_name, 0) do result = exec_raw!("pkill -KILL #{process_name}", timeout: 5000) {:pkill, result} end end