Tienes cuatro scripts en ~/scripts y log() está escrita cuatro veces. error() también. ejecutar() va por su tercera copia. El día que decidas que el registro debe llevar el nivel además de la fecha tendrás que editar cuatro ficheros y acordarte de los cuatro; y el día que se te olvide uno, tendrás un script que registra distinto sin que nadie se entere hasta que haga falta. Las funciones resuelven eso, pero no solo eso: una función bien nombrada convierte diez líneas crípticas en una línea que se lee como una frase. Esta lección va de cuándo extraerlas, de las dos trampas que Bash reserva a quien viene de otros lenguajes —el ámbito dinámico y el return que solo devuelve números— y de cómo montar una librería de verdad, con carga robusta, guardas y tests. Al terminar existirá lib/comunes.sh y los scripts anteriores la usarán.

Contenido

  1. Cuándo extraer una función
  2. Sintaxis, y por qué no usamos function
  3. Los argumentos de una función son suyos
  4. Ámbito: local y el ámbito dinámico de Bash
  5. Devolver valores: return solo devuelve un número
  6. Funciones predicado
  7. Recursión, y sobrescribir comandos
  8. Librerías: lib/comunes.sh
  9. Qué debe y qué no debe hacer una librería
  10. Tests mínimos sin frameworks
  11. Aplicación: refactorizar los scripts

  1. Cuándo extraer una función

Tres criterios, y basta con que se cumpla uno:

  • La regla de tres. Has escrito lo mismo por tercera vez: la primera puede ser casualidad, la segunda impaciencia; la tercera es una función.
  • El bloque necesita un comentario para entenderse. Si ibas a escribir # comprueba si el release existe y no está vacío, ese comentario es en realidad el nombre: existe_release(). Y si hay más de dos niveles de anidamiento, como avisábamos en 04-04, un tercer nivel casi siempre es una función que falta.

El nombre importa tanto como el código: un buen nombre es un verbo o un predicado, describe qué hace y no cómo, y hace que la llamada se lea sin comentarios. Compara if [[ -d /opt/tramontana/releases/$v && -f /opt/tramontana/releases/$v/app.jar ]] con if existe_release "$v". Y un aviso de equilibrio: una función de una línea usada una sola vez suele ser ruido. La medida no es «cuantas más, mejor», sino «que cada nombre ahorre una explicación».

  1. Sintaxis, y por qué no usamos function

Bash admite nombre() { cuerpo; } (POSIX), function nombre { cuerpo; } (extensión de Bash/ksh) y la mezcla function nombre() { ... }. Usamos siempre la primera: es la única que funciona en cualquier shell POSIX, es la que espera shellcheck por defecto y no aporta nada ahorrarse los paréntesis. Detalles de sintaxis que dan errores misteriosos:

  • El cuerpo entre { } necesita espacios después de { y antes de }, porque { es una palabra reservada, no un símbolo, y la última instrucción antes de } necesita ; o un salto de línea.
  • Las funciones deben definirse antes de usarse: Bash lee el fichero de arriba abajo. De ahí que en 04-07 pongamos main "$@" como última línea del script.
  • Una función y un alias con el mismo nombre chocan, y gana el alias: otra razón para no abusar de ellos.

declare -f nombre muestra el código de una función ya definida y declare -F lista los nombres: son el equivalente de declare -p para variables, muy útiles cuando dudas de qué versión se ha cargado.

  1. Los argumentos de una función son suyos

Dentro de una función, $1, $@ y $# no son los del script: son los de la llamada. $0, en cambio, sigue siendo el nombre del script.

operador@srv-tramontana:~$ cat /tmp/args_fn.sh
mostrar() { printf 'fn: $#=%d $1=%s $0=%s\n' "$#" "${1:-vacío}" "${0##*/}"; }
printf 'script: $#=%d $1=%s\n' "$#" "${1:-vacío}"
mostrar alfa beta
mostrar                      # sin argumentos
operador@srv-tramontana:~$ bash /tmp/args_fn.sh 3.2.1
script: $#=1 $1=3.2.1
fn: $#=2 $1=alfa $0=args_fn.sh
fn: $#=0 $1=vacío $0=args_fn.sh

