revision_salud.sh ya lee su configuración del entorno, pero sigue siendo un botón de una sola posición: no sabe explicarse, no admite «esta vez usa umbral 60» sin exportar una variable, y no hay forma de pedirle que calle. Un script sin interfaz es un script que solo puede usar quien lo escribió, y esta lección le da esa interfaz. Vas a aprender por dónde entra la información en un script y por dónde debe salir, y las dos preguntas tienen respuestas muy concretas: argumentos para lo que cambia en cada ejecución, stdin para los datos, stdout para el resultado y stderr para el humano. Esa disciplina es lo que hace que un programa sea componible, que encaje en una tubería como encajan grep o sort. Al final tendrás revision_salud.sh con opciones de verdad y nacerá informe_reservas.sh.

Contenido

  1. Los cuatro canales de entrada
  2. Parámetros posicionales
  3. $@ frente a $*: la diferencia que causa más bugs
  4. Validar argumentos y la función de ayuda
  5. Opciones: parseo manual y getopts
  6. Convenciones de línea de comandos
  7. read: leer del usuario y de un fichero
  8. Salida: stdout para el resultado, stderr para el humano
  9. Códigos de salida con significado
  10. Ficheros de configuración y el modo --dry-run
  11. Aplicación: opciones en revision_salud.sh y nace informe_reservas.sh

  1. Los cuatro canales de entrada

Un script puede recibir información por cuatro vías, y elegir mal es la primera decisión de diseño que se estropea.

Canal Cómo llega Úsalo para No lo uses para
Argumentos / stdin script.sh --umbral 60 agosto / cat datos | script.sh Lo que cambia en cada ejecución / flujos de datos y tuberías Datos grandes o secretos (visibles en ps) / configuración
Entorno UMBRAL=60 script.sh Contexto heredado, secretos, ajustes de despliegue Lo que se teclea a diario
Fichero de config. /etc/tramontana/app.conf Ajustes estables y compartidos Valores que cambian por ejecución

La regla de precedencia del curso, de más fuerte a más débil: argumentos > entorno > fichero de configuración > valores por defecto. Es la que espera cualquier administrador: permite fijar lo estable en un fichero y saltárselo puntualmente desde la línea de comandos.

  1. Parámetros posicionales

Al invocar script.sh alfa beta, Bash rellena un conjunto de variables especiales:

Variable Contenido
$0 / $1 … $9 El nombre con el que se invocó el script / los argumentos, en orden
${10} y siguientes A partir de 10 hacen falta las llaves: $10 se lee como $1 seguido de 0
$# / $@ / $* Número de argumentos / todos ellos (ver apartado siguiente)
operador@srv-tramontana:~$ cat /tmp/args.sh
printf 'invocado como : %s (corto: %s)\n' "$0" "${0##*/}"
printf 'argumentos    : %d -> [%s]\n' "$#" "$*"
shift 2
printf 'tras shift 2  : %d -> [%s]\n' "$#" "$*"
operador@srv-tramontana:~$ bash /tmp/args.sh --verbose 08 informe.txt
invocado como : /tmp/args.sh (corto: args.sh)
argumentos    : 3 -> [--verbose 08 informe.txt]
tras shift 2  : 1 -> [informe.txt]

shift descarta el primer argumento y desplaza el resto: $2 pasa a ser $1 y $# decrece; shift N descarta N de golpe. Es el mecanismo básico de cualquier bucle de parseo. ${0##*/} —expansión de parámetros de 04-02— da el nombre sin ruta, que es lo que quieres en la ayuda. Y puedes reasignar los posicionales con set -- "${@:-08}" para aplicar un valor por defecto; el -- es imprescindible, porque sin él set tomaría por opción suya cualquier argumento que empiece por -.

  1. $@ frente a $*: la diferencia que causa más bugs

Igual que con los arrays en 04-02, la diferencia solo aparece dentro de comillas dobles, y ahí se rompen los scripts en cuanto alguien pasa un nombre con espacios.

operador@srv-tramontana:~$ cat /tmp/comillas.sh          # tres bucles idénticos
for a in "$@"; do echo -n "[$a] "; done; echo '  <- "$@"'
for a in "$*"; do echo -n "[$a] "; done; echo '  <- "$*"'
for a in $@;   do echo -n "[$a] "; done; echo '  <- $@ sin comillas'
operador@srv-tramontana:~$ bash /tmp/comillas.sh "informe agosto.txt" reservas.csv
[informe agosto.txt] [reservas.csv]   <- "$@"
[informe agosto.txt reservas.csv]   <- "$*"
[informe] [agosto.txt] [reservas.csv]   <- $@ sin comillas

