Poesía Binaria

Bash: ¿Cómo ejecutar código antes y después de cada comando? Logging, monitorización, notificaciones y mucho más

Con el fin de hacer nuestro sistema (o servidor) más flexible. Aunque podemos hacer muchas cosas con estas técnicas. Es de gran utilidad poder ejecutar un script justo antes de la ejecución de cualquier orden de Bash y que, justo cuando esta orden termine, podamos ejecutar cualquier otra cosa. Esto nos abre las puertas, por ejemplo a notificaciones de inicio/finalización, logging, monitorización y muchas más posibilidades que veremos en futuros posts. Cosas que podremos hacer tanto en el ámbito local, como en servidores de forma muy sencilla.

Aunque otros intérpretes, como ksh, tcsh, zsh y algunos más tienen facilidades para estas tareas. Vamos a hacerlo en bash, ¡porque somos unos valientes! Porque se instala por defecto con muchas distribuciones y porque es el que me he encontrado con más frecuencia.

Ejecutar algo cuando termina un comando

Cuando un comando se ha ejecutado y Bash nos vuelve a pedir una nueva orden, justo antes de darnos el control, y para mostrar el prompt (ese texto que suele ser usuario@host /directorio/actual y termina en un dólar o una almohadilla), Bash, ejecutará lo que contenga la variable PROMPT_COMMAND, ya sea una función, un alias, o la ejecución de un programa. Vamos a hacer una prueba:

PROMPT_COMMAND=»date»
lun sep 18 10:23:47 CEST 2017
pwd
/home/gaspy
lun sep 18 10:24:47 CEST 2017

Incluso si sólo pulsamos enter, como se vuelve a generar el prompt, justo antes nos mostrará la fecha (porque el comando a ejecutar es date)

Como ejemplo práctico, vamos a hacer un pequeño script que nos notificará cuando alguna partición de nuestro disco duro llegue a un punto crítico, en este caso, vamos a revisar todas las particiones de /dev/sdb y avisar al usuario cuando alguna de estas sobrepase el 90% (opciones que podremos configurar luego), para ello, vamos a crear un script llamado critp.sh con lo siguiente:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#!/bin/sh

PARTITION_PATTERN="/dev/sdb*"
WARNING_PERCENT=90
__RED='\033[1;31m'
__YELLOW='\033[1;33m'
__NC='\033[0m' # Color end

function space_notifier()
{
        local OLDIFS=$IFS
        IFS=$'\n'
        local STATUS=($(df -h $PARTITION_PATTERN | awk 'NR > 1'))
        IFS=$' \n\t\r'
        for st in "${STATUS[@]}"; do
                local _ST=($st)
                local _PERCENT=$(echo ${_ST[4]}| tr -d '%')
                if (("$_PERCENT">"$WARNING_PERCENT")); then
                        echo -e $__RED"El dispositivo "$__YELLOW"${_ST[0]}"$__RED" está al ${_ST[4]}$
                fi
        done 2>/dev/null
        IFS=$OLDIFS
}

PROMPT_COMMAND="
space_notifier"

Y lo cargaremos de la siguiente manera:

source critp.sh

Con esto, cada vez que pulsemos enter o finalice un comando, veremos el conjunto de dispositivos que ha alcanzado ese porcentaje de espacio:

Una buena idea sería también notificar sólo a veces, tal vez con una probabilidad del 50%. Para que no siempre esté calculando ni nos sintamos abrumados por los mensajes de notificación. Para ello, podemos dejar el script así:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/bin/sh

PARTITION_PATTERN="/dev/sdb*"
WARNING_PERCENT=90
PROBABILITY=50
__RED='\033[1;31m'
__YELLOW='\033[1;33m'
__NC='\033[0m' # Color end

function space_notifier()
{
        if (($(($RANDOM%100))<$PROBABILITY)); then
                return
        fi
        local OLDIFS=$IFS
        IFS=$'\n'
        local STATUS=($(df -h $PARTITION_PATTERN | awk 'NR > 1'))
        IFS=$' \n\t\r'
        for st in "${STATUS[@]}"; do
                local _ST=($st)
                local _PERCENT=$(echo ${_ST[4]}| tr -d '%')
                if (("$_PERCENT">"$WARNING_PERCENT")); then
                        echo -e $__RED"El dispositivo "$__YELLOW"${_ST[0]}"$__RED" está al ${_ST[4]}$
                fi
        done 2>/dev/null
        IFS=$OLDIFS
}