Es la confusión número uno de quien empieza: llamar a la función esperando que «vea» los argumentos del script. Si quieres pasárselos, hazlo explícitamente con mostrar "$@", que ya sabes de 04-03 que es la única forma que respeta los espacios. Y fíjate en ${1:-vacío}: con set -u activo, leer $1 en una función sin argumentos aborta el script, así que un valor por defecto es obligatorio si el argumento puede faltar.

  1. Ámbito: local y el ámbito dinámico de Bash

Toda variable creada dentro de una función debe declararse local. Sin local, la variable es global y sobrevive a la función, con consecuencias divertidas:

operador@srv-tramontana:~$ contar() { for i in 1 2 3; do :; done; }
operador@srv-tramontana:~$ for i in a b c; do contar; printf '%s ' "$i"; done; echo
3 3 3
operador@srv-tramontana:~$ contar() { local i; for i in 1 2 3; do :; done; }
operador@srv-tramontana:~$ for i in a b c; do contar; printf '%s ' "$i"; done; echo
a b c

En el primer caso contar pisó la i del llamante y el bucle exterior imprimió tres veces el 3 en vez de a b c. Un local i lo arregla. Con nombres genéricos como i, n, tmp o linea esto pasa constantemente.

Ahora la parte que sorprende a quien viene de otros lenguajes. Bash tiene ámbito dinámico, no léxico: una variable local es visible también dentro de las funciones que esa función llame, aunque estén definidas en otro fichero.

operador@srv-tramontana:~$ interior() { echo "interior ve: ${secreto:-nada}"; }
operador@srv-tramontana:~$ exterior() { local secreto="visible"; interior; }
operador@srv-tramontana:~$ exterior; interior
interior ve: visible
interior ve: nada

En un lenguaje de ámbito léxico —Python, Java, C— interior no vería nunca esa variable, porque solo ve lo que hay en el texto que la rodea; en Bash ve lo que haya en la pila de llamadas. Tiene un uso legítimo —pasar contexto sin argumentos— pero es sobre todo acoplamiento invisible: no te apoyes en ello, declara local en todas partes y pasa los datos por argumentos.

  1. Devolver valores: return solo devuelve un número

operador@srv-tramontana:~$ sumar() { return $(( $1 + $2 )); }
operador@srv-tramontana:~$ sumar 200 100; echo "$?"
44

300 se convirtió en 44, porque return acepta solo un entero de 0 a 255 y el valor se trunca módulo 256. return no es «devolver un resultado»: es «devolver un código de salida», exactamente el mismo concepto de 04-01 y 04-04. Para devolver datos hay tres caminos:

Forma Cómo Ventaja Inconveniente
stdout + $( ) printf dentro, x=$(fn) fuera Componible, sin efectos secundarios Crea un subshell: lento en bucles grandes
Global convenida La función asigna RESULTADO=... Rápida, sin subshell Contamina el espacio de nombres
Nameref (local -n) El llamante pasa el nombre de su variable Explícita y sin subshell Bash ≥ 4.3; colisiona si los nombres coinciden
# stdout: la forma preferente, y la única realmente componible
ruta_release() { printf '%s\n' "/opt/tramontana/releases/$1"; }
r=$(ruta_release 3.2.1)
# nameref: cuando devuelves varios valores o estás en un bucle apretado
partir_version() {
    local -n _mayor="$2" _menor="$3"   # _mayor pasa a SER la variable del llamante
    local resto="${1#*.}"
    _mayor="${1%%.*}"; _menor="${resto%%.*}"
}
partir_version "3.10.2" may men
printf 'mayor=%s menor=%s\n' "$may" "$men"     # -> mayor=3 menor=10

El guion bajo de _mayor no es capricho: si el llamante tuviera una variable llamada exactamente igual que el nameref, Bash daría referencia de variable circular. Prefijar los namerefs con _ reduce esa colisión a la práctica imposibilidad.

Regla del curso: stdout por defecto, y nameref solo cuando devuelvas varios valores o el subshell sea un problema medido, no imaginado.

  1. Funciones predicado