"$@" es la única forma correcta de reenviar los argumentos a otro comando: produce exactamente los mismos que recibiste, con sus espacios intactos. "$*" los une en una cadena separada por el primer carácter de IFS y solo sirve para imprimir. Sin comillas, ambos se parten y «informe agosto.txt» se convierte en dos ficheros inexistentes. La regla, sin excepciones: "$@" para pasar, "$*" para mostrar.

  1. Validar argumentos y la función de ayuda

Un script serio comprueba lo que recibe antes de hacer nada, y sabe explicarse:

uso() {
    cat <<EOF
Uso: ${0##*/} [opciones] <mes>
Genera el informe de reservas del mes indicado (07, 08 o 09).

Opciones:
  -o FICHERO   Escribe el informe en FICHERO en vez de en la ruta estándar
  -v           Modo detallado: explica cada paso por stderr
  -h           Muestra esta ayuda y termina
EOF
}

[[ $# -ge 1 ]] || { uso >&2; exit 2; }    # error: la ayuda va a stderr

Fíjate en un detalle que casi nadie hace bien y que separa a un script educado de uno maleducado: si el usuario pide ayuda con -h, la ayuda es el resultado y va a stdout con código 0; si se muestra porque se ha equivocado, es un mensaje de error y va a stderr con código distinto de 0. La diferencia importa: script.sh -h | less funciona en el primer caso, y script.sh 2>/dev/null no silencia el resultado legítimo en el segundo. El cat <<EOF es el here-document de 03-04: sin comillas en EOF, ${0##*/} se expande.

  1. Opciones: parseo manual y getopts

Parseo manual con while y case

Única forma de admitir opciones largas (--umbral) en Bash puro, y el patrón es siempre el mismo:

DRY_RUN=0; UMBRAL=80        # valores por defecto, más débiles que todo
while [[ $# -gt 0 ]]; do
    case "$1" in
        -h|--help)     uso; exit 0 ;;
        -n|--dry-run)  DRY_RUN=1; shift ;;
        -u|--umbral)   UMBRAL="$2"; shift 2 ;;
        --umbral=*)    UMBRAL="${1#*=}"; shift ;;
        --)            shift; break ;;
        -*)            printf 'Opción desconocida: %s\n' "$1" >&2; exit 2 ;;
        *)             break ;;
    esac
done

Cada rama consume lo suyo con shift (uno si es un interruptor, dos si lleva valor) y el bucle vuelve a empezar. --umbral=60 se resuelve con ${1#*=}, que recorta hasta el primer =; -- marca el fin de opciones; lo que empiece por - y no reconozcas es un error de uso, y cualquier otra cosa es el primer posicional y termina el parseo. (case se formaliza en 04-04.)

getopts para opciones cortas

Cuando te bastan las cortas, getopts es un builtin que hace todo el trabajo, incluido agrupar -vn como -v -n:

VERBOSE=0; UMBRAL=80
while getopts ":hvu:" opcion; do
    case "$opcion" in
        h)  uso; exit 0 ;;
        v)  VERBOSE=1 ;;
        u)  UMBRAL="$OPTARG" ;;
        \?) printf 'Opción desconocida: -%s\n' "$OPTARG" >&2; uso >&2; exit 2 ;;
        :)  printf 'La opción -%s necesita un valor\n' "$OPTARG" >&2; exit 2 ;;
    esac
done
shift $(( OPTIND - 1 ))

Cuatro piezas hay que entender:

  • La cadena ":hvu:". Cada letra es una opción; los dos puntos detrás de una letra (u:) significan «esta lleva valor».
  • Los dos puntos iniciales. Activan el modo silencioso: getopts deja de imprimir sus mensajes en inglés y te avisa con \? (opción desconocida) y : (falta el valor), poniendo la letra culpable en OPTARG. Ponlos siempre, o tu script mezclará mensajes suyos con mensajes de Bash.
  • OPTARG contiene el valor de la opción actual, y OPTIND el índice del siguiente argumento por procesar. El shift $(( OPTIND - 1 )) final descarta las opciones consumidas y deja en $1 el primer posicional real. Si olvidas esa línea, $1 seguirá siendo -v.

