← Automatización con Scripts en Bash Bloque 10 · Integración y cierre · Tema 41 de 42

Tema 41

Proyecto: una herramienta de mantenimiento

A lo largo del curso cada tema terminó con un script completo, pero cada uno resolvía un problema y mostraba un puñado de técnicas. Este tema hace lo contrario: construye una sola herramienta, de principio a fin, que usa casi todo lo aprendido a la vez. Se llama mant, y hace el mantenimiento rutinario de un servidor: borra los archivos viejos, rota los registros, muestra el estado del equipo y se programa a sí misma para correr cada noche. Más que el código, lo que interesa aquí son las decisiones: cómo se diseña, se organiza, se prueba y se instala un script que otras personas van a usar durante años.

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:

RequisitoTécnicaTema
Varios subcomandos y opciones globalesgetopts, case, una función por subcomando13, 18, 21
Reglas en un archivo de configuraciónLectura segura de clave=valor, sin source16, 28, 30
Borrar y comprimir archivos por antigüedadfind con -mtime, -print0 y -delete32
Mensajes con niveles y un registroFunciones de registro, -v y -q25
Poder ver qué haría sin hacerloUn modo de simulación32, 36
No fallar en silencioModo estricto, trap ERR y EXIT, un aviso23, 24, 26, 40
Una sola ejecución a la vez, repetible sin dañoflock e idempotencia27
Correr cada noche sin intervenciónUn temporizador de systemd que instala ella misma35
Confianza para cambiarlaUn script de pruebas36, 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:

la interfaz, decidida antes del código
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:

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:

  1. Encabezado y modo estricto: qué es, cómo se usa, set -Eeuo pipefail. encabezado
  2. Valores iniciales: las variables globales, con sus valores por omisión, todas juntas. valores
  3. Funciones auxiliares: mensajes, registro, configuración, fallos, bloqueo. auxiliares
  4. Subcomandos: una función cmd_NOMBRE por cada uno. subcomandos
  5. La función main y, 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:

dentro de main
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

/etc/mant.conf
# 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.

las funciones de mensajes
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:

el único punto que cambia cosas
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.

terminal
$ 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.

terminal
$ 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:

/var/log/mant.log · un fallo, tal como queda registrado
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:

probar-mant
#!/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 ))
terminal
$ ./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

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:

terminal · instalar
$ 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:

mant.bash-completion
# /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:

mant
#!/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ó primeroQué pasabaCorrección
trap … ERR anotando $BASH_COMMAND de un fallo dentro de ejecutarEl 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ónCon 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

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

  1. Instala mant en una máquina virtual o un contenedor, con una configuración que limpie un directorio de prueba, y ejecútala con -n, con -v y de forma real. Luego ejecuta probar-mant.
  2. Agrega un subcomando config que muestre la configuración leída: las reglas de limpieza y de rotación, el registro y el avisador, después de validarlas.
  3. Agrega una regla vaciar = DIRECTORIO DÍAS que además borre los directorios que quedaron vacíos, con find -type d -empty -delete, y sus pruebas.
  4. Haz que todo ejecute también vigilar, del tema 40, si la configuración tiene una clave controles= con su archivo.
  5. Agrega al autocompletado que, después de programar, proponga algunas horas, como 01:00 03:15 05:00.
  6. 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.