Una función que solo responde sí o no debe devolver 0 para «sí» y usarse directamente en un if, sin == true ni variables intermedias.

# es_numero CADENA -> 0 si es un entero no negativo, 1 si no.
es_numero() { [[ ${1:-} =~ ^[0-9]+$ ]]; }
# existe_release VERSION -> 0 si el release existe y no está vacío.
existe_release() {
    local dir="/opt/tramontana/releases/${1:-}"
    [[ -d $dir ]] && [[ -n "$(ls -A "$dir" 2>/dev/null)" ]]
}

operador@srv-tramontana:~$ es_numero 80 && echo sí; es_numero "8O" || echo no
sí
no
operador@srv-tramontana:~$ if existe_release 3.2.1; then echo "listo"; fi
listo

Ninguna de las dos lleva return: una función devuelve el código de su última instrucción, y la última instrucción es justamente el test. Escribir if ...; then return 0; else return 1; fi es cuatro veces más largo y hace lo mismo.

Un aviso que enlaza con 04-06: si la última instrucción puede fallar legítimamente —un (( contador )) que valga 0, un grep sin coincidencias— la función devolverá 1 y, con set -e, tumbará el script. Por eso log() acaba con return 0 explícito.

  1. Recursión, y sobrescribir comandos

Bash admite recursión, pero rara vez es la respuesta: no hay optimización de llamadas de cola, FUNCNEST limita la profundidad, y casi todo lo recursivo que hace un administrador —recorrer un árbol de directorios— lo resuelve find mejor y más rápido. Lo que sí es útil es sobrescribir un comando con una función, típicamente para añadir seguridad o registro; la clave es poder llamar al original:

rm() {
    error "usa 'ejecutar rm' en vez de rm directo, para respetar --dry-run"
    command rm "$@"      # command salta las funciones y llama al programa real
}

command ignora funciones y alias y ejecuta el binario o builtin; builtin fuerza el builtin del shell (útil si envuelves cd o echo). Sin uno de los dos, la función se llamaría a sí misma infinitamente. Y una advertencia: no sobrescribas comandos en una librería compartida; que rm haga cosas raras a diez metros de donde está escrito es una forma excelente de arruinarle la tarde a alguien.

  1. Librerías: lib/comunes.sh

Creamos ~/scripts/lib/comunes.sh con las funciones que se repiten:

#!/usr/bin/env bash
#
# comunes.sh - Funciones compartidas por los scripts de Tramontana.
# Autor : Operador de sistemas <operador@srv-tramontana>  Fecha: 2026-08-18
# NO se ejecuta: se carga con 'source'. Sin código de nivel superior más
# allá de definir funciones y de la guarda de inclusión.

# Guarda de inclusión múltiple: si dos scripts cargan la librería, 'return 0'
# aborta esta lectura sin abortar el script que hizo el source.
[[ -n ${TRAMONTANA_COMUNES_CARGADA:-} ]] && return 0
readonly TRAMONTANA_COMUNES_CARGADA=1
TRAMONTANA_VERBOSE="${TRAMONTANA_VERBOSE:-0}"
# log MENSAJE... -> stderr con marca de tiempo, solo si TRAMONTANA_VERBOSE=1.
#   Devuelve: siempre 0 (para no romper 'set -e' cuando VERBOSE=0).
log() {
    (( TRAMONTANA_VERBOSE )) && printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2
    return 0
}
# error MENSAJE... -> mensaje de error en stderr. Devuelve 0.
error() { printf '[%s] ERROR: %s\n' "$(date '+%F %T')" "$*" >&2; }
# morir CODIGO MENSAJE... -> registra el error y termina el SCRIPT con CODIGO.
morir() { local codigo="$1"; shift; error "$@"; exit "$codigo"; }

