Bash scripting best practices, portable patterns and defensive coding
Mejores practicas para scripts Bash: coding defensivo, portabilidad, patrones robustos y automatizacion confiable.
Bash es el lenguaje de glue por excelencia para:
#!/usr/bin/env bash
set -euo pipefail
# -e: Salir inmediatamente si un comando falla
# -u: Error si se usa una variable no definida
# -o pipefail: El pipeline falla si cualquier comando falla (no solo el ultimo)
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# Evita problemas con espacios en nombres de archivo
# SIEMPRE hacer quote de variables
readonly APP_NAME="myapp"
readonly VERSION="${1:-1.0.0}" # Default value si no se pasa argumento
# Mal - sin quotes, se rompe con espacios
cp $file $destination
# Bien - con quotes, seguro
cp "$file" "$destination"
# Arrays
declare -a FILES=("file1.txt" "file2.txt" "file with spaces.txt")
for f in "${FILES[@]}"; do
echo "Processing: $f"
done
# ${var:-default} - usa default si var no esta definida o esta vacia
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-5432}"
# ${var:?message} - error si var no esta definida
DB_PASSWORD="${DB_PASSWORD:?ERROR: DB_PASSWORD no esta definida}"
# ${var:+value} - usa value solo si var ESTA definida
VERBOSE_FLAG="${VERBOSE:+--verbose}"
# Constantes como readonly
readonly CONFIG_DIR="/etc/myapp"
readonly LOG_FILE="/var/log/myapp.log"
# Variables locales en funciones
my_function() {
local result=""
local -r max_retries=3 # local + readonly
result="computed value"
echo "$result"
}
# Documentar parametros y return
# Usar local para todas las variables
# Preferir echo/printf para retornar valores (no return con numeros)
##
# Verifica si un comando existe en el PATH.
# Args: $1 - nombre del comando
# Returns: 0 si existe, 1 si no
##
command_exists() {
local cmd="$1"
command -v "$cmd" &>/dev/null
}
##
# Crea un directorio si no existe, con logging.
# Args: $1 - path del directorio
##
ensure_dir() {
local dir="$1"
if [[ ! -d "$dir" ]]; then
mkdir -p "$dir"
log "Created directory: $dir"
fi
}
# Colores (solo si stdout es un terminal)
if [[ -t 1 ]]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
else
RED='' GREEN='' YELLOW='' BLUE='' NC=''
fi
log() { echo -e "${BLUE}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
die() { error "$*"; exit 1; }
# Uso
log "Starting deployment..."
warn "Config file not found, using defaults"
die "Cannot connect to database"
# Usar [[ ]] en vez de [ ] (mas seguro, soporta regex y globbing)
if [[ -f "$config_file" ]]; then
source "$config_file"
fi
# Comparacion de strings
if [[ "$env" == "production" ]]; then
log "Production mode"
fi
# Regex matching
if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
log "Valid email"
fi
# Verificar si variable esta vacia
if [[ -z "${MY_VAR:-}" ]]; then
die "MY_VAR is required"
fi
# Verificar si comando existe
if ! command_exists "docker"; then
die "Docker is not installed"
fi
# Iterar sobre archivos (maneja espacios en nombres)
while IFS= read -r -d '' file; do
echo "Processing: $file"
done < <(find /path -name "*.txt" -print0)
# Iterar sobre lineas de un archivo
while IFS= read -r line; do
echo "Line: $line"
done < "$input_file"
# Iterar sobre output de un comando
while IFS= read -r container; do
docker stop "$container"
done < <(docker ps -q)
# NUNCA hacer esto (se rompe con espacios y caracteres especiales):
# for file in $(find . -name "*.txt"); do ...
# Cleanup automatico al salir (exito o error)
cleanup() {
local exit_code=$?
log "Cleaning up..."
[[ -f "$TEMP_FILE" ]] && rm -f "$TEMP_FILE"
[[ -d "$TEMP_DIR" ]] && rm -rf "$TEMP_DIR"
exit "$exit_code"
}
trap cleanup EXIT
# Crear recursos temporales seguros
TEMP_FILE="$(mktemp)"
TEMP_DIR="$(mktemp -d)"
##
# Reintenta un comando N veces con backoff exponencial.
# Args: $1 - max intentos, $@ - comando a ejecutar
##
retry() {
local max_attempts="$1"
shift
local attempt=1
while (( attempt <= max_attempts )); do
if "$@"; then
return 0
fi
if (( attempt == max_attempts )); then
error "Command failed after $max_attempts attempts: $*"
return 1
fi
local wait_time=$(( 2 ** (attempt - 1) ))
warn "Attempt $attempt/$max_attempts failed. Retrying in ${wait_time}s..."
sleep "$wait_time"
(( attempt++ ))
done
}
# Uso
retry 3 curl -sf "https://api.example.com/health"
retry 5 docker push "myimage:latest"
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS] <command>
Options:
-e, --env ENV Environment (dev|staging|production)
-v, --verbose Enable verbose output
-h, --help Show this help
Commands:
deploy Deploy the application
rollback Rollback to previous version
Examples:
$(basename "$0") --env production deploy
$(basename "$0") -e staging -v deploy
EOF
exit "${1:-0}"
}
# Parsing de argumentos
parse_args() {
local env="" verbose=false
while [[ $# -gt 0 ]]; do
case "$1" in
-e|--env)
env="$2"
shift 2
;;
-v|--verbose)
verbose=true
shift
;;
-h|--help)
usage 0
;;
--)
shift
break
;;
-*)
die "Unknown option: $1"
;;
*)
break
;;
esac
done
# Validar argumentos requeridos
[[ -z "$env" ]] && die "Missing required option: --env"
[[ "$env" =~ ^(dev|staging|production)$ ]] || die "Invalid env: $env"
readonly ENV="$env"
readonly VERBOSE="$verbose"
}
# Siempre verificar antes de operar
[[ -f "$file" ]] || die "File not found: $file"
[[ -d "$dir" ]] || die "Directory not found: $dir"
[[ -r "$file" ]] || die "File not readable: $file"
[[ -w "$file" ]] || die "File not writable: $file"
[[ -x "$script" ]] || die "Script not executable: $script"
# Paths absolutos con realpath
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Archivos temporales seguros
TEMP_FILE="$(mktemp "${TMPDIR:-/tmp}/myapp.XXXXXXXXXX")"
# Para generar archivos de configuracion
cat > "$CONFIG_FILE" <<EOF
host=$DB_HOST
port=$DB_PORT
database=$DB_NAME
EOF
# Heredoc sin interpolacion (comillas en el delimitador)
cat > "$SCRIPT_FILE" <<'EOF'
#!/bin/bash
echo "This $variable won't be expanded"
EOF
#!/usr/bin/env bash
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Source helpers
source "$SCRIPT_DIR/lib/logging.sh"
main() {
log "Setting up project..."
check_dependencies
setup_env
install_tools
log "Setup complete!"
}
check_dependencies() {
local deps=("docker" "git" "curl" "jq")
for dep in "${deps[@]}"; do
command -v "$dep" &>/dev/null || die "Missing dependency: $dep"
done
}
setup_env() {
if [[ ! -f "$PROJECT_ROOT/.env" ]]; then
cp "$PROJECT_ROOT/.env.example" "$PROJECT_ROOT/.env"
log "Created .env from .env.example"
fi
}
install_tools() {
if ! command -v "bd" &>/dev/null; then
log "Installing bd..."
# install bd
fi
}
main "$@"
#!/usr/bin/env bash
set -euo pipefail
check_service() {
local name="$1" url="$2" expected_status="${3:-200}"
local status
status=$(curl -sf -o /dev/null -w "%{http_code}" "$url" 2>/dev/null) || status="000"
if [[ "$status" == "$expected_status" ]]; then
log "OK: $name ($url) -> $status"
return 0
else
error "FAIL: $name ($url) -> $status (expected $expected_status)"
return 1
fi
}
main() {
local failed=0
check_service "API" "http://localhost:8000/health" || (( failed++ ))
check_service "Frontend" "http://localhost:4321" || (( failed++ ))
if (( failed > 0 )); then
die "$failed service(s) unhealthy"
fi
log "All services healthy"
}
main "$@"
#!/usr/bin/env bash
set -euo pipefail
# .git/hooks/pre-commit
# Verificar que no se commiteen secrets ni archivos prohibidos
readonly FORBIDDEN_PATTERNS=(
'\.env$'
'credentials\.json'
'\.pem$'
'password\s*='
)
main() {
local found=false
for pattern in "${FORBIDDEN_PATTERNS[@]}"; do
if git diff --cached --name-only | grep -qE "$pattern"; then
error "Forbidden file pattern detected: $pattern"
found=true
fi
done
if [[ "$found" == true ]]; then
die "Remove forbidden files before committing"
fi
}
main
# Detectar OS
detect_os() {
case "$(uname -s)" in
Linux*) echo "linux" ;;
Darwin*) echo "macos" ;;
MINGW*|MSYS*|CYGWIN*) echo "windows" ;;
*) echo "unknown" ;;
esac
}
# Comandos con diferencias entre OS
if [[ "$(detect_os)" == "macos" ]]; then
SED_INPLACE="sed -i ''"
DATE_CMD="gdate" # brew install coreutils
else
SED_INPLACE="sed -i"
DATE_CMD="date"
fi
# Verificar version minima de bash
if (( BASH_VERSINFO[0] < 4 )); then
die "Bash 4+ is required (found: $BASH_VERSION)"
fi
# Instalar
brew install shellcheck # macOS
apt-get install shellcheck # Debian/Ubuntu
# Ejecutar
shellcheck script.sh
shellcheck -x script.sh # Seguir source/includes
shellcheck -s bash script.sh # Forzar shell dialect
# En CI
shellcheck scripts/*.sh
# Ignorar un warning especifico (con justificacion)
# shellcheck disable=SC2086 # Word splitting intencional aqui
echo $variable
# Desactivar regla para una linea
# shellcheck disable=SC2155
export MY_VAR="$(compute_value)"
# Desactivar regla para todo el archivo (al inicio)
# shellcheck disable=SC2034 # Variables usadas en source
#!/usr/bin/env bash
set -euo pipefail
# test_mylib.sh
source "$(dirname "$0")/../lib/mylib.sh"
test_count=0
fail_count=0
assert_eq() {
local expected="$1" actual="$2" msg="${3:-}"
(( test_count++ ))
if [[ "$expected" == "$actual" ]]; then
echo " PASS: $msg"
else
echo " FAIL: $msg (expected='$expected', got='$actual')"
(( fail_count++ ))
fi
}
assert_exit_code() {
local expected="$1"
shift
(( test_count++ ))
if "$@" >/dev/null 2>&1; then
local actual=0
else
local actual=$?
fi
if [[ "$expected" == "$actual" ]]; then
echo " PASS: exit code $expected for: $*"
else
echo " FAIL: expected exit $expected, got $actual for: $*"
(( fail_count++ ))
fi
}
echo "=== Running tests ==="
# Tests aqui
assert_eq "hello" "$(my_function "hello")" "my_function returns input"
assert_exit_code 0 command_exists "bash"
assert_exit_code 1 command_exists "nonexistent_command_xyz"
echo ""
echo "Results: $test_count tests, $fail_count failures"
exit "$fail_count"
set -euo pipefail en todos los scripts"$var" nunca $var[[ ]] en vez de [ ] para condicionaleslocal dentro de funcionesreadonly para constantesmktemp para archivos temporalesprintf sobre echo para output portablels — usar globbing o find -print0$(command) en su lugareval — inyeccion de codigodirnameset -ecd sin verificar exito — cd "$dir" || die "..."cat file | grep — usar grep pattern file directo#!/usr/bin/env bash