PROMPT_COMMAND="
space_notifier"

Ya tenemos lo básico. Aunque estaría muy bien conocer información sobre el comando que se acaba de ejecutar y que, por supuesto, tenemos a nuestra disposición como es el propio comando ejecutado y su estado de salida. Más adelante podremos saber si se ha ejecutado realmente un comando, porque claro, cuando pulsas enter, vuelve a llamarse a la función. Ahora mismo, está muy bien si queremos realizar tareas de mantenimiento, notificar al usuario si está mal de espacio en disco, etc. Como el siguiente ejemplo que servirá para notificar al usuario cuando termine una determinada tarea. Suponemos que estamos en un entorno gráfico, así que avisaremos con zenity:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/bin/bash

function notify() {
        NOTIFY=1
}

function pcom() {
        local _PIPESTATUS=( ${PIPESTATUS[@]} )
        if [ -n "$NOTIFY" ]; then
                local OLDIFS=$IFS
                IFS=' '
                local STATUS="${_PIPESTATUS[@]}"
                IFS=$'\n'
                local HIST=($(HISTTIMEFORMAT= history 1 | awk '{ print $1"\n"substr($0, index($0,$2)) }'))
                IFS=$OLDIFS
                zenity --info --text "$(echo -e "COMANDO FINALIZADO: ${HIST[1]}\n ESTADO: $STATUS")"
        fi
        unset NOTIFY
}

PROMPT_COMMAND="pcom"

Ahora, cargaremos el archivo con source. Y podremos ejecutar cualquier comando con normalidad, aunque, cuando vayamos a hacer algo que vaya para largo podremos hacer lo siguiente:

notify; sleep 100

De esta forma, cuando termine la ejecución de sleep 100, o de cualquier otra cosa que creamos que va a tardar mucho, recibiremos un mensaje parecido a este:

Este script tiene algunas cosas curiosas. Para hallar el estado de la ejecución de las órdenes, utilizo PIPESTATUS. Esta variable, si el comando es sencillo, devolverá sólo un valor, haciendo lo mismo que $?. Pero cuando hemos llamado varios comandos unidos por una pipe o tubería, nos va a devolver el estado de todos los comandos ejecutados. Eso sí, como PIPESTATUS se actualiza tras cada cosa que ejecutamos, lo primero que haremos nada más entrar en la función será guardar su valor en otra variable, y así nos aseguramos de no perderlo.

Y esto está muy bien cuando estamos haciendo varias cosas y se nos puede ir la cabeza con una tarea. Nos puede servir para cuando estemos creando o restaurando un dump de base de datos, cuando estemos haciendo una copia de archivos (local o remota), o estemos realizando alguna tarea de cálculo intenso. Tan solo tenemos que llamar antes de la tarea en cuestión a notify y ya se encargará él de todo. Podemos extender la funcionalidad de notify, incluso aprovechar lo que voy a contar a continuación para hacerlo más potente (te adelanto que en unos días publicaré un script mucho más completo).

Ejecutar algo justo antes de llamar al comando

Para tener control total sobre los comandos ejecutados es interesante poder ejecutar código antes de la ejecución de los comandos. Esto, principalmente nos ayuda a depurar nuestros scripts, porque sabremos en qué orden se ejecuta cada cosa, pero también puede ser una buena herramienta de control, gran ayuda para hacer una visualización interactiva de comandos o, para saber lo que ejecutan nuestras visitas o los comandos que se ejecutan en un servidor además de muchas otras utilidades.

Como ejemplo, empezaremos con algo básico, simplemente haciendo echo antes y después de una llamada. Y, para dar un valor añadido, ya que somos capaces de ejecutar algo antes de hacer cualquier llamada, podremos detectar cuando simplemente se pulsa enter. Ya que, cuando pulsamos enter, la primera función, precmd, no se va a ejecutar:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/bin/bash
function postcmd() {
        if [ "${#COMANDOS[@]}" -gt 1 ]; then
                HISTORY=$(HISTTIMEFORMAT= history 1)
                echo "COMANDO EJECUTADO: "${HISTORY:7}
        fi;
        COMANDOS=();
}