# requiere_comando NOMBRE...
#   Devuelve: 0 si todos están disponibles; 1 si falta alguno (lo dice).
requiere_comando() {
    local cmd faltan=0
    for cmd in "$@"; do
        command -v "$cmd" >/dev/null 2>&1 || { error "falta el comando: $cmd"; faltan=1; }
    done
    return "$faltan"
}
# confirmar PREGUNTA
#   Devuelve: 0 si responde s/sí. Sin terminal (cron), 1 sin preguntar.
confirmar() {
    local respuesta
    [[ -t 0 ]] || { error "sin terminal interactiva: no confirmo"; return 1; }
    read -r -p "${1:-¿Continuar?} [s/N] " respuesta
    [[ ${respuesta,,} == s || ${respuesta,,} == si ]]
}
# formatear_bytes N -> imprime N bytes en la unidad legible más cercana.
formatear_bytes() {
    LC_ALL=C awk -v b="${1:-0}" 'BEGIN {
        split("B KiB MiB GiB TiB", u, " "); i = 1
        while (b >= 1024 && i < 5) { b /= 1024; i++ }
        printf (i == 1 ? "%d %s\n" : "%.1f %s\n"), b, u[i]
    }'
}

Cargarla de forma robusta

source lib/comunes.sh solo funciona si el directorio de trabajo es ~/scripts, y cron ejecuta desde $HOME. La solución canónica:

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/comunes.sh
source "${SCRIPT_DIR}/lib/comunes.sh"

Desmenuzada de dentro afuera:

  • ${BASH_SOURCE[0]} es la ruta del fichero que se está leyendo. Se usa en vez de $0 porque si el fichero se carga con source, $0 sería el del script padre, no el suyo. dirname extrae su directorio, que puede ser relativo.
  • cd ... && pwd convierte esa ruta relativa en absoluta y resuelve los .., todo dentro de $( ), es decir en un subshell: tu directorio de trabajo real no cambia. El && garantiza que si el cd falla no se ejecuta el pwd (SC2164 de 04-01).
  • El comentario # shellcheck source=... le dice a ShellCheck dónde está el fichero para que pueda analizarlo; sin él avisa con SC1091.

  1. Qué debe y qué no debe hacer una librería

Debe No debe
Definir funciones y, como mucho, constantes Ejecutar trabajo al cargarse
Llevar guarda de inclusión múltiple Llamar a exit en el nivel superior
Documentar cada función: parámetros, salida, retorno Escribir en stdout salvo que sea su resultado
Usar local en todas sus variables Redefinir comandos del sistema
Prefijar lo que no sean funciones públicas Depender del directorio de trabajo

La prohibición de exit merece un matiz: una librería no debe llamarlo en su nivel superior —mataría el shell de quien la cargue con source, incluida tu terminal—, pero sí puede hacerlo dentro de una función cuya finalidad declarada sea terminar, como morir(). La diferencia está en quién decide: el autor de la librería no, el que llama sí.

Sobre los prefijos: en un proyecto propio, log y error son cómodos y aceptables; en una librería pensada para que otros la carguen, prefija todo (tram_log, tram_error), porque el día que alguien cargue tu librería y otra que también define log ganará la última leída, en silencio. Nosotros ya prefijamos las variables (TRAMONTANA_*), que es donde el choque sería más difícil de diagnosticar.

  1. Tests mínimos sin frameworks

Una librería sin pruebas es una librería que romperás sin enterarte. No hace falta framework: basta con ejecutar cada función con entradas conocidas y contar los fallos.

#!/usr/bin/env bash
#
# test_comunes.sh - Pruebas de lib/comunes.sh.
# Uso: test_comunes.sh   -> 0 si todo pasa, 1 si hay algún fallo.
# Sin -e a propósito: queremos ejecutar TODOS los casos, no parar en el primero.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/comunes.sh
source "${SCRIPT_DIR}/lib/comunes.sh"

fallos=0
comprobar() {   # comprobar DESCRIPCION ESPERADO OBTENIDO
    if [[ $2 == "$3" ]]; then printf '  ok    %s\n' "$1"
    else printf '  FALLO %s: esperaba [%s], obtuve [%s]\n' "$1" "$2" "$3" >&2
         (( ++fallos ))
    fi
}
comprobar "bytes 0"       "0 B"      "$(formatear_bytes 0)"
comprobar "bytes 2048"    "2.0 KiB"  "$(formatear_bytes 2048)"
comprobar "bytes release" "97.0 MiB" "$(formatear_bytes 101711872)"
requiere_comando bash awk 2>/dev/null
comprobar "requiere_comando existente" "0" "$?"
requiere_comando comando_inexistente_xyz 2>/dev/null
comprobar "requiere_comando ausente"   "1" "$?"
TRAMONTANA_VERBOSE=0; log "no debe verse" 2>/dev/null
comprobar "log callado devuelve 0" "0" "$?"