Existe además getopt (sin s, el programa externo de GNU), que sí admite opciones largas reordenando los argumentos, pero su sintaxis con eval set -- "$OPCIONES" es frágil y la versión de macOS no es compatible. Resumiendo: getopts si te bastan las cortas, parseo manual si necesitas las largas, y getopt solo si sabes exactamente lo que haces.

  1. Convenciones de línea de comandos

Respetarlas es lo que hace que otra persona pueda usar tu script sin leerlo.

Opción Significado esperado
-h, --help / --version Ayuda o versión a stdout y exit 0
-v, --verbose / -q, --quiet Más detalle por stderr / solo errores
-n, --dry-run / -f, --force Enseña lo que haría / salta las confirmaciones
-- Fin de opciones: lo que sigue son datos aunque empiece por -. Resuelve un problema real: rm -- "-informe.txt" es la única forma de borrar ese fichero

  1. read: leer del usuario y de un fichero

read toma una línea de stdin y la reparte en variables. Sus opciones útiles:

Opción Efecto
-r No interpreta \ como escape. Ponla siempre; sin ella una ruta de Windows se destroza
-p TEXTO / -s Muestra un aviso antes de leer / no hace eco, para contraseñas
-t N / -n N / -a A Espera N segundos / lee N caracteres sin Intro / reparte en el array A
operador@srv-tramontana:~$ read -r -s -p "Contraseña de la BD: " clave; echo
Contraseña de la BD:
operador@srv-tramontana:~$ echo "${#clave} caracteres leídos, no mostrados"
14 caracteres leídos, no mostrados

Ese -s es la forma correcta de pedir un secreto de forma interactiva: no queda en pantalla ni en el historial. En 04-07 veremos la alternativa para tareas desatendidas.

El patrón canónico para leer un fichero

while IFS= read -r linea; do ... done < fichero es de las líneas que conviene memorizar tal cual, porque cada pieza está ahí por un motivo concreto:

  • IFS= (vacío, y solo para este read) impide que Bash recorte los espacios del principio y del final; sin él, la indentación de un fichero de configuración desaparece. -r impide que las barras invertidas se interpreten como escapes, y < fichero al final del done —no una tubería— es lo importante, como vemos ahora.

El bucle que pierde las variables

operador@srv-tramontana:~$ contador=0; grep -c '' /var/log/tramontana/errores.log
87
operador@srv-tramontana:~$ cat /var/log/tramontana/errores.log |
>     while read -r l; do contador=$(( contador + 1 )); done
operador@srv-tramontana:~$ echo "$contador"
0

Ha recorrido 87 líneas y el contador vale 0. La razón la conoces desde 04-02: cada tramo de una tubería se ejecuta en un subshell, así que el while incrementó su copia y esa copia murió al cerrarse la tubería. El mismo muro del cd, disfrazado. Tres soluciones, en orden de preferencia:

# 1. Redirección directa: el while corre en el shell principal
while read -r l; do (( ++contador )); done < /var/log/tramontana/errores.log
# 2. Sustitución de proceso: cuando la entrada viene de un comando
while read -r l; do (( ++contador )); done < <(grep 500 /var/log/tramontana/errores.log)
# 3. lastpipe: hace que el ÚLTIMO tramo corra en el shell principal
shopt -s lastpipe
grep 500 /var/log/tramontana/errores.log | while read -r l; do (( ++contador )); done

La 2 es la que más usarás. La 3 tiene letra pequeña: lastpipe solo funciona con el control de trabajos desactivado, cosa que ocurre en un script no interactivo pero no en tu terminal, así que probarlo a mano puede engañarte.

  1. Salida: stdout para el resultado, stderr para el humano

La regla que convierte un script en una pieza de Unix: stdout es el resultado —lo que otro programa podría querer procesar, datos y solo datos— y stderr es todo lo demás: avisos, progreso, errores, mensajes de -v. Si los mezclas, mi_script.sh | awk '{...}' se atraganta con tu «Procesando...» y mi_script.sh > informe.txt deja los errores dentro del informe. Las dos funciones que garantizan la disciplina:

# Registro informativo y error, ambos con marca de tiempo ISO y a stderr.
log()   { printf '[%s] %s\n'        "$(date '+%F %T')" "$*" >&2; }
error() { printf '[%s] ERROR: %s\n' "$(date '+%F %T')" "$*" >&2; }

