1. El proyecto
Todo servidor acumula basura: archivos temporales que nadie borra, cachés que crecen sin límite, registros que llenan el disco. La herramienta de este tema resuelve ese problema de forma configurable, y sus requisitos recorren el curso entero:
| Requisito | Técnica | Tema |
|---|---|---|
| Varios subcomandos y opciones globales | getopts, case, una función por subcomando | 13, 18, 21 |
| Reglas en un archivo de configuración | Lectura segura de clave=valor, sin source | 16, 28, 30 |
| Borrar y comprimir archivos por antigüedad | find con -mtime, -print0 y -delete | 32 |
| Mensajes con niveles y un registro | Funciones de registro, -v y -q | 25 |
| Poder ver qué haría sin hacerlo | Un modo de simulación | 32, 36 |
| No fallar en silencio | Modo estricto, trap ERR y EXIT, un aviso | 23, 24, 26, 40 |
| Una sola ejecución a la vez, repetible sin daño | flock e idempotencia | 27 |
| Correr cada noche sin intervención | Un temporizador de systemd que instala ella misma | 35 |
| Confianza para cambiarla | Un script de pruebas | 36, 38 |
2. Diseñar antes de escribir
La tentación es empezar por el código. Pero en una herramienta que otros van a usar, lo más difícil de cambiar después no es el código sino la interfaz: los nombres de los subcomandos, las opciones, el formato de la configuración, los códigos de salida. Una vez que alguien escribió un crontab o un script que llama a mant, cambiarlos rompe su trabajo. Por eso conviene decidirlos primero, escribiendo la ayuda antes que el programa:
mant [-c CONFIGURACIÓN] [-n] [-v | -q] SUBCOMANDO [ARGUMENTOS]
estado muestra el estado del equipo
limpiar borra los archivos viejos según las reglas limpiar=
rotar comprime y borra registros viejos según las reglas rotar=
todo limpiar y rotar: lo que ejecuta la tarea programada
programar [HORA] instala un temporizador de systemd para «todo»
desprogramar quita el temporizador
ayuda, version
Códigos de salida: 0 bien, 1 error, 2 uso incorrecto.
Algunas decisiones que vale la pena explicitar:
- Subcomandos con verbos, al estilo de
gitosystemctl:mant limpiarse lee como una orden. Las opciones globales van antes del subcomando, y cada subcomando decide sus propios argumentos. - Un subcomando para la tarea programada,
todo, en lugar de que el temporizador llame a varios. Así el temporizador no cambia si mañana se agrega una tarea nueva. - La configuración, en un lugar conocido y con un formato simple:
/etc/mant.confpara root,~/.config/mant/mant.confpara un usuario, y-cpara otro archivo, que es además lo que permite probarla sin tocar el sistema. - Lo destructivo, simulable: todo lo que borra o modifica pasa por un único punto que respeta
-n. - Códigos de salida documentados, que distinguen un error de un uso incorrecto, como hacen los comandos del sistema.
3. La estructura de un script grande
Un script de trescientas líneas necesita un orden predecible, para que quien lo abra encuentre lo que busca. mant sigue el que propuso el tema 4, con secciones marcadas por un comentario:
- Encabezado y modo estricto: qué es, cómo se usa,
set -Eeuo pipefail. encabezado - Valores iniciales: las variables globales, con sus valores por omisión, todas juntas. valores
- Funciones auxiliares: mensajes, registro, configuración, fallos, bloqueo. auxiliares
- Subcomandos: una función
cmd_NOMBREpor cada uno. subcomandos - La función
mainy, en la última línea,main "$@". main
Poner el programa principal dentro de una función main que se llama al final tiene dos ventajas. La primera: Bash lee un script a medida que lo ejecuta, de modo que si alguien edita el archivo mientras una ejecución está en curso, puede ejecutarse una mezcla de las dos versiones; con main al final, Bash tiene que leer el archivo entero antes de empezar a hacer algo. La segunda: todas las funciones están definidas antes de que se use cualquiera, y main puede tener variables locales como las demás.
4. Opciones globales y subcomandos
El análisis de la línea de comandos tiene dos etapas. Primero getopts, del tema 21, procesa las opciones globales, y se detiene en el primer argumento que no es una opción, que es el subcomando. Después un case lo valida contra la lista cerrada de subcomandos y comprueba sus argumentos:
shift $(( OPTIND - 1 ))
subcomando="${1:-ayuda}"
(( $# )) && shift
case "$subcomando" in
estado|limpiar|rotar|todo|desprogramar|ayuda|version)
(( $# == 0 )) || uso_error "$subcomando no lleva argumentos" ;;
programar)
(( $# <= 1 )) || uso_error "programar recibe como máximo una hora" ;;
*)
uso_error "subcomando desconocido: $subcomando" ;;
esac
…
"cmd_$subcomando" "$@"
La última línea llama a la función cuyo nombre se arma con el subcomando, la misma técnica de los temas 31 y 36. Es segura porque el case ya rechazó cualquier nombre que no esté en la lista: mant rm no ejecuta rm, sino que termina con un error de uso. Sin subcomando, mant muestra la ayuda, que es lo que espera quien la ejecuta por primera vez.
5. La ayuda
La ayuda es un documento en línea, como los del tema 31, con el delimitador sin comillas para que se expandan $PROG, $VERSION y la ruta de configuración que se usaría. Mostrar el valor actual de algo, como «configuración (ahora: /etc/mant.conf)», ahorra muchas consultas. Se llega a ella de tres maneras, mant, mant ayuda y mant -h, porque cada persona prueba una distinta. Los errores de uso, en cambio, no muestran la ayuda entera, que taparía el mensaje: dicen qué estuvo mal y sugieren mant ayuda, y terminan con el código 2.
6. La configuración
# Configuración de mant
registro = /var/log/mant.log
avisador = /usr/local/bin/avisar-webhook
# limpiar = DIRECTORIO DÍAS [PATRÓN]: borra los archivos de más de DÍAS días
limpiar = /var/tmp/tienda 2
limpiar = /var/cache/tienda/miniaturas 30 *.jpg
# rotar = DIRECTORIO DÍAS: comprime los .log de más de un día y borra los .gz de más de DÍAS días
rotar = /var/log/tienda 30
El formato es el clave=valor del tema 30, con una extensión: las claves limpiar y rotar pueden repetirse, y cada aparición agrega una regla a un array. El archivo nunca se ejecuta con source: se lee línea por línea, y cada línea tiene que coincidir con una expresión regular, del tema 28, que separa la clave del valor y descarta los espacios alrededor. Una clave desconocida o una regla mal formada detienen todo con el nombre del archivo y el número de línea, antes de que se haga ningún cambio.
Hay una validación más, que no es de forma sino de prudencia: el directorio de una regla tiene que ser absoluto y tener al menos dos niveles. limpiar = /var/tmp/tienda 2 se acepta; limpiar = / 2 o limpiar = /home 7 se rechazan. Una herramienta que borra archivos automáticamente cada noche no debe poder vaciar el disco entero por un error de tipeo.
7. Mensajes y registro
Las funciones de mensajes siguen el tema 25, en versión reducida: info para lo normal, detalle para lo que solo interesa con -v, aviso para lo que hay que saber aunque no detenga nada, y error para lo que sí. Todas escriben además en el archivo de registro, si la configuración define uno, con la fecha y el nivel. Con -q, que es como la ejecuta el temporizador, la salida queda en silencio salvo los avisos y los errores, que van a la salida de errores.
info() { registrar INFO "$*"; if (( nivel >= 1 )); then echo "$*"; fi; }
detalle() { registrar DEBUG "$*"; if (( nivel >= 2 )); then echo " $*"; fi; }
aviso() { registrar AVISO "$*"; echo "$PROG: aviso: $*" >&2; }
error() { registrar ERROR "$*"; echo "$PROG: $*" >&2; exit 1; }
Las dos primeras usan if y no la forma más corta (( nivel >= 2 )) && echo …, por una trampa del modo estricto que vimos en el tema 23: si la condición es falsa, esa línea termina con código 1, y como es la última de la función, la función entera devuelve 1. Llamada fuera de una condición, set -e detendría el script cada vez que un mensaje de detalle no se muestra. Con if, una condición falsa no es un error.
8. Simular antes de cambiar
Todo lo que modifica el sistema, desde borrar archivos hasta llamar a systemctl, pasa por una única función:
ejecutar() { # ejecuta un comando que cambia algo; con -n, solo lo muestra
local comando codigo
comando=$(printf '%q ' "$@")
if (( simular )); then
echo " (simulado) $comando"
return 0
fi
detalle "$comando"
"$@" || { codigo=$?; registrar ERROR "falló: $comando(código $codigo)"; return "$codigo"; }
}
Con -n, la función muestra el comando en lugar de ejecutarlo; sin -n, lo ejecuta y, si falla, anota en el registro exactamente qué comando fue. El comando se muestra con printf '%q', el mismo del tema 39, para que se vea como habría que escribirlo: -name \*.jpg y no -name *.jpg, que en una terminal se expandiría. Centralizar los cambios en un solo lugar tiene una ventaja que va más allá de la simulación: para auditar qué puede modificar la herramienta, basta con buscar ejecutar en el código.
9. Los subcomandos de trabajo
limpiar recorre las reglas, cuenta con find … -printf . | wc -c cuántos archivos coinciden, la técnica del tema 32 que no se confunde con nombres raros, y si hay alguno, los borra con find … -delete. Un directorio que no existe produce un aviso y se saltea, en lugar de detener las demás reglas: en un servidor, que falte la caché de una aplicación desinstalada no es motivo para dejar de limpiar el resto.
rotar hace dos cosas en cada directorio: comprime con gzip los .log que no se modificaron en el último día, es decir, los registros que su programa ya dejó de escribir, y borra los .log.gz de más de los días indicados. Si ya existe un .gz con ese nombre, avisa y no lo pisa. Es una versión sencilla de lo que hace logrotate, pensada para aplicaciones que escriben un registro por día; para los registros del sistema, logrotate y journald ya se ocupan.
estado no cambia nada: reúne en una pantalla lo que se mira al conectarse a un servidor, el tiempo encendido, la carga, la memoria, los discos y las unidades fallidas, con los comandos del tema 40. Por eso tampoco toma el candado.
$ sudo mant -n todo
Mantenimiento de web1, 2026-10-15 10:20:31
/var/tmp/tienda: archivos «*» de más de 2 días: 143
(simulado) find /var/tmp/tienda -mindepth 1 -type f -name \* -mtime +2 -delete
/var/cache/tienda/miniaturas: archivos «*.jpg» de más de 30 días: 2210
(simulado) find /var/cache/tienda/miniaturas -mindepth 1 -type f -name \*.jpg -mtime +30 -delete
(simulado) gzip -9 -- /var/log/tienda/acceso-2026-10-14.log
(simulado) gzip -9 -- /var/log/tienda/errores-2026-10-14.log
(simulado) find /var/log/tienda -type f -name \*.log.gz -mtime +30 -delete
/var/log/tienda: 2 comprimidos, 2 borrados de más de 30 días
Listo
$ mant estado
Equipo: web1, encendido desde 2026-09-28 06:12:40
Carga: 0.31 0.42 0.38
Memoria: 63 % disponible de 7.8 GiB
Discos:
Mounted on Use% Avail
/ 61% 15G
/var 94% 6.2G
Fallidas: ninguna
Último registro: 2026-10-15 03:17:52 INFO Listo
10. Programarse a sí misma
mant programar instala el temporizador de systemd que ejecuta mant -q todo cada día, con las técnicas del tema 35: un servicio oneshot con baja prioridad de procesador y de disco, y un temporizador con Persistent=true, para recuperar la ejecución si el equipo estaba apagado, y un retraso al azar. Si la ejecuta root, las unidades van a /etc/systemd/system; si la ejecuta un usuario, a su systemd personal.
Igual que timer-tarea, es idempotente: compara el contenido de cada unidad con el que escribiría, y solo si cambió las reescribe, recarga systemd y reinicia el temporizador. La hora se valida con systemd-analyze calendar, y la ruta del propio programa se obtiene con readlink -f "$0", de modo que el temporizador llama al mismo archivo que se ejecutó, esté donde esté. Con -n, muestra qué haría.
$ sudo mant programar
Created symlink /etc/systemd/system/timers.target.wants/mant.timer → /etc/systemd/system/mant.timer.
Programado: mant todo, cada día a las 03:15
$ sudo mant programar
Ya estaba programado así; no hay cambios
$ systemctl list-timers mant.timer
NEXT LEFT LAST PASSED UNIT ACTIVATES
Fri 2026-10-16 03:21:08 -03 16h - - mant.timer mant.service
1 timers listed.
11. Cuando algo falla
Una herramienta que corre sola a las tres de la mañana tiene que dejar rastro de cualquier fallo, y avisar. mant combina cuatro mecanismos:
- El modo estricto con
-E.set -Eeuo pipefailagrega a lo habitual la opción-E(errtrace), que hace que la trampaERRtambién se active dentro de las funciones. Sin ella, como casi todo el trabajo ocurre en funciones, la trampa casi nunca se dispararía. trap ERRanota en el registro el comando que falló, con su línea y su código, gracias a$BASH_COMMANDy$LINENO, como en el tema 26. Dos filtros evitan anotaciones inútiles: dentro de una subshell, como un$( … ), que hereda la trampa por el-E, no anota nada, porque lo anotará la shell principal; y si lo que «falló» es unreturncon un código de error, tampoco, porque la función ya anotó el motivo.trap EXITse ejecuta al terminar, de cualquier manera, y si la ejecución era la detodoy terminó con un error, llama al avisador configurado, por ejemplo elavisar-webhookdel tema 40.- Un candado con
flock, del tema 27, para los subcomandos que cambian cosas: si alguien ejecutamant limpiara mano justo cuando corre el temporizador, la segunda ejecución termina con un error en lugar de competir con la primera. La simulación no lo toma, porque no cambia nada.
2026-10-16 03:21:08 INFO inicio: todo
2026-10-16 03:21:08 INFO Mantenimiento de web1, 2026-10-16 03:21:08
2026-10-16 03:21:09 INFO /var/tmp/tienda: archivos «*» de más de 2 días: 37
2026-10-16 03:21:09 ERROR falló «cantidad=$(find "$dir" -mindepth 1 -type f -name "$patron" -mtime +"$dias" -printf . | wc -c)» (línea 164, código 1)
En este ejemplo, find encontró un subdirectorio sin permiso de lectura dentro de la caché; el registro dice qué línea falló y con qué comando, y el avisador envió un mensaje. El mensaje de find, Permission denied, quedó en la salida de errores, que con un temporizador va al registro del sistema: journalctl -u mant lo muestra.
12. Probarla
Una herramienta que borra archivos necesita pruebas automáticas, por la misma razón que build.sh las tenía en el tema 36: para cambiarla sin miedo. probar-mant arma en un directorio temporal un entorno de prueba, con archivos viejos y nuevos fechados con touch -d y una configuración propia, ejecuta mant sobre él con -c, y comprueba el resultado:
#!/usr/bin/env bash
#
# probar-mant — pruebas de mant en un directorio temporal.
# Uso: probar-mant [RUTA_DE_MANT]
#
set -uo pipefail # sin -e: una prueba que falla no detiene las demás
mant=$(readlink -f -- "${1:-./mant}")
T=$(mktemp -d)
trap 'rm -rf -- "$T"' EXIT
fallidas=0 total=0
comprobar() { # DESCRIPCIÓN COMANDO...: el comando debe terminar bien
total=$(( total + 1 ))
if "${@:2}" > /dev/null 2>&1; then
echo "ok $1"
else
echo "FALLA $1"
fallidas=$(( fallidas + 1 ))
fi
}
existe() { [[ -e "$1" ]]; }
no_existe() { [[ ! -e "$1" ]]; }
contiene() { grep -q -- "$2" "$1"; }
# Un entorno de prueba: archivos viejos y nuevos, y una configuración
mkdir -p "$T/tmp/app" "$T/log/app" "$T/bin"
touch -d '10 days ago' "$T/tmp/app/viejo.tmp" "$T/tmp/app/viejo.txt"
touch "$T/tmp/app/nuevo.tmp"
echo uno > "$T/log/app/ayer.log"; touch -d '2 days ago' "$T/log/app/ayer.log"
echo dos > "$T/log/app/hoy.log"
echo tres > "$T/log/app/viejo.log.gz"; touch -d '40 days ago' "$T/log/app/viejo.log.gz"
cat > "$T/mant.conf" <<EOF
# Configuración de prueba
registro = $T/mant.log
limpiar = $T/tmp/app 7 *.tmp
rotar = $T/log/app 30
EOF
printf '#!/bin/sh\necho "[systemctl $*]"\n' > "$T/bin/systemctl"; chmod +x "$T/bin/systemctl"
export PATH="$T/bin:$PATH" HOME="$T" XDG_RUNTIME_DIR="$T"
m() { "$mant" -c "$T/mant.conf" "$@"; }
# Simular no cambia nada
comprobar "simular no borra" m -n todo
comprobar " sigue viejo.tmp" existe "$T/tmp/app/viejo.tmp"
comprobar " ayer.log sin comprimir" existe "$T/log/app/ayer.log"
# La ejecución real
comprobar "todo termina bien" m -q todo
comprobar " borra viejo.tmp" no_existe "$T/tmp/app/viejo.tmp"
comprobar " respeta el patrón" existe "$T/tmp/app/viejo.txt"
comprobar " respeta nuevo.tmp" existe "$T/tmp/app/nuevo.tmp"
comprobar " comprime ayer.log" existe "$T/log/app/ayer.log.gz"
comprobar " no comprime hoy.log" existe "$T/log/app/hoy.log"
comprobar " borra viejo.log.gz" no_existe "$T/log/app/viejo.log.gz"
comprobar " escribe el registro" contiene "$T/mant.log" "inicio: todo"
comprobar "una segunda vez no falla" m -q todo
# Los errores
comprobar "subcomando desconocido: 2" bash -c '"$0" -c "$1" borrar; (( $? == 2 ))' "$mant" "$T/mant.conf"
printf 'limpiar = /tmp 1\n' > "$T/malo.conf"
comprobar "rechaza /tmp" bash -c '! "$0" -c "$1" limpiar' "$mant" "$T/malo.conf"
printf 'limpiar = %s/tmp/app 7\nbasura\n' "$T" > "$T/malo.conf"
comprobar "informa la línea mala" bash -c '"$0" -c "$1" limpiar 2>&1 | grep -q "malo.conf:2:"' "$mant" "$T/malo.conf"
# Un fallo en la tarea: queda en el registro y se avisa
printf '#!/bin/sh\n{ echo "$1"; cat; } > "%s/aviso.txt"\n' "$T" > "$T/bin/avisar"; chmod +x "$T/bin/avisar"
echo "avisador = $T/bin/avisar" >> "$T/mant.conf"
echo x > "$T/log/app/cerrado.log"; touch -d '2 days ago' "$T/log/app/cerrado.log"; chmod 000 "$T/log/app/cerrado.log"
comprobar "un fallo termina con error" bash -c '! "$0" -q -c "$1" todo' "$mant" "$T/mant.conf"
comprobar " el registro dice qué falló" contiene "$T/mant.log" "falló: gzip"
comprobar " se envió el aviso" contiene "$T/aviso.txt" "mant falló"
chmod 644 "$T/log/app/cerrado.log"
# Dos ejecuciones a la vez
flock "$T/mant.lock" sleep 3 &
sleep 0.5
comprobar "el candado impide otra" bash -c '! "$0" -q -c "$1" limpiar' "$mant" "$T/mant.conf"
wait
# Programar es idempotente
comprobar "programar" m programar 04:30
comprobar " crea el temporizador" contiene "$T/.config/systemd/user/mant.timer" "OnCalendar=04:30"
comprobar " la segunda vez no cambia" bash -c '"$0" -c "$1" programar 04:30 | grep -q "no hay cambios"' "$mant" "$T/mant.conf"
comprobar " rechaza una hora mala" bash -c '! "$0" -c "$1" programar 25:99' "$mant" "$T/mant.conf"
comprobar "desprogramar" m desprogramar
comprobar " borra el temporizador" no_existe "$T/.config/systemd/user/mant.timer"
echo "$(( total - fallidas )) de $total pruebas correctas"
(( fallidas == 0 ))
$ ./probar-mant ./mant
ok simular no borra
ok sigue viejo.tmp
ok ayer.log sin comprimir
ok todo termina bien
ok borra viejo.tmp
ok respeta el patrón
…
ok desprogramar
ok borra el temporizador
25 de 25 pruebas correctas
- El script de pruebas no usa
-e: una prueba que falla no debe detener las demás, sino contarse. La funcióncomprobarejecuta cualquier comando y lo informa comookoFALLA, y tres funciones de una línea,existe,no_existeycontiene, hacen que cada prueba se lea casi como una frase. - Prueba el camino feliz y los fallos: que la simulación no borre nada, que la ejecución real borre exactamente lo que debe y nada más, que una segunda ejecución no falle, que se rechacen las configuraciones peligrosas o mal escritas con el número de línea, que un fallo quede en el registro y produzca un aviso, que el candado funcione y que
programarsea idempotente. - Lo que no se puede probar, se reemplaza: el entorno de prueba pone en el
PATHunsystemctlfalso que solo muestra lo que recibe, y un avisador falso que escribe en un archivo. Así se prueba quemantlos llama bien, sin instalar temporizadores reales ni enviar mensajes. - Para provocar un fallo real, crea un registro sin permisos de lectura, que
gzipno puede comprimir.
Además de las pruebas, conviene pasar ShellCheck, del tema 26, por cada cambio. Y las pruebas tienen que correr en el mismo tipo de sistema que la herramienta: estas se ejecutaron en Linux, con find, gzip, flock y systemd-analyze reales.
13. Instalarla
Instalar es copiar cada archivo a su lugar con los permisos correctos. install hace las dos cosas en un paso, y su opción -b guarda una copia del archivo anterior. La configuración se copia solo si no existe, para no pisar la del administrador:
$ sudo install -m 755 mant /usr/local/bin/mant
$ [[ -e /etc/mant.conf ]] || sudo install -m 644 mant.conf /etc/mant.conf
$ sudo install -m 644 mant.bash-completion /etc/bash_completion.d/mant
$ sudo mant -n todo # revisar qué haría
$ sudo mant programar
La tercera línea instala el autocompletado: un pequeño archivo que le enseña a Bash qué palabras completar cuando se escribe mant y se pulsa Tab. Bash llama a la función registrada con complete -F, que encuentra en COMP_WORDS las palabras escritas y en COMP_CWORD la posición de la actual, y deja las propuestas en el array COMPREPLY; compgen -W elige, de una lista, las que empiezan como la palabra actual:
# /etc/bash_completion.d/mant — autocompletado de mant
_mant() {
local actual="${COMP_WORDS[COMP_CWORD]}" anterior="${COMP_WORDS[COMP_CWORD-1]}"
if [[ "$anterior" == -c ]]; then
mapfile -t COMPREPLY < <(compgen -f -- "$actual")
elif [[ "$actual" == -* ]]; then
mapfile -t COMPREPLY < <(compgen -W "-c -n -v -q -h" -- "$actual")
else
mapfile -t COMPREPLY < <(compgen -W "estado limpiar rotar todo programar desprogramar ayuda version" -- "$actual")
fi
}
complete -F _mant mant
Después de una opción -c completa nombres de archivo, con compgen -f; si la palabra empieza con un guion, las opciones; y en cualquier otro caso, los subcomandos. Así, mant li y Tab se completa como mant limpiar.
14. El código completo
Estas son las 317 líneas de mant. Cada sección corresponde a uno de los apartados anteriores:
#!/usr/bin/env bash
#
# mant — tareas de mantenimiento de un servidor: limpiar archivos viejos,
# rotar registros, mostrar el estado y programarse a sí mismo.
#
# Uso: mant [-c CONFIGURACIÓN] [-n] [-v | -q] SUBCOMANDO [ARGUMENTOS]
# mant ayuda
#
set -Eeuo pipefail
shopt -s nullglob
readonly VERSION="1.0.0"
readonly PROG="${0##*/}"
# ---------------------------------------------------------------- Valores iniciales
if (( EUID == 0 )); then
config="/etc/mant.conf"
else
config="${XDG_CONFIG_HOME:-$HOME/.config}/mant/mant.conf"
fi
simular=0
nivel=1 # 0: solo errores; 1: normal; 2: detallado
subcomando=""
registro="" avisador=""
limpiezas=() rotaciones=()
# ---------------------------------------------------------------- Mensajes y registro
registrar() { # NIVEL MENSAJE: una línea en el registro, si hay
if [[ -n "$registro" ]]; then
printf '%s %-5s %s\n' "$(date '+%F %T')" "$1" "$2" >> "$registro"
fi
}
info() { registrar INFO "$*"; if (( nivel >= 1 )); then echo "$*"; fi; }
detalle() { registrar DEBUG "$*"; if (( nivel >= 2 )); then echo " $*"; fi; }
aviso() { registrar AVISO "$*"; echo "$PROG: aviso: $*" >&2; }
error() { registrar ERROR "$*"; echo "$PROG: $*" >&2; exit 1; }
uso_error() { echo "$PROG: $*" >&2; echo "Pruebe: $PROG ayuda" >&2; exit 2; }
ejecutar() { # ejecuta un comando que cambia algo; con -n, solo lo muestra
local comando codigo
comando=$(printf '%q ' "$@")
if (( simular )); then
echo " (simulado) $comando"
return 0
fi
detalle "$comando"
"$@" || { codigo=$?; registrar ERROR "falló: $comando(código $codigo)"; return "$codigo"; }
}
# ---------------------------------------------------------------- Configuración
validar_regla() { # CLAVE VALOR LÍNEA: comprueba una regla limpiar= o rotar=
local partes
read -ra partes <<< "$2"
case "$1" in
limpiar) (( ${#partes[@]} == 2 || ${#partes[@]} == 3 )) || error "$config:$3: limpiar=DIRECTORIO DÍAS [PATRÓN]" ;;
rotar) (( ${#partes[@]} == 2 )) || error "$config:$3: rotar=DIRECTORIO DÍAS" ;;
esac
[[ "${partes[0]}" =~ ^/[^/]+/.+ ]] ||
error "$config:$3: el directorio debe ser absoluto y tener al menos dos niveles: ${partes[0]}"
[[ "${partes[1]}" =~ ^[0-9]+$ ]] || error "$config:$3: los días deben ser un número: ${partes[1]}"
}
leer_config() {
if [[ ! -f "$config" ]]; then
detalle "no hay archivo de configuración en $config"
return 0
fi
local n=0 linea clave valor
while IFS= read -r linea || [[ -n "$linea" ]]; do
n=$(( n + 1 ))
[[ "$linea" =~ ^[[:space:]]*(#|$) ]] && continue
[[ "$linea" =~ ^[[:space:]]*([a-z_]+)[[:space:]]*=[[:space:]]*(.*[^[:space:]])[[:space:]]*$ ]] ||
error "$config:$n: se esperaba clave=valor"
clave="${BASH_REMATCH[1]}" valor="${BASH_REMATCH[2]}"
case "$clave" in
registro) registro="$valor" ;;
avisador) avisador="$valor" ;;
limpiar) validar_regla limpiar "$valor" "$n"; limpiezas+=("$valor") ;;
rotar) validar_regla rotar "$valor" "$n"; rotaciones+=("$valor") ;;
*) error "$config:$n: clave desconocida: $clave" ;;
esac
done < "$config"
}
# ---------------------------------------------------------------- Fallos y bloqueo
al_fallar() { # trap ERR: anota qué comando falló y dónde
(( BASHPID == $$ )) || return 0 # en una subshell: lo anotará la shell principal
[[ "$BASH_COMMAND" == return* ]] && return 0 # una función que devuelve un error: ya se anotó
registrar ERROR "falló «$BASH_COMMAND» (línea $1, código $2)"
}
al_salir() { # trap EXIT: si la tarea programada falló, avisa
local codigo=$?
if (( codigo != 0 )) && [[ "$subcomando" == todo && -n "$avisador" ]]; then
printf '%s %s terminó con código %d.\nRegistro: %s\n' "$PROG" "$subcomando" "$codigo" "${registro:-ninguno}" |
"$avisador" "[$(hostname -s)] $PROG falló" || true
fi
}
bloquear() { # una sola ejecución que cambie cosas a la vez
local candado="${XDG_RUNTIME_DIR:-/tmp}/mant.lock"
(( EUID == 0 )) && candado="/run/lock/mant.lock"
exec 9> "$candado"
flock -n 9 || error "ya hay otra ejecución de $PROG en marcha"
}
# ---------------------------------------------------------------- Subcomandos
cmd_ayuda() {
cat <<EOF
$PROG $VERSION — tareas de mantenimiento
Uso: $PROG [-c CONFIGURACIÓN] [-n] [-v | -q] SUBCOMANDO [ARGUMENTOS]
Subcomandos:
estado muestra el estado del equipo
limpiar borra los archivos viejos según las reglas limpiar=
rotar comprime y borra registros viejos según las reglas rotar=
todo limpiar y rotar: lo que ejecuta la tarea programada
programar [HORA] instala un temporizador de systemd para «todo» (03:15)
desprogramar quita el temporizador
ayuda, version
Opciones:
-c ARCHIVO configuración (ahora: $config)
-n simular: mostrar qué se haría, sin hacerlo
-v, -q más detalle, o solo errores
Códigos de salida: 0 bien, 1 error, 2 uso incorrecto.
EOF
}
cmd_version() { echo "$PROG $VERSION"; }
cmd_estado() {
local fallidas
echo "Equipo: $(hostname), encendido desde $(uptime -s)"
echo "Carga: $(cut -d ' ' -f 1-3 /proc/loadavg)"
awk '/^MemTotal:/ { t = $2 } /^MemAvailable:/ { a = $2 }
END { printf "Memoria: %d %% disponible de %.1f GiB\n", a * 100 / t, t / 1048576 }' /proc/meminfo
echo "Discos:"
df -h --output=target,pcent,avail -x tmpfs -x devtmpfs -x squashfs -x overlay | sed 's/^/ /'
fallidas=$(systemctl --failed --no-legend --plain 2> /dev/null | awk '{ print $1 }' | paste -sd ' ')
echo "Fallidas: ${fallidas:-ninguna}"
if [[ -n "$registro" && -s "$registro" ]]; then
echo "Último registro: $(tail -n 1 -- "$registro")"
fi
}
cmd_limpiar() {
(( ${#limpiezas[@]} )) || { info "No hay reglas limpiar= en $config"; return 0; }
local regla dir dias patron cantidad
for regla in "${limpiezas[@]}"; do
read -r dir dias patron <<< "$regla"
patron="${patron:-*}"
if [[ ! -d "$dir" ]]; then
aviso "no existe $dir; se saltea"
continue
fi
cantidad=$(find "$dir" -mindepth 1 -type f -name "$patron" -mtime +"$dias" -printf . | wc -c)
if (( cantidad == 0 )); then
detalle "$dir: nada que borrar"
continue
fi
info "$dir: archivos «$patron» de más de $dias días: $cantidad"
ejecutar find "$dir" -mindepth 1 -type f -name "$patron" -mtime +"$dias" -delete
done
}
cmd_rotar() {
(( ${#rotaciones[@]} )) || { info "No hay reglas rotar= en $config"; return 0; }
local regla dir dias archivo comprimidos borrados
for regla in "${rotaciones[@]}"; do
read -r dir dias <<< "$regla"
if [[ ! -d "$dir" ]]; then
aviso "no existe $dir; se saltea"
continue
fi
comprimidos=0
while IFS= read -r -d '' archivo; do
if [[ -e "$archivo.gz" ]]; then
aviso "ya existe $archivo.gz; no se comprime $archivo"
continue
fi
ejecutar gzip -9 -- "$archivo"
comprimidos=$(( comprimidos + 1 ))
done < <(find "$dir" -type f -name '*.log' -mtime +0 -print0)
borrados=$(find "$dir" -type f -name '*.log.gz' -mtime +"$dias" -printf . | wc -c)
if (( borrados )); then
ejecutar find "$dir" -type f -name '*.log.gz' -mtime +"$dias" -delete
fi
info "$dir: $comprimidos comprimidos, $borrados borrados de más de $dias días"
done
}
cmd_todo() {
info "Mantenimiento de $(hostname -s), $(date '+%F %T')"
cmd_limpiar
cmd_rotar
info "Listo"
}
unidades() { # dónde van las unidades y cómo se llama a systemctl
if (( EUID == 0 )); then
dir_unidades=/etc/systemd/system
sc=(systemctl)
else
dir_unidades="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
sc=(systemctl --user)
fi
}
actualizar() { # escribe $2 en el archivo $1, solo si cambia
[[ -f "$1" && "$(< "$1")" == "$2" ]] && return 1
ejecutar mkdir -p -- "$(dirname -- "$1")"
if (( ! simular )); then
printf '%s\n' "$2" > "$1.tmp"
mv -f -- "$1.tmp" "$1"
fi
}
cmd_programar() {
local hora="${1:-03:15}" programa cambios=0
systemd-analyze calendar "$hora" &> /dev/null || uso_error "hora no válida: $hora"
programa=$(readlink -f -- "$0")
[[ "$programa$config" != *[[:space:]%\$]* ]] || error "las rutas no pueden tener espacios, % ni \$"
unidades
actualizar "$dir_unidades/mant.service" "[Unit]
Description=Mantenimiento con $PROG
[Service]
Type=oneshot
ExecStart=$programa -q -c $config todo
Nice=10
IOSchedulingClass=idle" && cambios=1
actualizar "$dir_unidades/mant.timer" "[Unit]
Description=Mantenimiento diario con $PROG
[Timer]
OnCalendar=$hora
Persistent=true
RandomizedDelaySec=10min
[Install]
WantedBy=timers.target" && cambios=1
if (( cambios )); then
ejecutar "${sc[@]}" daemon-reload
ejecutar "${sc[@]}" enable --now mant.timer
ejecutar "${sc[@]}" restart mant.timer
info "Programado: $PROG todo, cada día a las $hora"
else
ejecutar "${sc[@]}" enable --now mant.timer
info "Ya estaba programado así; no hay cambios"
fi
}
cmd_desprogramar() {
unidades
if [[ ! -e "$dir_unidades/mant.timer" ]]; then
info "No estaba programado"
return 0
fi
ejecutar "${sc[@]}" disable --now mant.timer
ejecutar rm -f -- "$dir_unidades/mant.timer" "$dir_unidades/mant.service"
ejecutar "${sc[@]}" daemon-reload
info "Ya no está programado"
}
# ---------------------------------------------------------------- Programa principal
main() {
local opcion
while getopts ":c:nvqh" opcion; do
case "$opcion" in
c) config="$OPTARG" ;;
n) simular=1 ;;
v) nivel=2 ;;
q) nivel=0 ;;
h) cmd_ayuda; exit 0 ;;
:) uso_error "a la opción -$OPTARG le falta su valor" ;;
*) uso_error "opción desconocida: -$OPTARG" ;;
esac
done
shift $(( OPTIND - 1 ))
subcomando="${1:-ayuda}"
(( $# )) && shift
case "$subcomando" in
estado|limpiar|rotar|todo|desprogramar|ayuda|version)
(( $# == 0 )) || uso_error "$subcomando no lleva argumentos" ;;
programar)
(( $# <= 1 )) || uso_error "programar recibe como máximo una hora" ;;
*)
uso_error "subcomando desconocido: $subcomando" ;;
esac
leer_config
trap 'al_fallar "$LINENO" "$?"' ERR
trap al_salir EXIT
case "$subcomando" in
limpiar|rotar|todo) (( simular )) || bloquear ;;
esac
if (( simular )); then
registrar INFO "inicio: $subcomando (simulado)"
else
registrar INFO "inicio: $subcomando"
fi
"cmd_$subcomando" "$@"
}
main "$@"
15. Lo que salió mal al escribirla
Estos son errores reales que aparecieron al escribir y probar mant, y que las pruebas detectaron. Son buenos ejemplos de por qué conviene tener pruebas:
| Lo que se escribió primero | Qué pasaba | Corrección |
|---|---|---|
trap … ERR anotando $BASH_COMMAND de un fallo dentro de ejecutar | El registro decía falló «"$@"»: el texto literal del comando, no el comando real. | ejecutar anota el comando expandido, y la trampa ignora los return. |
set -E y un fallo dentro de $( … ) | El fallo se anotaba dos veces: en la subshell, que hereda la trampa, y en la principal. | Anotar solo si $BASHPID es igual a $$. |
echo " (simulado) $*" | Mostraba -name *.jpg, que copiado en una terminal se expandiría. | printf '%q '. |
(( nivel >= 2 )) && echo … como última línea de una función | Con set -e, la función devolvía 1 cuando no mostraba nada. | if (( … )); then …; fi. |
"$cantidad archivos" | «1 archivos». | Un mensaje que no depende del número: «archivos: 1». |
16. Resumen
- En una herramienta que otros van a usar, la interfaz se diseña primero: subcomandos, opciones, configuración y códigos de salida, empezando por la ayuda.
- Un script grande sigue un orden fijo: encabezado, valores iniciales, funciones auxiliares, subcomandos y una función
mainllamada en la última línea. getoptsprocesa las opciones globales; uncasevalida el subcomando contra una lista cerrada, y"cmd_$subcomando"lo ejecuta.- La configuración se lee línea por línea, sin
source, con expresiones regulares, claves conocidas, reglas validadas y errores con el número de línea; las rutas peligrosas se rechazan. - Los mensajes tienen niveles y van también a un registro; dentro de funciones, se prefiere
ifa&¶ no devolver errores falsos. - Todo lo que cambia el sistema pasa por una función que respeta la simulación y anota los fallos.
set -E,trap ERRytrap EXITregistran y avisan los fallos;flockimpide dos ejecuciones a la vez.- La herramienta se programa a sí misma con un temporizador de systemd, de forma idempotente.
- Un script de pruebas arma un entorno temporal, reemplaza lo que no se puede probar y comprueba tanto lo que debe pasar como lo que no.
installcopia con permisos; un archivo de autocompletado concomplete -Fycompgencompleta los subcomandos y opciones.
17. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. ¿Por qué conviene diseñar la interfaz de una herramienta antes de escribir su código?
Porque es lo más difícil de cambiar después: una vez que otras personas y scripts usan los subcomandos, las opciones y la configuración, cambiarlos rompe su trabajo. El código interno, en cambio, se puede reescribir sin que nadie lo note.
2. ¿Qué ventajas tiene poner el programa principal en una función main llamada al final?
Que Bash tiene que leer el archivo entero antes de ejecutar nada, de modo que editarlo durante una ejecución no mezcla versiones; que todas las funciones están definidas antes de usarse; y que main puede tener variables locales.
3. ¿Por qué "cmd_$subcomando" "$@" no permite ejecutar cualquier comando?
Porque antes un case comprobó que el subcomando está en una lista cerrada. Cualquier otro valor termina con un error de uso antes de llegar a esa línea.
4. ¿Por qué la configuración no se carga con source?
Porque source ejecuta el archivo como código: cualquier línea, por error o a propósito, se ejecutaría con los permisos de la herramienta, que suele correr como root. Leerlo línea por línea y aceptar solo claves conocidas garantiza que solo se usen datos.
5. ¿Por qué mant rechaza limpiar = /home 7?
Porque exige que el directorio tenga al menos dos niveles. Es una protección contra errores de tipeo: una herramienta que borra archivos automáticamente no debe poder vaciar un directorio de primer nivel como / o /home.
6. ¿Por qué info usa if en lugar de (( nivel >= 1 )) && echo?
Porque si la condición es falsa, la forma con && termina con código 1, y al ser la última línea de la función, la función devuelve 1; con set -e, eso detendría el script. Un if con la condición falsa termina con 0.
7. ¿Qué hace la opción -E de set, y por qué la necesita mant?
Hace que la trampa ERR se herede en las funciones, las sustituciones de comandos y las subshells. Como casi todo el trabajo de mant ocurre en funciones, sin -E la trampa casi nunca se activaría.
8. ¿Para qué compara al_fallar los valores de $BASHPID y $$?
Para saber si se está ejecutando en una subshell, donde $BASHPID es distinto de $$. Con -E, un fallo dentro de $( … ) dispara la trampa en la subshell y otra vez en la shell principal; anotando solo en la principal, cada fallo queda registrado una vez.
9. ¿Por qué el script de pruebas reemplaza systemctl por un programa falso?
Para probar que mant programar escribe bien las unidades y llama a systemctl con los argumentos correctos, sin instalar temporizadores reales en el sistema donde se prueba. El programa falso, puesto primero en el PATH, solo muestra lo que recibe.
10. ¿Qué contienen COMP_WORDS, COMP_CWORD y COMPREPLY en una función de autocompletado?
COMP_WORDS, las palabras ya escritas en la línea; COMP_CWORD, la posición de la que se está completando; y COMPREPLY, el array donde la función deja las propuestas que Bash ofrecerá.
Para practicar
- Instala
manten una máquina virtual o un contenedor, con una configuración que limpie un directorio de prueba, y ejecútala con-n, con-vy de forma real. Luego ejecutaprobar-mant. - Agrega un subcomando
configque muestre la configuración leída: las reglas de limpieza y de rotación, el registro y el avisador, después de validarlas. - Agrega una regla
vaciar = DIRECTORIO DÍASque además borre los directorios que quedaron vacíos, confind -type d -empty -delete, y sus pruebas. - Haz que
todoejecute tambiénvigilar, del tema 40, si la configuración tiene una clavecontroles=con su archivo. - Agrega al autocompletado que, después de
programar, proponga algunas horas, como01:00 03:15 05:00. - Rompe a propósito algo de
mant, por ejemplo cambia-mtime +"$dias"por-mtime -"$dias", y comprueba qué pruebas fallan. ¿Hay algún cambio peligroso que las pruebas no detecten? Escribe la prueba que falta.