printf '\n%d fallo(s)\n' "$fallos"
(( fallos == 0 ))

operador@srv-tramontana:~$ ~/scripts/test_comunes.sh; echo "código: $?"
  ok    bytes 0
  ok    bytes 2048
  ok    bytes release
  ok    requiere_comando existente
  ok    requiere_comando ausente
  ok    log callado devuelve 0

0 fallo(s)
código: 0

Treinta líneas y ya tienes una red de seguridad. El caso log callado devuelve 0 no es decorativo: comprueba el return 0 del apartado 6, el que evita que set -e mate el script cuando el modo detallado está desactivado. Los tests valen sobre todo para eso: fijar las decisiones sutiles antes de que alguien las «simplifique».

  1. Aplicación: refactorizar los scripts

Con la librería lista, la cabecera de revision_salud.sh e informe_reservas.sh queda idéntica y las funciones duplicadas desaparecen:

set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/comunes.sh
source "${SCRIPT_DIR}/lib/comunes.sh"
requiere_comando curl df awk || morir 69 "faltan dependencias"

while getopts ":hvu:" opcion; do
    case "$opcion" in
        h)  uso; exit 0 ;;
        v)  TRAMONTANA_VERBOSE=1 ;;      # ahora la controla la librería
        u)  es_numero "$OPTARG" || morir 2 "el umbral debe ser un número: $OPTARG"
            UMBRAL_DISCO="$OPTARG" ;;
        \?) morir 2 "opción desconocida: -$OPTARG" ;;
        :)  morir 2 "la opción -$OPTARG necesita un valor" ;;
    esac
done
shift $(( OPTIND - 1 ))

operador@srv-tramontana:~$ ~/scripts/revision_salud.sh -u ochenta; echo "código: $?"
[2026-08-18 12:03:44] ERROR: el umbral debe ser un número: ochenta
código: 2

Tres ganancias concretas. morir 2 "..." sustituye a error ...; exit 2 en cinco sitios, así que ningún camino de error puede olvidarse del exit. La validación del umbral aparece por primera vez, y aparece porque es_numero ya existía y costaba una línea usarla. Y TRAMONTANA_VERBOSE es una sola variable en un solo fichero, en vez de cuatro VERBOSE que podían desincronizarse. Añade también purgar_releases.sh: su ejecutar() se muda a la librería tal cual, y su comprobación del release activo se convierte en existe_release "$activo" || morir 66 "el release activo no existe".

Errores Comunes y Consejos

  • Olvidar local. La variable se vuelve global y pisa la del llamante; con nombres como i, tmp o linea es cuestión de tiempo.
  • local x=$(comando) y creer que has comprobado el error. El código de salida que se ve es el de local, no el del comando: siempre 0. Declara primero (local x) y asigna después (x=$(comando)). Es SC2155, y lo veremos a fondo en 04-06.
  • Esperar que return devuelva un dato. Solo devuelve 0-255, y 300 se convierte en 44: los datos van por stdout. Y ojo con usar los argumentos del script dentro de una función: dentro, $1 es el de la función, así que pásalos con "$@". Define la función antes de usarla, porque Bash lee de arriba abajo y la llamada fallaría con orden no encontrada.
  • Poner código ejecutable en el nivel superior de una librería. Cargarla tendrá efectos que nadie pidió, y un exit allí cerrará la terminal de quien la cargue a mano. Y no la cargues con ruta relativa: funciona en tu terminal y falla en cron, que arranca desde otro directorio; usa SCRIPT_DIR.
  • Consejo: documenta cada función con tres líneas de contrato —qué recibe, qué imprime, qué devuelve— justo encima: es lo que permite usarla sin leer el cuerpo. Y ejecuta test_comunes.sh antes de cada commit de la librería: diez segundos que evitan romper cuatro scripts a la vez.
  • Consejo: si una función necesita más de cinco argumentos o más de treinta líneas, probablemente son dos funciones.