operador@srv-tramontana:~$ bash /tmp/demo_log.sh > /tmp/salida.txt
[2026-08-18 10:02:11] Comprobando el release activo
operador@srv-tramontana:~$ cat /tmp/salida.txt
3.2.1

Los mensajes han aparecido en la terminal y el fichero solo contiene el dato: eso es un script componible. Usamos printf en lugar de echo por la razón de 04-02: comportamiento definido y formato controlado.

  1. Códigos de salida con significado

Con la interfaz montada, el código de salida deja de ser «0 o 1» y pasa a ser documentación ejecutable. La convención de Tramontana toma prestados los valores de sysexits.h:

Código Nombre Cuándo
0 / 1 OK / error genérico Todo correcto / fallo no clasificado
2 Uso incorrecto Faltan argumentos, opción desconocida
64 / 65 EX_USAGE / EX_DATAERR Uso incorrecto, en scripts sysexits / datos mal formados
66 / 69 EX_NOINPUT / EX_UNAVAILABLE No se puede leer la entrada / un servicio no responde
73 / 78 EX_CANTCREAT / EX_CONFIG No se pudo crear la salida / configuración incorrecta

Lo esencial no es adoptar exactamente estos números, sino elegir unos, documentarlos en la cabecera y ser coherente: un script cuyo 69 significa siempre «la aplicación no responde» permite que quien lo llama actúe sin leer su salida.

  1. Ficheros de configuración y el modo --dry-run

Hay dos formas de leer un fichero de configuración, y una es peligrosa. source /etc/tramontana/app.conf es cómoda, pero el fichero es código que se ejecuta con tus permisos: si alguien con escritura sobre él añade rm -rf /, tu script lo ejecuta. Es el mismo riesgo que dejó una credencial expuesta en una copia con permisos 644. Con source solo se puede vivir si el fichero es tuyo, con permisos estrictos y verificados. La alternativa segura es parsearlo:

leer_config() {
    local fichero="$1" clave valor
    [[ -r $fichero ]] || return 0          # sin fichero, valores por defecto
    while IFS='=' read -r clave valor; do
        clave="${clave// /}"                          # quita espacios de la clave
        [[ -z $clave || $clave == \#* ]] && continue  # salta blancos y comentarios
        valor="${valor#"${valor%%[![:space:]]*}"}"    # y los del inicio del valor
        case "$clave" in
            max_conexiones)   MAX_CONEXIONES="$valor" ;;
            timeout_consulta) TIMEOUT="$valor" ;;
            *) : ;;                                   # desconocida: se ignora
        esac
    done < "$fichero"
}

Solo se aceptan las claves que el script conoce. Es más código, pero es la diferencia entre leer datos y ejecutar código ajeno.

El modo --dry-run es el otro patrón imprescindible, y se implementa con un envoltorio:

DRY_RUN=0
ejecutar() { if (( DRY_RUN )); then printf '[simulado] %s\n' "$*" >&2; else "$@"; fi; }
ejecutar rm -f /srv/tramontana/backups/temporales/parcial.tar

"$@" dentro de ejecutar reconstruye el comando con sus argumentos exactos —de ahí que el apartado 3 importara tanto— y con DRY_RUN=1 solo lo imprime. La regla de 03-03 se vuelve así automática: todo script que borre, mueva o sobrescriba algo tiene --dry-run.

  1. Aplicación: opciones en revision_salud.sh y nace informe_reservas.sh

La cabecera de revision_salud.sh pasa a documentar la sintaxis y los códigos (# Uso: revision_salud.sh [-h] [-v] [-u UMBRAL], # Salida: 0 correcto | 2 uso incorrecto), y entre la configuración de 04-02 y el cuerpo se intercala esto:

VERBOSE=0
log()   { (( VERBOSE )) && printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2; return 0; }
error() { printf '[%s] ERROR: %s\n' "$(date '+%F %T')" "$*" >&2; }
uso() {
    cat <<EOF
Uso: ${0##*/} [-h] [-v] [-u UMBRAL]
Comprueba la aplicación (puerto ${PUERTO}), el disco y los errores de hoy.
  -u UMBRAL  Porcentaje de disco a partir del cual avisar (defecto ${UMBRAL_DISCO})
  -v         Modo detallado por stderr
  -h         Esta ayuda
EOF
}

while getopts ":hvu:" opcion; do
    case "$opcion" in
        h)  uso; exit 0 ;;   v) VERBOSE=1 ;;   u) UMBRAL_DISCO="$OPTARG" ;;
        \?) error "opción desconocida: -$OPTARG"; uso >&2; exit 2 ;;
        :)  error "la opción -$OPTARG necesita un valor"; exit 2 ;;
    esac
