#!/usr/bin/env bash set -Eeuo pipefail ROOT="${ROOT:-${1:-/var/www/dhv2.korexlabs.com}}" BACKUP_ROOT="${BACKUP_ROOT:-/root/DH_V2_BACKUPS}" STAMP="${STAMP:-$(date +%Y%m%d_%H%M%S)}" BACKUP_DIR="${BACKUP_DIR:-$BACKUP_ROOT/INVENTARIO_A_REVISAR_$STAMP}" MARKER="$BACKUP_DIR/INVENTARIO A REVISAR.txt" MIN_FREE_KB=$((12 * 1024 * 1024)) fail() { echo "ERROR BACKUP INVENTARIO A REVISAR: $*" >&2; exit 1; } cd "$ROOT" [[ -f docker-compose.yml ]] || fail "No existe docker-compose.yml en $ROOT" [[ -f api-v3/package.json ]] || fail "No se encontró api-v3/package.json" FREE_KB="$(df -Pk "$ROOT" | awk 'NR==2 {print $4}')" [[ "$FREE_KB" =~ ^[0-9]+$ ]] || fail "No se pudo determinar espacio libre" (( FREE_KB >= MIN_FREE_KB )) || fail "Se requieren al menos 12 GB libres para el backup fuerte; hay $(( FREE_KB / 1024 / 1024 )) GB" DB_HEALTH="$(docker inspect dhv2-db --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' 2>/dev/null || true)" [[ "$DB_HEALTH" == "healthy" ]] || fail "PostgreSQL no está healthy (estado: $DB_HEALTH)" docker compose exec -T db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null umask 077 mkdir -p "$BACKUP_DIR" chmod 700 "$BACKUP_DIR" API_VERSION="$(node -p "require('./api-v3/package.json').version")" WEB_VERSION="$(node -p "require('./web-v2/package.json').version")" PHASE="$(sed -n "s/.*API_PHASE = '\([^']*\)'.*/\1/p" api-v3/src/version.ts | head -n1)" cat > "$MARKER" < "$BACKUP_DIR/database.dump" [[ -s "$BACKUP_DIR/database.dump" ]] || fail "database.dump quedó vacío" docker compose exec -T db sh -lc 'pg_restore -l' < "$BACKUP_DIR/database.dump" > "$BACKUP_DIR/database-list.txt" [[ -s "$BACKUP_DIR/database-list.txt" ]] || fail "No se pudo validar database.dump" cat <<'SQL' | docker compose exec -T db sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1' > "$BACKUP_DIR/inventory-state.txt" \echo '========== INVENTARIO A REVISAR · ESTADO DEL MAESTRO ==========' SELECT count(*) AS assets_total FROM assets; SELECT type.code,type.name,count(asset.id) AS cantidad FROM asset_types type LEFT JOIN assets asset ON asset.asset_type_id=type.id AND asset.information_status<>'INACTIVE' GROUP BY type.id,type.code,type.name ORDER BY type.code; \echo '' \echo '========== IMPORTACIONES ==========' SELECT batch.id,batch.original_name,batch.status,plan.revision,plan.status AS plan_status,plan.applied_at FROM asset_import_batches batch LEFT JOIN LATERAL (SELECT revision,status,applied_at FROM asset_import_plans p WHERE p.batch_id=batch.id ORDER BY revision DESC LIMIT 1) plan ON true ORDER BY batch.created_at DESC; SQL printf '%s\n' "[2/8] Source" tar \ --exclude='api-v3/node_modules' \ --exclude='api-v3/dist' \ --exclude='web-v2/node_modules' \ --exclude='web-v2/dist' \ --exclude='.git' \ --exclude='DH_V2_FASE_*.zip' \ -czf "$BACKUP_DIR/source.tar.gz" \ api-v3 web-v2 docs scripts infra docker-compose.yml README.md .env.example [[ -s "$BACKUP_DIR/source.tar.gz" ]] || fail "source.tar.gz quedó vacío" tar -tzf "$BACKUP_DIR/source.tar.gz" >/dev/null printf '%s\n' "[3/8] Configuración privada" cp -a docker-compose.yml "$BACKUP_DIR/docker-compose.yml" if [[ -f .env ]]; then cp -a .env "$BACKUP_DIR/.env" chmod 600 "$BACKUP_DIR/.env" fi printf '%s\n' "[4/8] Asset media / archivos importados" docker volume inspect dhv2_asset_media >/dev/null # nginx:alpine ya forma parte del stack web y aporta tar/gzip BusyBox sin instalar nada en el host. docker run --rm \ -v dhv2_asset_media:/source:ro \ -v "$BACKUP_DIR:/backup" \ --entrypoint sh nginx:alpine \ -c 'cd /source && tar -czf /backup/asset-media.tar.gz .' [[ -s "$BACKUP_DIR/asset-media.tar.gz" ]] || fail "asset-media.tar.gz quedó vacío" tar -tzf "$BACKUP_DIR/asset-media.tar.gz" >/dev/null printf '%s\n' "[5/8] Imágenes Docker exactas" for image in dhv2-api:latest dhv2-web:latest dhv2-migrate:latest; do docker image inspect "$image" >/dev/null || fail "Falta la imagen $image" done docker image save dhv2-api:latest dhv2-web:latest dhv2-migrate:latest | gzip -1 > "$BACKUP_DIR/docker-images.tar.gz" [[ -s "$BACKUP_DIR/docker-images.tar.gz" ]] || fail "docker-images.tar.gz quedó vacío" gzip -t "$BACKUP_DIR/docker-images.tar.gz" printf '%s\n' "[6/8] Estado operativo" docker compose ps -a > "$BACKUP_DIR/docker-status.txt" for image in dhv2-api:latest dhv2-web:latest dhv2-migrate:latest; do docker image inspect "$image" --format '{{.RepoTags}} {{.Id}} {{.Created}}'; done > "$BACKUP_DIR/docker-images.txt" docker compose --profile tools run --rm migrate npm run migration:show > "$BACKUP_DIR/migrations.txt" 2>&1 grep -Fq 'Pending migrations: no' "$BACKUP_DIR/migrations.txt" || fail "Hay migraciones pendientes; backup no se marca como aceptado" curl -fsS http://127.0.0.1:3101/api/v3/health > "$BACKUP_DIR/health.json" printf '%s\n' "[7/8] Runbook de restauración" cat > "$BACKUP_DIR/RESTORE_INVENTARIO_A_REVISAR.txt" <<'TXT' RESTAURACIÓN — INVENTARIO A REVISAR No ejecutar estas acciones durante operación normal. Este paquete es un punto de retorno. Contenido principal: - database.dump PostgreSQL completo - source.tar.gz código fuente previo - asset-media.tar.gz volumen de archivos/importaciones - docker-images.tar.gz imágenes exactas api/web/migrate - .env secretos/configuración previa (si existía) - docker-compose.yml compose previo Orden recomendado de recuperación: 1) detener api/web/migrate y mantener una copia del estado actual; 2) restaurar source.tar.gz y .env/docker-compose.yml; 3) cargar docker-images.tar.gz con: gzip -dc docker-images.tar.gz | docker image load; 4) restaurar PostgreSQL desde database.dump con el usuario owner; 5) restaurar dhv2_asset_media desde asset-media.tar.gz; 6) recrear api/web y verificar /api/v3/health y migration:show. La restauración destructiva debe hacerse sólo después de revisar el incidente y conservar un backup del estado que se reemplazará. TXT chmod 600 "$BACKUP_DIR/RESTORE_INVENTARIO_A_REVISAR.txt" printf '%s\n' "[8/8] Integridad SHA-256" cat > "$BACKUP_DIR/BACKUP_OK.txt" < "$BACKUP_DIR/SHA256SUMS.txt" chmod 600 "$BACKUP_DIR/SHA256SUMS.txt" ( cd "$BACKUP_DIR" sha256sum -c SHA256SUMS.txt >/dev/null ) || fail "Falló la validación SHA-256" printf '\nBACKUP FUERTE INVENTARIO A REVISAR: OK\n' printf 'Ruta: %s\n' "$BACKUP_DIR" du -sh "$BACKUP_DIR"