#!/usr/bin/env bash # -*- coding: utf-8 -*- # backup_instancias.sh — Backups automáticos y verificados de instancias SKEEN. # # Descubre dinámicamente las instancias a partir de las unidades systemd # `odoo-*.service`, lee de cada una su `-c ` (ExecStart) y de ahí la # base de datos, el puerto HTTP, el logfile y el data_dir. Para cada instancia: # # 1. pg_dump -Fc a /tmp y lo mueve a // # (_.dump, chmod 600 root:root). # 2. Verifica integridad con `pg_restore --list`; si falla, borra y reporta. # 3. Respalda la configuración del host en un tar.gz pequeño (odoo.conf, unit # systemd, vhost nginx y directorios de marca del frontend). # 4. (--verificar) Restaura a una DB temporal _verify_, compara # conteos de tablas clave y la borra. # 5. Rota según la política de retención (14 diarios / 8 semanales / 6 # mensuales), sin borrar el mensual vigente ni el último backup bueno. # # Uso: # backup_instancias.sh [--instancia ] [--solo-db] [--sin-config] # [--verificar] [--dry-run] [--backup-root ] # # Exit code: 0 si todo OK; 1 si alguna instancia falló; 3 si ya había otro # backup en curso (lockfile). set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="${SKEEN_BACKUP_ENV:-$SCRIPT_DIR/backup.env}" # ------------------------------------------------------------- valores por defecto BACKUP_ROOT=/root/backups LOG_FILE=/var/log/skeen-backup.log LOCK_FILE=/var/lock/skeen-backup.lock DB_HOST=localhost DB_PORT=5432 DB_USER=skeen DB_PASSWORD= PG_OS_USER=postgres VERIFY_MIN_FREE_FACTOR=2 RETAIN_DAILY=14 RETAIN_WEEKLY=8 RETAIN_MONTHLY=6 LOG_MAX_BYTES=5242880 SUPABASE_BACKUP=0 WACRM_BACKUP=0 if [[ -f "$ENV_FILE" ]]; then # shellcheck disable=SC1090 source "$ENV_FILE" fi # ---------------------------------------------------------------- argumentos INSTANCIA="" SOLO_DB=0 SIN_CONFIG=0 VERIFICAR=0 DRY_RUN=0 usage() { sed -n '3,33p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' } while (($#)); do case "$1" in --instancia) INSTANCIA="${2:-}"; shift 2 ;; --solo-db) SOLO_DB=1; SIN_CONFIG=1; shift ;; --sin-config) SIN_CONFIG=1; shift ;; --verificar) VERIFICAR=1; shift ;; --dry-run) DRY_RUN=1; shift ;; --backup-root) BACKUP_ROOT="${2:-}"; shift 2 ;; -h|--help) usage; exit 0 ;; *) echo "Opción desconocida: $1" >&2; exit 2 ;; esac done STAMP="$(date +%Y%m%d_%H%M%S)" # ------------------------------------------------------------------- logging _log_raw() { printf '%s\n' "$*" >>"$LOG_FILE"; } log() { local m="[$(date '+%F %T')] $*"; _log_raw "$m"; printf '%s\n' "$m"; } log_warn(){ local m="[$(date '+%F %T')] WARN $*"; _log_raw "$m"; printf '%s\n' "$m" >&2; } log_err() { local m="[$(date '+%F %T')] ERROR $*"; _log_raw "$m"; printf '%s\n' "$m" >&2; } log_ok() { local m="[$(date '+%F %T')] OK $*"; _log_raw "$m"; printf '%s\n' "$m"; } log_truncate_if_needed() { [[ -f "$LOG_FILE" ]] || return 0 local size size=$(stat -c %s "$LOG_FILE" 2>/dev/null || echo 0) if (( size > LOG_MAX_BYTES )); then mv -f "$LOG_FILE" "${LOG_FILE}.1" : >"$LOG_FILE" log_warn "log truncado por tamaño (>${LOG_MAX_BYTES} bytes); anterior en ${LOG_FILE}.1" fi } mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true log_truncate_if_needed MODE=""; (( DRY_RUN )) && MODE=", dry-run" log "=== Inicio de backup (stamp=$STAMP${INSTANCIA:+, instancia=$INSTANCIA}$MODE) ===" # --------------------------------------------------------------------- lock if ! exec 9>"$LOCK_FILE"; then log_err "no se pudo abrir el lock $LOCK_FILE" exit 1 fi if ! flock -n 9; then log_err "ya hay otro backup en curso (lock $LOCK_FILE). Saliendo sin hacer nada." exit 3 fi log "lock adquirido ($LOCK_FILE)" # ------------------------------------------------------------- utilidades # Lee una clave de un odoo.conf (sección [options], formato `clave = valor`). conf_get() { local file="$1" key="$2" [[ -f "$file" ]] || return 1 awk -v k="$key" ' /^[[:space:]]*\[/ { next } /^[[:space:]]*[#;]/ { next } { line=$0 sub(/^[[:space:]]+/, "", line) eq=index(line, "=") if (eq>0) { name=substr(line,1,eq-1); sub(/[[:space:]]+$/, "", name) if (name==k) { v=substr(line, eq+1); sub(/^[[:space:]]+/, "", v); sub(/[[:space:]]+$/, "", v); print v; exit } } }' "$file" } human_bytes() { numfmt --to=iec --suffix=B "${1:-0}" 2>/dev/null || echo "${1:-0}B" } # Lista las unidades odoo-*.service conocidas por systemd. list_units() { systemctl list-unit-files --type=service --no-legend --no-pager 2>/dev/null \ | awk '$1 ~ /^odoo-.*\.service$/ { print $1 }' } # Devuelve el vhost nginx de una instancia: por slug o por puerto. find_vhost() { local slug="$1" port="$2" f for f in "/etc/nginx/sites-enabled/$slug" "/etc/nginx/sites-available/$slug"; do [[ -f "$f" ]] && { echo "$f"; return 0; } done f=$(grep -rlE "127\.0\.0\.1:${port}\b" /etc/nginx/sites-enabled/ /etc/nginx/sites-available/ 2>/dev/null | head -1 || true) [[ -n "$f" ]] && { echo "$f"; return 0; } return 1 } # ------------------------------------------------ estructura de una instancia # Rellena INST_* para la unidad dada. Devuelve 1 si la unidad no es válida. load_instance() { local unit="$1" local fragment exec_line fragment=$(systemctl show "$unit" -p FragmentPath --value 2>/dev/null || true) [[ -z "$fragment" || ! -f "$fragment" ]] && return 1 exec_line=$(systemctl cat "$unit" 2>/dev/null | grep -E '^[[:space:]]*ExecStart=' | tail -1 || true) [[ -z "$exec_line" ]] && return 1 INST_SLUG="${unit#odoo-}" INST_SLUG="${INST_SLUG%.service}" INST_UNIT="$unit" INST_UNIT_PATH="$fragment" INST_CONF="$(printf '%s' "$exec_line" | sed -nE 's/.*[[:space:]]-c[[:space:]]+([^[:space:]]+).*/\1/p')" [[ -z "$INST_CONF" ]] && return 1 [[ "$INST_CONF" != /* ]] && INST_CONF="/root/$INST_CONF" INST_DB="$(conf_get "$INST_CONF" db_name || true)" [[ -z "$INST_DB" ]] && return 1 INST_PORT="$(conf_get "$INST_CONF" http_port || true)" INST_DATA_DIR="$(conf_get "$INST_CONF" data_dir || true)" INST_DB_HOST="$(conf_get "$INST_CONF" db_host || true)" INST_DB_PORT="$(conf_get "$INST_CONF" db_port || true)" INST_DB_USER="$(conf_get "$INST_CONF" db_user || true)" INST_LOG="$(printf '%s' "$exec_line" | sed -nE 's/.*--logfile=([^[:space:]]+).*/\1/p')" if [[ -z "$INST_LOG" ]]; then INST_LOG="$(conf_get "$INST_CONF" logfile || true)" fi [[ -z "$INST_LOG" ]] && INST_LOG="/tmp/odoo-${INST_SLUG}.log" return 0 } # ------------------------------------------------------------- pg_dump dump_db() { local db="$1" out="$2" if [[ -n "$DB_PASSWORD" ]]; then PGPASSWORD="$DB_PASSWORD" pg_dump -Fc -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$db" -f "$out" else su "$PG_OS_USER" -c "pg_dump -Fc -d $(printf %q "$db") -f $(printf %q "$out")" fi } pg_restore_file() { # Restaura por peer como superusuario local (el dump debe ser legible por # el usuario PG_OS_USER). Usado por la prueba de restauración. local db="$1" dump="$2" su "$PG_OS_USER" -c "pg_restore --no-owner --no-privileges -d $(printf %q "$db") $(printf %q "$dump")" } # Ejecuta SQL (tuples only) contra postgres por peer (superusuario local). psql_su() { local db="$1" sql="$2" su "$PG_OS_USER" -c "psql -d $(printf %q "$db") -Atc $(printf %q "$sql")" 2>/dev/null } # ------------------------------------------------- respaldo de configuración backup_config() { local slug="$1" unit="$2" conf="$3" vhost="$4" data_dir="$5" db="$6" port="$7" out="$8" local stage stage="$(mktemp -d /tmp/skeen-cfg.XXXXXX)" local -a seen=() saw_dir() { local d; for d in "${seen[@]:-}"; do [[ "$d" == "$1" ]] && return 0; done; return 1; } mkdir -p "$stage/systemd" "$stage/nginx" "$stage/frontend" [[ -f "$conf" ]] && cp -a "$conf" "$stage/odoo.conf" [[ -f "$unit" ]] && cp -a "$unit" "$stage/systemd/" [[ -n "$vhost" && -f "$vhost" ]] && cp -a "$vhost" "$stage/nginx/" local d base for d in "/var/www/${slug}-frontend/brand" \ "/var/www/${slug}-frontend/skeen-brand" \ "/var/www/skeen-frontend/brand" \ "/var/www/skeen-frontend/skeen-brand"; do [[ -d "$d" ]] || continue saw_dir "$d" && continue seen+=("$d") base="$(basename "$(dirname "$d")")-$(basename "$d")" cp -a "$d" "$stage/frontend/$base" done { echo "Instancia: $slug" echo "Unidad: $unit" echo "Conf: $conf" echo "Base: $db" echo "Puerto: $port" echo "data_dir: $data_dir" echo "Vhost: ${vhost:-}" echo "Generado: $(date '+%F %T')" echo "Host: $(hostname)" echo "Contenido: odoo.conf, systemd/, nginx/, frontend/" } >"$stage/MANIFEST.txt" if (( DRY_RUN )); then log " [dry-run] escribiría config: $out" rm -rf "$stage" return 0 fi tar czf "$out" -C "$stage" . rm -rf "$stage" chmod 600 "$out" chown root:root "$out" 2>/dev/null || true } # --------------------------------------------------------------- rotación # Plan de rotación GFS. Imprime las rutas a borrar (una por línea), sin borrar. rotation_plan() { local dir="$1" local -a stamps=() local f st shopt -s nullglob for f in "$dir"/*.dump; do if [[ "$f" =~ _([0-9]{8}_[0-9]{6})\.dump$ ]]; then stamps+=("${BASH_REMATCH[1]}") fi done shopt -u nullglob ((${#stamps[@]})) || return 0 local sorted sorted="$(printf '%s\n' "${stamps[@]}" | sort -r)" declare -A keep=() month_rep=() week_rep=() day_rep=() local mcount=0 wcount=0 dcount=0 local first=1 newest="" for st in $sorted; do local d="${st:0:8}" month="${st:0:6}" iso day iso="$(date -d "${d:0:4}-${d:4:2}-${d:6:2}" +%G-W%V 2>/dev/null || echo "x")" day="$d" if (( first )); then newest="$st"; keep["$st"]="ultimo"; first=0; fi if [[ -z "${month_rep[$month]:-}" && $mcount -lt $RETAIN_MONTHLY ]]; then month_rep[$month]="$st"; keep["$st"]="mensual"; mcount=$((mcount+1)); continue fi if [[ -z "${week_rep[$iso]:-}" && $wcount -lt $RETAIN_WEEKLY ]]; then week_rep[$iso]="$st"; keep["$st"]="semanal"; wcount=$((wcount+1)); continue fi if [[ -z "${day_rep[$day]:-}" && $dcount -lt $RETAIN_DAILY ]]; then day_rep[$day]="$st"; keep["$st"]="diario"; dcount=$((dcount+1)); continue fi done for st in $sorted; do [[ -n "${keep[$st]:-}" ]] && continue # borra el dump y su config tar gemelo (si existe) local dfile dfile=$(ls "$dir"/*_"$st".dump 2>/dev/null | head -1 || true) [[ -n "$dfile" ]] && echo "$dfile" local cfile cfile=$(ls "$dir"/*_"$st".config.tar.gz 2>/dev/null | head -1 || true) [[ -n "$cfile" ]] && echo "$cfile" done } rotate() { local dir="$1" slug="$2" local -a to_delete=() local line while IFS= read -r line; do [[ -n "$line" ]] && to_delete+=("$line") done < <(rotation_plan "$dir") if ((${#to_delete[@]} == 0)); then log " rotación: nada que borrar en $dir" return 0 fi local f for f in "${to_delete[@]}"; do if (( DRY_RUN )); then log " rotación [dry-run] borraría $(basename "$f")" else rm -f "$f" log " rotación: borrado $(basename "$f")" fi done local ndumps ncfgs ndumps=$(ls "$dir"/*.dump 2>/dev/null | wc -l) ncfgs=$(ls "$dir"/*.config.tar.gz 2>/dev/null | wc -l) log " rotación: quedan $ndumps dumps y $ncfgs configs en $dir" } # --------------------------------------------------------- restaurar y verificar verify_restore() { local slug="$1" db="$2" dump="$3" local tmpdb="${db}_verify_${STAMP}" tmpdb="${tmpdb:0:63}" # espacio libre en el filesystem de PostgreSQL local pgdata free_kb dbsize_kb pgdata="$(psql_su postgres 'show data_directory' | head -1)" dbsize_kb="$(psql_su postgres "select pg_database_size('$db')/1024" | head -1)" free_kb="$(df -Pk "${pgdata:-/var/lib/postgresql}" | awk 'NR==2{print $4}')" if [[ -n "$dbsize_kb" && -n "$free_kb" ]] && (( free_kb < dbsize_kb * VERIFY_MIN_FREE_FACTOR )); then log_warn " verificación OMITIDA: espacio libre insuficiente ($(human_bytes $((free_kb*1024))) < ${VERIFY_MIN_FREE_FACTOR}x base ($(human_bytes $((dbsize_kb*1024)))))" return 0 fi log " verificación: restaurando $dump a la DB temporal $tmpdb" if (( DRY_RUN )); then log " [dry-run] createdb $tmpdb; pg_restore; conteos; dropdb $tmpdb" return 0 fi if ! su "$PG_OS_USER" -c "createdb -T template0 $(printf %q "$tmpdb")" 2>>"$LOG_FILE"; then log_err " verificación: no se pudo crear $tmpdb" return 1 fi # El dump es root:600. Se copia a un temporal legible por el superusuario # postgres y se restaura por peer (evita passwords y problemas de permisos # con extensiones que requieren superusuario). local tmpd tmpd="$(mktemp /tmp/skeen-vrf.XXXXXX)" cp -a "$dump" "$tmpd" chmod 640 "$tmpd" chown "${PG_OS_USER}:${PG_OS_USER}" "$tmpd" local restore_rc=0 pg_restore_file "$tmpdb" "$tmpd" >>"$LOG_FILE" 2>&1 || restore_rc=$? rm -f "$tmpd" # pg_restore puede devolver !=0 por avisos benignos (comentarios/acl); la # prueba real es la comparación de conteos. Se registra pero no aborta. (( restore_rc == 0 )) || log_warn " verificación: pg_restore devolvió $restore_rc (ver $LOG_FILE); se valida por conteos" local tables=(res_partner skeen_cita skeen_venta) local t orig rest status rc=0 log " verificación: comparación de conteos" for t in "${tables[@]}"; do if [[ -z "$(psql_su "$db" "select to_regclass('public.$t')")" ]]; then log " $t: (no existe en $db) — omitida" continue fi orig="$(psql_su "$db" "select count(*) from $t" | head -1)" rest="$(psql_su "$tmpdb" "select count(*) from $t" | head -1)" if [[ "$orig" == "$rest" ]]; then status="OK"; else status="DIF"; rc=1; fi log " $t: original=$orig restaurado=$rest [$status]" done log " verificación: borrando DB temporal $tmpdb" su "$PG_OS_USER" -c "dropdb --if-exists $(printf %q "$tmpdb")" >/dev/null 2>&1 || log_warn " no se pudo borrar $tmpdb" return $rc } # --------------------------------------------------------------- instancia process_instance() { local unit="$1" load_instance "$unit" || { log_warn "no se pudo interpretar la unidad $unit; omitida"; return 1; } local slug="$INST_SLUG" db="$INST_DB" port="$INST_PORT" local dir="$BACKUP_ROOT/$slug" local dump_final="$dir/${db}_${STAMP}.dump" local cfg_final="$dir/${db}_${STAMP}.config.tar.gz" local dump_tmp="/tmp/${db}_${STAMP}.dump" log "--- instancia $slug (unit=$unit, db=$db, puerto=$port, log=$INST_LOG) ---" if (( DRY_RUN )); then log " [dry-run] destino: $dir/" log " [dry-run] pg_dump -Fc -> $dump_final" else mkdir -p "$dir" chmod 700 "$dir" 2>/dev/null || true fi # 1-2. dump + verificación de integridad local t0 t1 dur size t0=$(date +%s) local dump_ok=0 if (( DRY_RUN )); then log " [dry-run] dump de $db a $dump_tmp (sin ejecutar)" dump_ok=1 else if dump_db "$db" "$dump_tmp" 2>>"$LOG_FILE"; then if pg_restore --list "$dump_tmp" >/dev/null 2>>"$LOG_FILE"; then mv -f "$dump_tmp" "$dump_final" chmod 600 "$dump_final"; chown root:root "$dump_final" 2>/dev/null || true dump_ok=1 else log_err " dump CORRUPTO ($db): falló pg_restore --list; se borra" rm -f "$dump_tmp" fi else log_err " falló pg_dump de $db (ver $LOG_FILE)" rm -f "$dump_tmp" fi fi t1=$(date +%s); dur=$((t1 - t0)) if (( dump_ok )); then if (( DRY_RUN )); then log " [dry-run] dump simulado (no se ejecutó pg_dump)" else size=$(stat -c %s "$dump_final" 2>/dev/null || echo 0) log_ok " dump OK: $dump_final ($(human_bytes "$size"), ${dur}s)" fi fi # 3. configuración local cfg_ok=1 if (( SIN_CONFIG )); then log " config: omitida (--solo-db/--sin-config)" else local vhost vhost="$(find_vhost "$slug" "$port" || true)" if backup_config "$slug" "$INST_UNIT_PATH" "$INST_CONF" "$vhost" "$INST_DATA_DIR" "$db" "$port" "$cfg_final"; then (( DRY_RUN )) || log_ok " config OK: $cfg_final ($(human_bytes "$(stat -c %s "$cfg_final" 2>/dev/null || echo 0)"))" else cfg_ok=0; log_err " falló el respaldo de configuración" fi fi # 4. verificación con restauración local verify_ok=1 if (( VERIFICAR )); then if (( dump_ok )); then verify_restore "$slug" "$db" "$dump_final" || verify_ok=0 else verify_ok=0; log_err " verificación omitida: no hay dump válido" fi fi # 5. rotación if (( DRY_RUN )); then rotate "$dir" "$slug" else rotate "$dir" "$slug" fi (( dump_ok && cfg_ok && verify_ok )) } # ------------------------------------------------------------------- main log "retención efectiva: ${RETAIN_DAILY} diarios / ${RETAIN_WEEKLY} semanales / ${RETAIN_MONTHLY} mensuales" declare -a RESULTS=() TOTAL_FAIL=0 log "unidades detectadas: $(list_units | tr '\n' ' ')" FOUND=0 while IFS= read -r unit; do [[ -n "$unit" ]] || continue if [[ -n "$INSTANCIA" ]]; then [[ "$unit" == "odoo-${INSTANCIA}.service" ]] || continue fi FOUND=1 if process_instance "$unit"; then RESULTS+=("OK $unit") else RESULTS+=("FALLO $unit"); TOTAL_FAIL=$((TOTAL_FAIL + 1)) fi done < <(list_units) if (( ! FOUND )); then if [[ -n "$INSTANCIA" ]]; then log_err "no se encontró la unidad odoo-${INSTANCIA}.service" else log_err "no se detectó ninguna instancia (unidades odoo-*.service)" fi TOTAL_FAIL=$((TOTAL_FAIL + 1)) fi log "---------------- RESUMEN ----------------" for r in "${RESULTS[@]:-}"; do [[ -n "$r" ]] && log " $r"; done if (( TOTAL_FAIL == 0 )); then log_ok "ESTADO GLOBAL: OK (${#RESULTS[@]} instancia(s)); backup $STAMP completado" exit 0 else log_err "ESTADO GLOBAL: FALLO ($TOTAL_FAIL instancia(s) con error); backup $STAMP" exit 1 fi