done
shift $(( OPTIND - 1 ))

log "iniciando revisión (umbral de disco: ${UMBRAL_DISCO}%)"
# ... el cuerpo de 04-02, sin cambios ...  ->  log "revisión terminada"

UMBRAL_DISCO ya no puede ser readonly: la opción -u debe sobrescribir el valor del entorno, que a su vez sobrescribe el defecto. Es la precedencia del apartado 1, implementada.

operador@srv-tramontana:~$ ~/scripts/revision_salud.sh -v -u 60 > /tmp/salud.txt
[2026-08-18 10:14:03] iniciando revisión (umbral de disco: 60%)
[2026-08-18 10:14:04] revisión terminada
operador@srv-tramontana:~$ ~/scripts/revision_salud.sh -x; echo "código: $?"
[2026-08-18 10:14:20] ERROR: opción desconocida: -x
código: 2

El resultado fue al fichero y la traza a la terminal, tal como promete el apartado 8. Y fíjate en el return 0 de log(): sin él, cuando VERBOSE vale 0 la función devuelve 1 y con set -e el script moriría en la primera llamada; es una de las trampas que 04-06 desmenuza. Vamos con el script nuevo que Marta pidió para su informe mensual:

#!/usr/bin/env bash
#
# informe_reservas.sh - Informe de reservas de un mes de 2026.
# Autor : Operador de sistemas <operador@srv-tramontana>  Fecha: 2026-08-18
# Uso   : informe_reservas.sh [-o FICHERO] [-v] <mes: 07|08|09>
# Salida: 0 correcto | 2 uso incorrecto | 66 no se encuentra el CSV

set -euo pipefail
readonly CSV="${TRAMONTANA_CSV:-/home/operador/datos/reservas.csv}"
readonly BASE_TRABAJO="/home/operador/trabajo/2026"
SALIDA=""; VERBOSE=0
log()   { (( VERBOSE )) && printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2; return 0; }
error() { printf '[%s] ERROR: %s\n' "$(date '+%F %T')" "$*" >&2; }
uso()   { printf 'Uso: %s [-o FICHERO] [-v] <mes: 07|08|09>\n' "${0##*/}"; }