Ejercicios

Ejercicio 1. Explica qué imprime este fragmento y por qué, y luego arréglalo para que imprima lo que su autor esperaba.

procesar() {
    total=0
    for n in "$@"; do total=$(( total + n )); done
    return "$total"
}
total=1000
procesar 7 6 5 4 3
echo "reservas: $total"

Ejercicio 2. Añade a lib/comunes.sh una función resumen_casa CSV CASA que imprima casa;reservas;noches;importe para una casa de reservas.csv, con código 0 si existe y 1 si no. Añade dos casos a test_comunes.sh: mas-figueres (debe dar mas-figueres;7;21;2450.00) y una casa inexistente.

Ejercicio 3. Luis ha escrito esta librería. Encuentra los cinco problemas y reescríbela.

#!/bin/sh
echo "cargando librería..."
LOGFILE=/tmp/tramontana.log
function log { echo "$1" >> $LOGFILE; }
function comprobar_disco {
  uso=`df / | tail -1 | awk '{print $5}' | tr -d %`
  if [ $uso -gt 80 ]; then log "disco lleno"; exit 1; fi
}

Soluciones

Solución 1. Imprime reservas: 25, y es una casualidad afortunada que oculta dos errores.

Lo que ocurre: total dentro de procesar no es local, así que la función pisa la variable global del llamante; la suma 7+6+5+4+3 da 25 y esa es la que se imprime, no el 1000 asignado antes. El return "$total" es además inútil aquí y peligroso en general: si las reservas sumaran 300, return devolvería 44 (300 módulo 256) y quien lo usara obtendría un dato falso sin ningún aviso.

# Devuelve la suma por stdout, el canal correcto para un dato. 'local'
# impide pisar variables del llamante (también la 'n' del bucle, que es
# el olvido más frecuente).
procesar() {
    local suma=0 n
    for n in "$@"; do suma=$(( suma + n )); done
    printf '%d\n' "$suma"
}
total=1000
reservas=$(procesar 7 6 5 4 3)
printf 'previo: %d  reservas: %d\n' "$total" "$reservas"
# -> previo: 1000  reservas: 25

Ahora total conserva su valor y el resultado llega por su canal.

Solución 2.