function precmd() {
        COMANDOS+=("$BASH_COMMAND");
        if [[ "$BASH_COMMAND" != "$PROMPT_COMMAND" ]]; then
                echo "INICIA EJECUCIÓN: "$BASH_COMMAND
        fi
}

PROMPT_COMMAND="postcmd"

trap 'precmd' debug

Trabajando un poco en este código podremos ser capaces, por ejemplo de guardar un log de todo lo que los usuarios ejecutan, simplemente introduciendo:

1
logger $BASH_COMMAND

En lugar del echo «INICIA EJECUCION». Así podemos hacer que el sistema sea totalmente transparente al usuario. Además, para completar el registro podremos incluir otra función:

1
2
3
4
5
function errcmd() {
        echo "ERROR EJECUTANDO: $BASH_COMMAND en linea $LINENO Codigo de error: $?"
}

trap 'errcmd' err

Que vinculamos a cualquier error. Es decir, cancelación de ejecución o que el código de salida de lo que sea que hayamos llamado no sea 0. Por supuesto, podríamos ayudarnos de más traps como int (para Control+C), quit (para Control+\), term (para cuando se mata el proceso), etc.

Bueno, y, hagamos algo interesante como evitar que el usuario ejecute cosas que no queremos que ejecute:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
shopt -s extdebug

function denyaccess () {
        if [ "$BASH_COMMAND" = "$PROMPT_COMMAND" ]; then
                return
        fi
        local this_command=`HISTTIMEFORMAT= history 1 | sed -e "s/^[ ]*[0-9]*[ $

        if [ "
shopt -u extdebug" == "$this_command" ]; then
                return 0
        elif [[ "
$this_command" =~ ping ]]; then
                return 0
        fi
        echo "
ACCESO DENEGADO para: $this_command"
        return 1
}

trap 'denyaccess' DEBUG

En este caso, haremos return 0, sólo cuando ejecutemos shopt -u extdebug y el comando ping. Mostrando el texto «ACCESO DENEGADO» si queremos ejecutar cualquier otra cosa. Esto en la práctica no vale de nada, tendríamos que introducir muchas restricciones o tal vez denegar (return 1) ciertas palabras, manteniendo el return 0 por defecto. Pero nos da una idea de cómo funciona.
Gracias a la opción extdebug, esta función es capaz de denegar la ejecución de la orden que hemos llamado en función del valor de retorno de la función denyaccess().
En este caso, el argumento -s de shopt, activa (set) esta capacidad. Del mismo modo, cuando utilizamos -u, la desactiva, y por eso esa orden está permitida, porque es un programa de ejemplo.

Para todo lo demás, incluso nos podemos servir de expresiones regulares para Bash, o incluso la llamada a una función.

Y, para terminar, vamos a hacer sustituciones de órdenes dentro de un comando. O lo que es lo mismo, cambiar el comando que vamos a ejecutar. Podríamos afectar a cualquier parte de la orden ejecutada, aunque para hacer un sencillo ejemplo, vamos a cambiar las llamadas a sudo por gksudo, así vemos un bonito entorno gráfico y será más llevadero para el usuario:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/bin/bash
shopt -s extdebug

function changesudo() {
        if [ "$BASH_COMMAND" = "$PROMPT_COMMAND" ]; then
                return
        fi
        local this_command=`HISTTIMEFORMAT= history 1 | sed -e "s/^[ ]*[0-9]*[ ]*/$

        if [ "
shopt -u extdebug" == "$this_command" ]; then
                return 0
        fi
        eval ${this_command/sudo/gksudo}
        return 1
}

trap 'changesudo' DEBUG

Incorporar a nivel de sistema estos scripts

Los scripts que hagamos utilizando estas técnicas podemos cargarlos a nivel de usuario, introduciendo:

1
source script.sh

en el archivo ~/.bashrc o a nivel de sistema añadiendo esa línea en /etc/profile , /etc/bash.bashrc , o, mejor aún, en un nuevo archivo dentro de /etc/profile.d/

En las próximas semanas quiero publicar algunos ejemplos en los que veremos, aún más, la potencia de estas acciones.

Andrew Worley

También podría interesarte....