# Mismo bloque getopts del script anterior, con ":hvo:" y o) SALIDA="$OPTARG".
shift $(( OPTIND - 1 ))     # tras esto, $1 es el primer posicional real
[[ $# -eq 1 ]] || { error "hace falta un argumento: el mes"; uso >&2; exit 2; }
mes="$1"
[[ -r $CSV ]] || { error "no puedo leer $CSV"; exit 66; }

# Si no se indicó -o, la ruta estándar del mes en el árbol de trabajo.
: "${SALIDA:=${BASE_TRABAJO}/${mes}/informes/reservas-${mes}.txt}"
mkdir -p "${SALIDA%/*}" && log "escribiendo el informe en $SALIDA"

{
    printf 'INFORME DE RESERVAS - MES %s\n\n' "$mes"
    LC_ALL=C awk -F';' -v mes="2026-$mes" '
      NR>1 && $2 ~ "^" mes { r[$3]++; n[$3]+=$5; i[$3]+=$6; tr++; tn+=$5; ti+=$6 }
      END { printf "%-14s %9s %8s %12s\n", "CASA", "RESERVAS", "NOCHES", "IMPORTE"
            for (c in r) printf "%-14s %9d %8d %12.2f\n", c, r[c], n[c], i[c]
            printf "%-14s %9d %8d %12.2f\n", "TOTAL", tr, tn, ti }' "$CSV"
} > "$SALIDA"
log "informe terminado"
printf '%s\n' "$SALIDA"     # stdout: la ruta, para encadenar
exit 0
operador@srv-tramontana:~$ ruta=$(~/scripts/informe_reservas.sh -v 08)
[2026-08-18 10:21:40] escribiendo el informe en /home/operador/trabajo/2026/08/informes/reservas-08.txt
[2026-08-18 10:21:40] informe terminado
operador@srv-tramontana:~$ head -3 "$ruta"; tail -1 "$ruta"
INFORME DE RESERVAS - MES 08

CASA            RESERVAS   NOCHES      IMPORTE
TOTAL                 25       70      7842.50

Dos detalles de diseño: : "${SALIDA:=...}" usa el builtin : (que no hace nada) solo para forzar la asignación por defecto ${VAR:=valor} de 04-02, y el script imprime la ruta por stdout, de modo que ruta=$(...) la captura y el informe queda listo para encadenar con mail o scp.

Errores Comunes y Consejos

  • Olvidar shift $(( OPTIND - 1 )). El fallo número uno con getopts: las opciones siguen ocupando $1 y el script cree que le faltan argumentos. El segundo es usar $@ o $* sin comillas, con lo que cualquier argumento con espacios se parte.
  • read sin -r. Una ruta con \ se corrompe silenciosamente; no hay ningún caso en que quieras read sin -r. Y evita los bucles while read al final de una tubería: las variables mueren con el subshell, así que usa < fichero o < <(comando).
  • Mandar los mensajes de progreso a stdout. Contaminas el resultado y rompes cualquier tubería: todo lo que no sea el dato va a stderr. Y getopts sin los dos puntos iniciales hace que tu script imprima mensajes en inglés que no controlas.
  • Poner un secreto en la línea de comandos. Cualquiera con acceso a ps lo ve, y queda en ~/.bash_history. Fichero con permisos 600 o variable de entorno; lo cerramos en 04-07 y en 06-05.
  • Consejo: escribe la función uso() antes que el cuerpo; si te cuesta describir la interfaz, es que aún no la has decidido. Y haz que -h funcione siempre, incluso sin argumentos y sin configuración válida: es lo primero que prueba quien encuentra tu script.
  • Consejo: valida los argumentos en cuanto los tengas y sal inmediatamente si algo no cuadra. Cuanto antes falle, menos ha destrozado.

Ejercicios

Ejercicio 1. Escribe ~/scripts/contar_errores.sh, que lea por stdin un log con el formato de acceso.log y acepte -c CODIGO (por defecto 500), -v y -h. Debe imprimir por stdout solo el número de líneas con ese código y, con -v, cuántas líneas leyó en total por stderr. acceso.log tiene 412 líneas y 14 respuestas 500.

Ejercicio 2. Amplía informe_reservas.sh con -n/--dry-run, que muestre qué fichero se escribiría sin crearlo, usando el envoltorio ejecutar(). Explica por qué el parseo tiene que pasar de getopts a manual.

Ejercicio 3. Luis ha escrito esto para contar cuántas IP distintas hay en acceso.log y dice que «devuelve 0 siempre, será cosa del log»: n=0; cut -d' ' -f6 acceso.log | sort -u | while read ip; do n=$((n+1)); done; echo "$n". Diagnostícalo y da dos soluciones.

Soluciones

Solución 1. Cabecera y getopts como en los apartados 4 y 5, con ":hvc:" y CODIGO="$OPTARG". Lo interesante es el cuerpo:

# El bucle lee de stdin, heredado de quien invoca el script; al no haber
# tubería dentro, las variables sobreviven. read reparte la línea en
# campos por IFS: el código es el quinto del formato de acceso.log.
total=0; coincidencias=0
while read -r _f _h _m _r codigo _resto; do
    (( ++total ))
    [[ $codigo == "$CODIGO" ]] && (( ++coincidencias ))
done
(( VERBOSE )) && printf 'Leídas %d líneas buscando el código %s\n' "$total" "$CODIGO" >&2
printf '%d\n' "$coincidencias"      # stdout: solo el dato
exit 0
operador@srv-tramontana:~$ ~/scripts/contar_errores.sh -v < /var/log/tramontana/acceso.log
Leídas 412 líneas buscando el código 500
14

read con varios nombres trocea la línea por IFS sin llamar a cut ni a awk: 412 procesos ahorrados. Aquí sí omitimos IFS=, porque queremos precisamente la división en campos.

Solución 2. Hay que pasar a parseo manual porque getopts no admite opciones largas: --dry-run llegaría entera y getopts la leería como -, -d, -r… Se sustituye el while getopts por el bucle while [[ $# -gt 0 ]] del apartado 5, añadiendo la rama -n|--dry-run) DRY_RUN=1; shift ;;, y el envoltorio hace el resto:

ejecutar() { if (( DRY_RUN )); then printf '[simulado] %s\n' "$*" >&2; else "$@"; fi; }

ejecutar mkdir -p "${SALIDA%/*}"
if (( DRY_RUN )); then log "se escribiría el informe en $SALIDA"
else generar_informe > "$SALIDA"    # el bloque { ... } extraído a una función
fi
printf '%s\n' "$SALIDA"
operador@srv-tramontana:~$ ~/scripts/informe_reservas.sh --dry-run -v 09
[2026-08-18 10:33:02] se escribiría el informe en /home/operador/trabajo/2026/09/informes/reservas-09.txt
/home/operador/trabajo/2026/09/informes/reservas-09.txt
operador@srv-tramontana:~$ ls /home/operador/trabajo/2026/09/informes/

El directorio sigue vacío: nada se ha creado. Y nota que la redirección > "$SALIDA" no puede pasar por ejecutar, porque el shell la resuelve antes de invocar la función; de ahí que ese caso lleve su propio if. Es la limitación clásica del patrón.

Solución 3. El diagnóstico: while read es el último tramo de una tubería, así que corre en un subshell; n se incrementa en la copia y el echo del shell principal ve el 0 original. No tiene nada que ver con el log.

# A: sustitución de proceso, el while corre en el shell principal
n=0; while IFS= read -r ip; do (( ++n )); done < <(cut -d' ' -f6 acceso.log | sort -u)
# B: no usar un bucle en absoluto, que es lo correcto aquí
n=$(cut -d' ' -f6 acceso.log | sort -u | wc -l)
printf 'IPs distintas: %d\n' "$n"          # -> IPs distintas: 5

Las cinco IP conocidas (10.0.2.77, .31, .44, .52 y .18). Prefiere la B: si el bucle solo cuenta, no necesitas el bucle. Un tercer camino sería shopt -s lastpipe, pero en tu terminal interactiva no funciona y podrías creer que tu arreglo no sirve.

Conclusión

Tus scripts ya tienen interfaz, y con ella dejan de ser tuyos para ser utilizables.

  • Distingues los cuatro canales de entrada y aplicas la precedencia argumentos > entorno > configuración > defectos.
  • Manejas los parámetros posicionales con $#, shift, ${10} y set --, y sabes que "$@" es la única forma correcta de reenviar argumentos mientras que "$*" solo sirve para mostrar.
  • Escribes una función uso() que va a stdout si la piden y a stderr si es un error, y parseas opciones con getopts —cadena de opciones, dos puntos iniciales, OPTARG y el imprescindible shift $(( OPTIND - 1 ))— o a mano con while y case para las largas, respetando las convenciones -h, -v, -q, -n y --.
  • Usas read con -r siempre, y conoces el patrón canónico while IFS= read -r linea; do ... done < fichero, con las tres soluciones al bucle que pierde las variables por el subshell.
  • Separas stdout (el resultado) de stderr (el humano) con log() y error(), devuelves códigos de salida documentados, lees configuración parseándola en vez de con source, y llevas --dry-run incorporado con el envoltorio ejecutar().

Pero mira el código que has escrito hoy: está lleno de if, de case y de [[ ]] usados por intuición, sin haberlos estudiado. Es el momento de arreglarlo. En Estructuras de Control verás la idea que sostiene todo Bash —que se ramifica según el código de salida de un comando, no según un booleano—, los tres tipos de test y cuándo usar cada uno, la tabla completa de operadores de fichero, cadena y número, =~ con BASH_REMATCH para validar fechas, case con sus patrones y sus tres terminadores, todos los bucles con la advertencia de por qué nunca for f in $(ls), y una medición real sobre acceso.log que te dirá cuándo un bucle de Bash es mil veces más lento que un awk. Al terminarla, revision_salud.sh sabrá decir OK, AVISO o CRÍTICO, y purgar_releases.sh recorrerá /opt/tramontana/releases/ sin tocar la versión activa.

Curso de Linux: De Principiante a Administrador de Sistemas

Módulo 1: Introducción a Linux

Módulo 2: Comandos Básicos de Linux

Módulo 3: Habilidades Avanzadas en la Línea de Comandos

Módulo 4: Scripting en Shell

Módulo 5: Administración del Sistema

Módulo 6: Redes y Seguridad

Módulo 7: Temas Avanzados

Módulo 8: Proyectos Prácticos

© Copyright 2026. Todos los derechos reservados