# resumen_casa FICHERO_CSV CASA -> "casa;reservas;noches;importe".
#   Devuelve: 0 si la casa aparece en el fichero; 1 si no o si no se puede leer.
resumen_casa() {
    local csv="${1:-}" casa="${2:-}" salida
    [[ -r $csv ]] || { error "no puedo leer $csv"; return 1; }
    salida=$(LC_ALL=C awk -F';' -v c="$casa" '
        NR > 1 && $3 == c { r++; n += $5; i += $6 }
        END { if (r) printf "%s;%d;%d;%.2f\n", c, r, n, i }' "$csv")
    [[ -n $salida ]] || return 1
    printf '%s\n' "$salida"
}

# Casos añadidos a test_comunes.sh
CSV="/home/operador/datos/reservas.csv"
comprobar "resumen mas-figueres" "mas-figueres;7;21;2450.00" \
    "$(resumen_casa "$CSV" mas-figueres)"
resumen_casa "$CSV" casa-inexistente >/dev/null 2>&1
comprobar "resumen casa inexistente devuelve 1" "1" "$?"
operador@srv-tramontana:~$ ~/scripts/test_comunes.sh | tail -2
  ok    resumen mas-figueres
  ok    resumen casa inexistente devuelve 1

El diseño respeta las reglas del apartado 9: el dato sale por stdout, el error por stderr, el código de retorno distingue los casos, y la función no llama a exit ni asume directorio de trabajo. Que awk no imprima nada sin coincidencias es lo que permite distinguir «no existe» de «existe con ceros».

Solución 3. Los cinco problemas:

  1. #!/bin/sh con function. Contradictorio: dash no conoce function. Una librería que se carga con source lleva shebang por convención y para los editores, pero no debe ser ejecutable.
  2. echo "cargando librería..." en el nivel superior. Ejecuta trabajo al cargarse y escribe en stdout, contaminando la salida de cualquier script que la use. Una librería carga en silencio.
  3. exit 1 dentro de la librería. Mata el script del llamante sin darle opción, y si alguien la carga en su terminal, la cierra. La función debe informar y devolver un código.
  4. Sin local, sin guarda de inclusión y sin documentación. uso es global y pisará cualquier variable homónima, cargar el fichero dos veces redefine todo, y ninguna función dice qué devuelve.
  5. ` `, $uso sin comillas y LOGFILE fijo en /tmp. Un fichero de log predecible en /tmp es un riesgo de seguridad clásico (04-06), y [ $uso -gt 80 ] falla si uso queda vacío.
#!/usr/bin/env bash
# comunes_disco.sh - Utilidades de disco. Se carga con 'source'.
[[ -n ${TRAMONTANA_DISCO_CARGADA:-} ]] && return 0
readonly TRAMONTANA_DISCO_CARGADA=1
# uso_disco [PUNTO_MONTAJE] -> imprime el porcentaje de uso (solo el número).
#   Devuelve: 1 si no se pudo medir.
uso_disco() {
    local punto="${1:-/}" pct
    pct=$(df --output=pcent "$punto" 2>/dev/null | tail -1 | tr -dc '0-9') || return 1
    [[ -n $pct ]] || return 1
    printf '%s\n' "$pct"
}
# disco_por_encima UMBRAL [PUNTO_MONTAJE]
#   Devuelve: 0 si el uso supera UMBRAL, 1 si no, 2 si no se pudo medir.
disco_por_encima() {
    local umbral="${1:?falta el umbral}" punto="${2:-/}" pct
    pct=$(uso_disco "$punto") || { error "no puedo medir $punto"; return 2; }
    (( pct > umbral ))
}

Ahora quien la usa decide: disco_por_encima 80 || exit 0, o disco_por_encima 80 && purgar_releases.sh. La librería informa, el script actúa.

Conclusión

Tus scripts han dejado de repetirse, y de paso has entendido las dos rarezas de Bash que más confunden a quien viene de otros lenguajes.

  • Extraes funciones por la regla de tres, porque el bloque pedía un comentario o porque había un tercer nivel de anidamiento, y las nombras para que la llamada se lea como una frase. Usas la sintaxis nombre() { ... }, defines antes de usar, y sabes que dentro de una función $1 y $@ son los de la función, no los del script.
  • Declaras local en todas las variables de función, y entiendes que Bash tiene ámbito dinámico: lo que declares es visible en las funciones que llames, con el acoplamiento invisible que eso implica.
  • Sabes que return solo devuelve un código de 0 a 255 y conoces las tres formas reales de devolver datos, con stdout por defecto y local -n prefijado con _ cuando hace falta.
  • Escribes funciones predicado sin return explícito, aprovechando que la función devuelve el código de su última instrucción, y añades return 0 cuando esa última instrucción podría fallar legítimamente. Conoces command y builtin para llamar al original cuando sobrescribes un nombre, y la razón para no hacerlo en una librería compartida.
  • Tienes lib/comunes.sh con log(), error(), morir(), requiere_comando(), confirmar() y formatear_bytes(), cargada con SCRIPT_DIR, protegida con guarda de inclusión, documentada función a función y probada por test_comunes.sh.

Y un ejercicio de honestidad. revision_salud.sh lleva set -euo pipefail desde la primera lección y nunca has visto qué hace exactamente. log() acaba con un return 0 que ha aparecido dos veces «porque si no, el script muere», sin explicación real. informe_reservas.sh crea el directorio del informe y, si algo falla a mitad, lo deja a medio escribir. Y respaldo_tramontana.sh, prometido desde el Módulo 2, sigue sin existir. La siguiente lección, Depuración y Manejo de Errores, cierra todo eso: bash -x con un PS4 legible, los códigos de shellcheck que más importan, el modo estricto desmenuzado —incluidos los cinco sitios donde set -e no funciona—, trap con EXIT, ERR e INT para que ningún temporal ni ningún bloqueo sobreviva a una interrupción, la idempotencia como objetivo de diseño, y por fin el script de respaldo completo y blindado.

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