1203 lines
35 KiB
Bash
1203 lines
35 KiB
Bash
#!/bin/bash
|
||
# patch_rollback.sh - 企业级补丁包回滚脚本
|
||
# 支持原子性回滚、多回滚点管理、验证等完整功能
|
||
|
||
set -euo pipefail
|
||
shopt -s nullglob extglob
|
||
|
||
# 脚本配置
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
CONFIG_FILE="${SCRIPT_DIR}/patch_config.sh"
|
||
LOG_FILE="/var/log/patch_system/rollback_$(date +%Y%m%d_%H%M%S).log"
|
||
LOCK_FILE="/tmp/patch_rollback.lock"
|
||
ROLLBACK_INFO_DIR="/var/lib/patch_system/rollback_info"
|
||
ROLLBACK_POINTS=() # 存储回滚点数组
|
||
ROLLBACK_COUNT=0 # 回滚点数量
|
||
|
||
# 颜色定义
|
||
RED='\033[0;31m'
|
||
GREEN='\033[0;32m'
|
||
YELLOW='\033[1;33m'
|
||
BLUE='\033[0;34m'
|
||
PURPLE='\033[0;35m'
|
||
CYAN='\033[0;36m'
|
||
NC='\033[0m'
|
||
|
||
# 日志函数
|
||
log() {
|
||
local level="$1"
|
||
local message="$2"
|
||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||
|
||
case "$level" in
|
||
"INFO") color="$GREEN" ;;
|
||
"WARN") color="$YELLOW" ;;
|
||
"ERROR") color="$RED" ;;
|
||
"DEBUG") color="$BLUE" ;;
|
||
*) color="$NC" ;;
|
||
esac
|
||
|
||
echo -e "${color}[$timestamp] [$level]${NC} $message" | tee -a "$LOG_FILE"
|
||
}
|
||
|
||
info() { log "INFO" "$1"; }
|
||
warn() { log "WARN" "$1"; }
|
||
error() { log "ERROR" "$1"; }
|
||
debug() { log "DEBUG" "$1"; }
|
||
|
||
# 配置加载
|
||
load_config() {
|
||
if [[ ! -f "$CONFIG_FILE" ]]; then
|
||
error "配置文件不存在: $CONFIG_FILE"
|
||
exit 1
|
||
fi
|
||
|
||
source "$CONFIG_FILE"
|
||
info "配置文件加载完成"
|
||
|
||
# 设置默认值
|
||
: ${TEMP_DIR:="/tmp/patch_rollback_$$"}
|
||
: ${BACKUP_DIR:="/var/backups/patch"}
|
||
: ${ROLLBACK_ENABLED:="true"}
|
||
: ${ROLLBACK_INFO_DIR:="/var/lib/patch_system/rollback_info"}
|
||
: ${MAX_ROLLBACK_POINTS:=10}
|
||
: ${VALIDATION_ENABLED:="true"}
|
||
: ${NOTIFICATIONS_ENABLED:="true"}
|
||
}
|
||
|
||
# 环境设置
|
||
setup_environment() {
|
||
mkdir -p "$(dirname "$LOG_FILE")"
|
||
mkdir -p "$ROLLBACK_INFO_DIR"
|
||
mkdir -p "$BACKUP_DIR"
|
||
mkdir -p "/tmp/patch_rollback"
|
||
|
||
info "环境设置完成"
|
||
info "日志文件: $LOG_FILE"
|
||
info "回滚信息目录: $ROLLBACK_INFO_DIR"
|
||
}
|
||
|
||
# 锁管理
|
||
acquire_lock() {
|
||
local max_retries=5
|
||
local retry_count=0
|
||
|
||
while [[ $retry_count -lt $max_retries ]]; do
|
||
if (set -C; echo $$ > "$LOCK_FILE") 2>/dev/null; then
|
||
info "🔒 获取回滚锁"
|
||
return 0
|
||
fi
|
||
|
||
local lock_pid=$(cat "$LOCK_FILE" 2>/dev/null)
|
||
if kill -0 "$lock_pid" 2>/dev/null; then
|
||
warn "回滚操作正在进行中 (PID: $lock_pid),等待..."
|
||
sleep 5
|
||
retry_count=$((retry_count+1))
|
||
else
|
||
warn "清理过期的锁文件"
|
||
rm -f "$LOCK_FILE"
|
||
fi
|
||
done
|
||
|
||
error "无法获取锁,可能已有回滚操作在进行中"
|
||
exit 1
|
||
}
|
||
|
||
release_lock() {
|
||
rm -f "$LOCK_FILE"
|
||
info "🔓 释放回滚锁"
|
||
}
|
||
|
||
# 路径规范化函数 - 去除多余的斜杠
|
||
normalize_path() {
|
||
local path="$1"
|
||
# 使用 sed 去除重复的斜杠
|
||
echo "$path" | sed 's|/\+|/|g'
|
||
}
|
||
|
||
# 清理函数
|
||
cleanup() {
|
||
local exit_code=$?
|
||
|
||
if [[ -d "$TEMP_DIR" ]]; then
|
||
# rm -rf "$TEMP_DIR"
|
||
info "临时目录已清理: $TEMP_DIR"
|
||
fi
|
||
|
||
release_lock
|
||
|
||
if [[ $exit_code -eq 0 ]]; then
|
||
info "✅ 回滚流程完成"
|
||
else
|
||
error "💥 回滚流程失败 (退出码: $exit_code)"
|
||
fi
|
||
|
||
exit $exit_code
|
||
}
|
||
|
||
trap cleanup EXIT INT TERM
|
||
|
||
# 依赖检查
|
||
check_dependencies() {
|
||
local deps=("tar" "gzip" "jq" "sha256sum" "cp" "mkdir" "find")
|
||
local missing=()
|
||
|
||
for dep in "${deps[@]}"; do
|
||
if ! command -v "$dep" >/dev/null 2>&1; then
|
||
missing+=("$dep")
|
||
fi
|
||
done
|
||
|
||
if [[ ${#missing[@]} -gt 0 ]]; then
|
||
error "缺少依赖工具: ${missing[*]}"
|
||
return 1
|
||
fi
|
||
|
||
info "依赖检查通过"
|
||
return 0
|
||
}
|
||
|
||
# 获取可用的回滚点
|
||
list_rollback_points() {
|
||
info "📋 可用的回滚点:" >&2
|
||
info "回滚还原点信息目录: $ROLLBACK_INFO_DIR" >&2
|
||
|
||
if [[ ! -d "$ROLLBACK_INFO_DIR" ]]; then
|
||
warn "回滚信息目录不存在: $ROLLBACK_INFO_DIR" >&2
|
||
return 1
|
||
fi
|
||
|
||
local count=0
|
||
local temp_file="/tmp/rollback_list_$$"
|
||
local found_files=0
|
||
local valid_files=0
|
||
|
||
# 安全检查
|
||
if [[ ! -d "$ROLLBACK_INFO_DIR" ]]; then
|
||
error "回滚信息目录不存在: $ROLLBACK_INFO_DIR" >&2
|
||
return 1
|
||
fi
|
||
|
||
info "在 $ROLLBACK_INFO_DIR 中搜索回滚信息文件..." >&2
|
||
|
||
# 清空临时文件
|
||
> "$temp_file" || {
|
||
error "无法创建临时文件: $temp_file"
|
||
return 1
|
||
}
|
||
|
||
# 使用数组存储文件,避免管道问题
|
||
local files=()
|
||
while IFS= read -r -d '' file; do
|
||
files+=("$file")
|
||
done < <(find "$ROLLBACK_INFO_DIR" -maxdepth 1 -name "*.json" -type f -print0 2>/dev/null)
|
||
|
||
# 使用 ls -t 按时间排序
|
||
IFS=$'\n' sorted_files=($(ls -t "${files[@]}"))
|
||
unset IFS
|
||
|
||
found_files=${#sorted_files[@]}
|
||
info "找到 $found_files 个JSON文件" >&2
|
||
|
||
for info_file in "${sorted_files[@]}"; do
|
||
debug "处理文件: $info_file"
|
||
|
||
# 文件可读性检查
|
||
if [[ ! -r "$info_file" ]]; then
|
||
warn "文件不可读: $info_file" >&2
|
||
continue
|
||
fi
|
||
|
||
# 文件大小检查
|
||
if [[ ! -s "$info_file" ]]; then
|
||
warn "空文件: $info_file" >&2
|
||
continue
|
||
fi
|
||
|
||
# 解析JSON
|
||
if ! jq -e '.' "$info_file" >/dev/null 2>&1; then
|
||
warn "无效的JSON文件: $info_file" >&2
|
||
continue
|
||
fi
|
||
|
||
# 提取字段(带默认值)
|
||
local patch_name apply_time backup_path status
|
||
|
||
patch_name=$(jq -r '.patch_name // "unknown_patch"' "$info_file" 2>/dev/null)
|
||
apply_time=$(jq -r '.apply_time // "unknown_time"' "$info_file" 2>/dev/null)
|
||
backup_path=$(jq -r '.backup_path // ""' "$info_file" 2>/dev/null)
|
||
status=$(jq -r '.status // "unknown"' "$info_file" 2>/dev/null)
|
||
|
||
# 验证必要字段
|
||
if [[ -z "$backup_path" ]]; then
|
||
debug "缺少backup_path字段: $info_file" >&2
|
||
continue
|
||
fi
|
||
|
||
# 验证备份文件
|
||
if [[ ! -f "$backup_path" ]]; then
|
||
warn "备份文件不存在: $backup_path (来自 $info_file)" >&2
|
||
continue
|
||
fi
|
||
|
||
# 验证状态
|
||
if [[ "$status" != "applied" ]]; then
|
||
debug "状态不是applied: $status (来自 $info_file)" >&2
|
||
continue
|
||
fi
|
||
|
||
# 有效的可回滚文件
|
||
debug "有效的可回滚文件: $info_file" >&2
|
||
count=$((count+1))
|
||
valid_files=$((valid_files+1))
|
||
|
||
echo "$info_file" >> "$temp_file"
|
||
echo " $count. $patch_name"
|
||
echo " 应用时间: $apply_time"
|
||
echo " 备份文件: $(basename "$backup_path")"
|
||
echo " 状态: $status"
|
||
echo " 信息文件: $(basename "$info_file")"
|
||
|
||
done
|
||
|
||
info "扫描完成: 找到 $found_files 个文件,其中 $valid_files 个可回滚" >&2
|
||
|
||
if [[ $valid_files -eq 0 ]]; then
|
||
rm -f "$temp_file"
|
||
return 1
|
||
fi
|
||
|
||
export ROLLBACK_LIST_FILE="$temp_file"
|
||
export ROLLBACK_COUNT=$valid_files
|
||
return 0
|
||
}
|
||
|
||
# 选择回滚点
|
||
select_rollback_point() {
|
||
local rollback_id="${1:-}"
|
||
|
||
if [[ -z "$rollback_id" ]]; then
|
||
info "交互式,请选择要回滚的点:"
|
||
# 交互式选择
|
||
local points=()
|
||
local count=0
|
||
|
||
# 先获取回滚点列表
|
||
local temp_file=$(mktemp)
|
||
if ! list_rollback_points > "$temp_file"; then
|
||
rm -f "$temp_file"
|
||
return 1
|
||
fi
|
||
|
||
# 从临时文件中读取回滚点数组
|
||
if [[ -f "$temp_file" ]]; then
|
||
cat "$temp_file"
|
||
|
||
# 使用临时文件存储回滚点数量
|
||
local count_file=$(mktemp)
|
||
echo "0" > "$count_file"
|
||
|
||
# 注意:这里我们重新加载 points 数组
|
||
local temp_points_file=$(mktemp)
|
||
while IFS= read -r -d '' info_file; do
|
||
if [[ -f "$info_file" ]]; then
|
||
local backup_path=$(jq -r '.backup_path // ""' "$info_file" 2>/dev/null)
|
||
local status=$(jq -r '.status // "unknown"' "$info_file" 2>/dev/null)
|
||
if [[ -n "$backup_path" && -f "$backup_path" && "$status" == "applied" ]]; then
|
||
echo "$info_file" >> "$temp_points_file"
|
||
local current_count=$(cat "$count_file")
|
||
echo $((current_count + 1)) > "$count_file"
|
||
fi
|
||
fi
|
||
done < <(find "$ROLLBACK_INFO_DIR" -name "*.json" -print0 2>/dev/null | sort -z)
|
||
|
||
# 读取计数
|
||
count=$(cat "$count_file")
|
||
rm -f "$count_file"
|
||
|
||
if [[ -f "$temp_points_file" ]]; then
|
||
mapfile -t points < "$temp_points_file"
|
||
rm -f "$temp_points_file"
|
||
fi
|
||
rm -f "$temp_file"
|
||
fi
|
||
|
||
if [[ $count -eq 0 ]]; then
|
||
error "无可用的回滚点"
|
||
return 1
|
||
fi
|
||
|
||
echo ""
|
||
read -p "📝 请选择要回滚的补丁编号 (1-${count}): " selected_num
|
||
|
||
if [[ ! "$selected_num" =~ ^[0-9]+$ ]] || [[ "$selected_num" -lt 1 ]] || [[ "$selected_num" -gt "$count" ]]; then
|
||
error "无效的选择: $selected_num"
|
||
return 1
|
||
fi
|
||
|
||
rollback_id="${points[$((selected_num-1))]}"
|
||
else
|
||
# 直接指定回滚点
|
||
if [[ "$rollback_id" == "latest" ]]; then
|
||
# 选择最新的回滚点
|
||
rollback_id=$(find "$ROLLBACK_INFO_DIR" -name "*.json" -exec jq -r '.apply_time + "|" + input_filename' {} \; 2>/dev/null | \
|
||
sort -r | head -1 | cut -d'|' -f2)
|
||
|
||
if [[ -z "$rollback_id" ]]; then
|
||
error "未找到回滚点"
|
||
return 1
|
||
fi
|
||
elif [[ ! -f "$rollback_id" ]]; then
|
||
# 检查是否是文件路径
|
||
if [[ -f "$ROLLBACK_INFO_DIR/$rollback_id" ]]; then
|
||
rollback_id="$ROLLBACK_INFO_DIR/$rollback_id"
|
||
elif [[ -f "$ROLLBACK_INFO_DIR/${rollback_id}.json" ]]; then
|
||
rollback_id="$ROLLBACK_INFO_DIR/${rollback_id}.json"
|
||
else
|
||
error "回滚点不存在: $rollback_id"
|
||
return 1
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
if [[ ! -f "$rollback_id" ]]; then
|
||
error "回滚信息文件不存在: $rollback_id"
|
||
return 1
|
||
fi
|
||
|
||
echo "$rollback_id"
|
||
return 0
|
||
}
|
||
|
||
# 解析回滚信息
|
||
parse_rollback_info() {
|
||
local info_file="$1"
|
||
|
||
info "📖 解析回滚信息: $(basename "$info_file")" >&2
|
||
|
||
if [[ ! -f "$info_file" ]]; then
|
||
error "回滚信息文件不存在: $info_file" >&2
|
||
return 1
|
||
fi
|
||
|
||
# 使用jq解析JSON
|
||
local patch_name=$(jq -r '.patch_name // "unknown"' "$info_file" 2>/dev/null)
|
||
local apply_time=$(jq -r '.apply_time // "unknown"' "$info_file" 2>/dev/null)
|
||
local backup_path=$(jq -r '.backup_path // ""' "$info_file" 2>/dev/null)
|
||
local rollback_dir=$(jq -r '.rollback_dir // ""' "$info_file" 2>/dev/null)
|
||
local manifest_info=$(jq -r '.manifest_info // ""' "$info_file" 2>/dev/null)
|
||
local status=$(jq -r '.status // "unknown"' "$info_file" 2>/dev/null)
|
||
|
||
# 验证必要字段
|
||
if [[ -z "$backup_path" ]]; then
|
||
error "回滚信息缺少备份路径" >&2
|
||
return 1
|
||
fi
|
||
|
||
if [[ ! -f "$backup_path" ]]; then
|
||
error "备份文件不存在: $backup_path" >&2
|
||
return 1
|
||
fi
|
||
|
||
if [[ "$status" != "applied" ]]; then
|
||
warn "回滚点状态为 $status,可能已回滚或无效" >&2
|
||
fi
|
||
|
||
# 创建回滚信息结构
|
||
cat << EOF
|
||
{
|
||
"info_file": "$info_file",
|
||
"patch_name": "$patch_name",
|
||
"apply_time": "$apply_time",
|
||
"backup_path": "$backup_path",
|
||
"rollback_dir": "$rollback_dir",
|
||
"manifest_info": "$manifest_info",
|
||
"status": "$status"
|
||
}
|
||
EOF
|
||
|
||
return 0
|
||
}
|
||
|
||
# 验证备份文件
|
||
verify_backup_file() {
|
||
local backup_file="$1"
|
||
|
||
info "🔍 验证备份文件: $(basename "$backup_file")"
|
||
|
||
if [[ ! -f "$backup_file" ]]; then
|
||
error "备份文件不存在: $backup_file"
|
||
return 1
|
||
fi
|
||
|
||
# 检查文件大小
|
||
local size=$(stat -c%s "$backup_file" 2>/dev/null || echo "0")
|
||
if [[ $size -eq 0 ]]; then
|
||
error "备份文件为空: $backup_file"
|
||
return 1
|
||
fi
|
||
|
||
# 验证压缩包完整性
|
||
if ! tar -tzf "$backup_file" >/dev/null 2>&1; then
|
||
error "备份文件损坏或格式错误: $backup_file"
|
||
return 1
|
||
fi
|
||
|
||
info "✅ 备份文件验证通过"
|
||
return 0
|
||
}
|
||
|
||
# 提取备份文件
|
||
extract_backup() {
|
||
local backup_file="$1"
|
||
local extract_dir="$2"
|
||
|
||
info "📦 提取备份文件到: $extract_dir"
|
||
|
||
if [[ ! -d "$extract_dir" ]]; then
|
||
mkdir -p "$extract_dir"
|
||
fi
|
||
|
||
if tar -xzf "$backup_file" -C "$extract_dir"; then
|
||
info "✅ 备份文件提取完成"
|
||
|
||
# 验证提取内容
|
||
# 查找 changes.txt 文件是否存在, 如果存在,则说明找到了,如果不存在,则说明备份文件提取失败
|
||
if [[ -f "$extract_dir/changes.txt" ]]; then
|
||
info "找到 changes.txt 文件"
|
||
else
|
||
warn "❌ 备份文件中未找到 changes.txt 文件"
|
||
return 1
|
||
fi
|
||
|
||
# 查找解压目录下的contents目录,获得文件数量
|
||
local contents_dir=$(normalize_path "$extract_dir/contents")
|
||
local file_count=$(find "$contents_dir" -type f | wc -l)
|
||
if [[ $file_count -gt 0 ]]; then
|
||
info "找到 $file_count 个备份文件"
|
||
return 0
|
||
else
|
||
warn "⚠️ 备份文件中未找到文件"
|
||
return 0
|
||
fi
|
||
else
|
||
error "❌ 备份文件提取失败"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# 执行回滚操作
|
||
perform_rollback() {
|
||
local extract_dir="$1"
|
||
local dry_run="${2:-false}"
|
||
local rollback_info_file="${3:-}"
|
||
|
||
info "🔄 开始执行回滚操作 (干跑模式: $dry_run)"
|
||
|
||
if [[ ! -d "$extract_dir" ]]; then
|
||
error "提取目录不存在: $extract_dir"
|
||
return 1
|
||
fi
|
||
|
||
local rollback_count=0
|
||
local error_count=0
|
||
local skip_count=0
|
||
local deleted_count=0
|
||
|
||
# 首先处理新增文件的删除
|
||
handle_added_files_deletion "$extract_dir" "$dry_run"
|
||
deleted_count=$?
|
||
|
||
# 查找所有备份文件并恢复, 备份文件存放于contents目录下
|
||
local contents_dir="$extract_dir/contents"
|
||
contents_dir=$(normalize_path "$contents_dir")
|
||
|
||
find "$contents_dir" -type f | while read backup_file; do
|
||
local relative_path="${backup_file#$contents_dir/}"
|
||
relative_path=$(normalize_path "$relative_path")
|
||
local target_file="$relative_path"
|
||
target_file=$(normalize_path "$target_file")
|
||
local target_dir=$(dirname "$target_file")
|
||
target_dir=$(normalize_path "$target_dir")
|
||
|
||
if [[ "$dry_run" == "true" ]]; then
|
||
info "📋 [干跑] 回滚文件: $relative_path → $target_file" >&2
|
||
rollback_count=$((rollback_count+1))
|
||
continue
|
||
fi
|
||
|
||
# 创建目标目录
|
||
if [[ ! -d "$target_dir" ]]; then
|
||
if ! mkdir -p "$target_dir"; then
|
||
error "❌ 创建目录失败: $target_dir" >&2
|
||
error_count=$((error_count+1))
|
||
continue
|
||
fi
|
||
debug "创建目录: $target_dir"
|
||
fi
|
||
|
||
# 备份当前文件(如果存在)
|
||
if [[ -f "$target_file" ]]; then
|
||
local current_backup="$TEMP_DIR/current_backup/$relative_path"
|
||
local current_dir=$(dirname "$current_backup")
|
||
mkdir -p "$current_dir"
|
||
|
||
if ! cp -p "$target_file" "$current_backup"; then
|
||
warn "⚠️ 当前文件备份失败: $target_file" >&2
|
||
fi
|
||
fi
|
||
|
||
# 恢复文件
|
||
if cp -p "$backup_file" "$target_file"; then
|
||
info "✅ 回滚文件: $relative_path | $backup_file -> $target_file" >&2
|
||
((rollback_count++))
|
||
else
|
||
error "❌ 文件回滚失败: $relative_path" >&2
|
||
error_count=$((error_count+1))
|
||
fi
|
||
done
|
||
|
||
info "📊 回滚操作统计:"
|
||
info " 恢复文件: $rollback_count"
|
||
info " 删除新增: $deleted_count"
|
||
info " 失败: $error_count"
|
||
info " 跳过: $skip_count"
|
||
|
||
if [[ $error_count -gt 0 ]]; then
|
||
error "❌ 回滚操作存在失败项"
|
||
return 1
|
||
fi
|
||
|
||
if [[ "$dry_run" == "true" ]]; then
|
||
info "✅ 干跑模式完成 - 可安全执行回滚"
|
||
return 0
|
||
else
|
||
info "✅ 回滚操作完成"
|
||
return 0
|
||
fi
|
||
}
|
||
|
||
# 处理新增文件的删除
|
||
handle_added_files_deletion() {
|
||
local extract_dir="$1"
|
||
local dry_run="$2"
|
||
|
||
info "🗑️ 处理新增文件删除..."
|
||
|
||
local changes_file="$extract_dir/changes.txt"
|
||
|
||
if [[ ! -f "$changes_file" ]]; then
|
||
warn "⚠️ changes.txt 文件不存在,无法处理新增文件删除"
|
||
return 0
|
||
fi
|
||
|
||
local error_count=0
|
||
local skip_count=0
|
||
local deleted_count=0
|
||
|
||
# 处理ADDED类型的文件
|
||
while IFS='|' read -r change_type path extra_info; do
|
||
if [[ "$change_type" == "ADDED" ]]; then
|
||
local target_file=$(normalize_path "$path")
|
||
|
||
if [[ "$dry_run" == "true" ]]; then
|
||
info "📋 [干跑] 删除新增文件: $target_file" >&2
|
||
((deleted_count++))
|
||
continue
|
||
fi
|
||
|
||
# 检查文件是否存在
|
||
if [[ -f "$target_file" ]]; then
|
||
# 备份即将删除的文件
|
||
local backup_target="$TEMP_DIR/added_files_backup/$path"
|
||
backup_target=$(normalize_path "$backup_target")
|
||
local backup_dir=$(dirname "$backup_target")
|
||
backup_dir=$(normalize_path "$backup_dir")
|
||
mkdir -p "$backup_dir"
|
||
|
||
if cp -p "$target_file" "$backup_target" 2>/dev/null; then
|
||
debug "备份新增文件: $target_file"
|
||
fi
|
||
|
||
# 删除新增文件
|
||
if rm -f "$target_file"; then
|
||
info "✅ 删除新增文件: $target_file" >&2
|
||
((deleted_count++))
|
||
|
||
# 尝试清理空目录
|
||
local target_dir=$(dirname "$target_file")
|
||
target_dir=$(normalize_path "$target_dir")
|
||
clean_empty_directories_rollback "$target_dir"
|
||
else
|
||
error_count=$((error_count+1))
|
||
error "❌ 删除新增文件失败: $target_file" >&2
|
||
fi
|
||
else
|
||
warn "⚠️ 新增文件不存在,无需删除: $target_file" >&2
|
||
((skip_count++))
|
||
fi
|
||
fi
|
||
done < "$changes_file"
|
||
|
||
return $deleted_count
|
||
}
|
||
|
||
# 清理空目录(回滚版本)
|
||
clean_empty_directories_rollback() {
|
||
local dir="$1"
|
||
|
||
while [[ "$dir" != "/" ]] && [[ -d "$dir" ]]; do
|
||
# 检查目录是否为空(只检查文件,不考虑隐藏文件)
|
||
if [[ -z "$(ls -A "$dir" 2>/dev/null)" ]]; then
|
||
if rmdir "$dir" 2>/dev/null; then
|
||
debug "清理空目录: $dir"
|
||
dir=$(dirname "$dir")
|
||
else
|
||
break # 目录删除失败,停止清理
|
||
fi
|
||
else
|
||
break # 目录非空,停止清理
|
||
fi
|
||
done
|
||
}
|
||
|
||
# 验证回滚结果
|
||
verify_rollback() {
|
||
local extract_dir="$1"
|
||
local rollback_info="$2"
|
||
|
||
if [[ "$VALIDATION_ENABLED" != "true" ]]; then
|
||
info "验证功能已禁用"
|
||
return 0
|
||
fi
|
||
|
||
info "🔍 验证回滚结果..."
|
||
|
||
local verification_passed=true
|
||
local verified_count=0
|
||
local failed_count=0
|
||
|
||
# 查找所有备份文件并恢复, 备份文件存放于contents目录下
|
||
local contents_dir="$extract_dir/contents"
|
||
contents_dir=$(normalize_path "$contents_dir")
|
||
|
||
# 检查所有备份文件是否已正确恢复
|
||
find "$contents_dir" -type f | while read backup_file; do
|
||
local relative_path="${backup_file#$contents_dir/}"
|
||
local target_file="$relative_path"
|
||
|
||
if [[ ! -f "$target_file" ]]; then
|
||
error "❌ 目标文件不存在: $target_file"
|
||
verification_passed=false
|
||
failed_count=$((failed_count+1))
|
||
continue
|
||
fi
|
||
|
||
# 比较文件内容
|
||
if ! cmp -s "$backup_file" "$target_file"; then
|
||
error "❌ 文件内容不匹配: $relative_path"
|
||
verification_passed=false
|
||
failed_count=$((failed_count+1))
|
||
continue
|
||
fi
|
||
|
||
verified_count=$((verified_count+1))
|
||
done
|
||
|
||
info "📊 回滚验证统计:"
|
||
info " 成功: $verified_count"
|
||
info " 失败: $failed_count"
|
||
|
||
if $verification_passed; then
|
||
info "✅ 回滚验证通过"
|
||
return 0
|
||
else
|
||
error "❌ 回滚验证失败"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# 更新回滚点状态
|
||
update_rollback_status() {
|
||
local info_file="$1"
|
||
local new_status="$2"
|
||
|
||
if [[ ! -f "$info_file" ]]; then
|
||
error "回滚信息文件不存在: $info_file"
|
||
return 1
|
||
fi
|
||
|
||
# 使用jq更新状态
|
||
if jq ".status = \"$new_status\" | .rollback_time = \"$(date -Iseconds)\"" "$info_file" > "$info_file.tmp"; then
|
||
mv "$info_file.tmp" "$info_file"
|
||
info "✅ 回滚点状态更新为: $new_status"
|
||
return 0
|
||
else
|
||
error "❌ 回滚点状态更新失败"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# 清理回滚点
|
||
cleanup_rollback_point() {
|
||
local info_file="$1"
|
||
local extract_dir="$2"
|
||
|
||
info "🧹 清理回滚资源..."
|
||
|
||
# 删除提取目录
|
||
if [[ -d "$extract_dir" ]]; then
|
||
rm -rf "$extract_dir"
|
||
debug "清理提取目录: $extract_dir"
|
||
fi
|
||
|
||
# 可选:删除备份文件(谨慎操作)
|
||
local delete_backup="${3:-false}"
|
||
if [[ "$delete_backup" == "true" ]]; then
|
||
local backup_path=$(jq -r '.backup_path // ""' "$info_file")
|
||
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
|
||
rm -f "$backup_path"
|
||
info "🗑️ 删除备份文件: $(basename "$backup_path")"
|
||
fi
|
||
fi
|
||
|
||
# 删除回滚信息文件
|
||
if [[ -f "$info_file" ]]; then
|
||
rm -f "$info_file"
|
||
info "🗑️ 删除回滚信息文件: $(basename "$info_file")"
|
||
fi
|
||
|
||
info "✅ 回滚资源清理完成"
|
||
}
|
||
|
||
# 发送通知
|
||
send_rollback_notification() {
|
||
local status="$1"
|
||
local rollback_info="$2"
|
||
local message="$3"
|
||
|
||
if [[ "$NOTIFICATIONS_ENABLED" != "true" ]]; then
|
||
return 0
|
||
fi
|
||
|
||
local patch_name=$(echo "$rollback_info" | jq -r '.patch_name')
|
||
local apply_time=$(echo "$rollback_info" | jq -r '.apply_time')
|
||
|
||
local subject=""
|
||
local color=""
|
||
|
||
case "$status" in
|
||
"start")
|
||
subject="回滚操作开始"
|
||
color="#3498db"
|
||
;;
|
||
"success")
|
||
subject="回滚操作成功"
|
||
color="#2ecc71"
|
||
;;
|
||
"failure")
|
||
subject="回滚操作失败"
|
||
color="#e74c3c"
|
||
;;
|
||
*)
|
||
subject="回滚操作通知"
|
||
color="#95a5a6"
|
||
;;
|
||
esac
|
||
|
||
# Slack通知
|
||
if [[ -n "${SLACK_WEBHOOK:-}" ]]; then
|
||
send_slack_rollback_notification "$subject" "$message" "$color" "$patch_name" "$apply_time"
|
||
fi
|
||
|
||
log "NOTIFICATION" "$subject: $message"
|
||
}
|
||
|
||
send_slack_rollback_notification() {
|
||
local subject="$1"
|
||
local message="$2"
|
||
local color="$3"
|
||
local patch_name="$4"
|
||
local apply_time="$5"
|
||
|
||
local payload=$(cat << EOF
|
||
{
|
||
"attachments": [
|
||
{
|
||
"color": "$color",
|
||
"title": "$subject",
|
||
"text": "$message",
|
||
"fields": [
|
||
{
|
||
"title": "补丁名称",
|
||
"value": "$patch_name",
|
||
"short": true
|
||
},
|
||
{
|
||
"title": "应用时间",
|
||
"value": "$apply_time",
|
||
"short": true
|
||
},
|
||
{
|
||
"title": "回滚时间",
|
||
"value": "$(date)",
|
||
"short": true
|
||
},
|
||
{
|
||
"title": "回滚主机",
|
||
"value": "$(hostname)",
|
||
"short": true
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
EOF
|
||
)
|
||
|
||
if command -v curl >/dev/null 2>&1; then
|
||
if curl -s -X POST -H 'Content-type: application/json' \
|
||
--data "$payload" "$SLACK_WEBHOOK" >/dev/null; then
|
||
info "✅ Slack通知发送成功"
|
||
else
|
||
warn "⚠️ Slack通知发送失败"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# 主回滚函数
|
||
rollback_patch() {
|
||
local rollback_point="${1:-}"
|
||
local dry_run="${2:-false}"
|
||
local cleanup_after="${3:-false}"
|
||
|
||
info "🚀 开始回滚操作, 回滚点: ${rollback_point}"
|
||
|
||
# 发送开始通知
|
||
local rollback_info_json=""
|
||
local rollback_info_file=""
|
||
|
||
# 选择回滚点
|
||
info "定位到回滚点 ${rollback_point}"
|
||
rollback_info_file=$(select_rollback_point "$rollback_point")
|
||
if [[ $? -ne 0 ]]; then
|
||
send_rollback_notification "failure" "{}" "回滚点选择失败"
|
||
return 1
|
||
fi
|
||
|
||
# 解析回滚信息
|
||
info "解析回滚信息 ${rollback_info_file}"
|
||
rollback_info_json=$(parse_rollback_info "$rollback_info_file")
|
||
if [[ $? -ne 0 ]]; then
|
||
send_rollback_notification "failure" "{}" "回滚信息解析失败"
|
||
return 1
|
||
fi
|
||
|
||
local patch_name=$(echo "$rollback_info_json" | jq -r '.patch_name')
|
||
local backup_path=$(echo "$rollback_info_json" | jq -r '.backup_path')
|
||
|
||
send_rollback_notification "start" "$rollback_info_json" "开始回滚补丁: $patch_name"
|
||
|
||
# 验证备份文件
|
||
if ! verify_backup_file "$backup_path"; then
|
||
send_rollback_notification "failure" "$rollback_info_json" "备份文件验证失败"
|
||
return 1
|
||
fi
|
||
|
||
# 创建临时目录
|
||
TEMP_DIR=$(mktemp -d "/tmp/patch_rollback_XXXXXX")
|
||
local extract_dir="$TEMP_DIR/extract"
|
||
|
||
# 提取备份文件
|
||
if ! extract_backup "$backup_path" "$extract_dir"; then
|
||
send_rollback_notification "failure" "$rollback_info_json" "备份文件提取失败"
|
||
return 1
|
||
fi
|
||
|
||
# 执行回滚操作
|
||
if ! perform_rollback "$extract_dir" "$dry_run" "$rollback_info_file"; then
|
||
send_rollback_notification "failure" "$rollback_info_json" "回滚操作失败"
|
||
return 1
|
||
fi
|
||
|
||
# 验证回滚结果(非干跑模式)
|
||
if [[ "$dry_run" != "true" ]]; then
|
||
if ! verify_rollback "$extract_dir" "$rollback_info_json"; then
|
||
send_rollback_notification "failure" "$rollback_info_json" "回滚验证失败"
|
||
return 1
|
||
fi
|
||
|
||
# 更新回滚点状态
|
||
if ! update_rollback_status "$rollback_info_file" "rolled_back"; then
|
||
warn "⚠️ 回滚点状态更新失败"
|
||
fi
|
||
|
||
# 清理资源
|
||
if [[ "$cleanup_after" == "true" ]]; then
|
||
cleanup_rollback_point "$rollback_info_file" "$extract_dir" "true"
|
||
else
|
||
cleanup_rollback_point "$rollback_info_file" "$extract_dir" "false"
|
||
fi
|
||
fi
|
||
|
||
send_rollback_notification "success" "$rollback_info_json" "回滚操作成功完成"
|
||
info "✅ 回滚流程完成"
|
||
return 0
|
||
}
|
||
|
||
# 批量清理旧回滚点
|
||
cleanup_old_rollback_points() {
|
||
local max_points="${1:-$MAX_ROLLBACK_POINTS}"
|
||
|
||
info "🧹 清理旧回滚点 (保留最近 $max_points 个)"
|
||
|
||
if [[ ! -d "$ROLLBACK_INFO_DIR" ]]; then
|
||
warn "回滚信息目录不存在"
|
||
return 0
|
||
fi
|
||
|
||
# 获取所有回滚点并按时间排序
|
||
local points=()
|
||
while IFS= read -r -d '' info_file; do
|
||
if [[ -f "$info_file" ]]; then
|
||
points+=("$info_file")
|
||
fi
|
||
done < <(find "$ROLLBACK_INFO_DIR" -name "*.json" -print0 2>/dev/null)
|
||
|
||
local total_points=${#points[@]}
|
||
local points_to_remove=$((total_points - max_points))
|
||
|
||
if [[ $points_to_remove -le 0 ]]; then
|
||
info "无需清理,当前 $total_points 个回滚点"
|
||
return 0
|
||
fi
|
||
|
||
info "需要清理 $points_to_remove 个回滚点"
|
||
|
||
# 按修改时间排序,删除最旧的
|
||
for ((i=0; i<points_to_remove; i++)); do
|
||
local oldest_file=$(find "$ROLLBACK_INFO_DIR" -name "*.json" -exec stat -c "%Y|%n" {} \; | \
|
||
sort -n | head -1 | cut -d'|' -f2)
|
||
|
||
if [[ -n "$oldest_file" && -f "$oldest_file" ]]; then
|
||
local backup_path=$(jq -r '.backup_path // ""' "$oldest_file")
|
||
|
||
# 删除备份文件
|
||
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
|
||
rm -f "$backup_path"
|
||
debug "删除备份文件: $(basename "$backup_path")"
|
||
fi
|
||
|
||
# 删除信息文件
|
||
rm -f "$oldest_file"
|
||
info "🗑️ 清理回滚点: $(basename "$oldest_file")"
|
||
fi
|
||
done
|
||
|
||
info "✅ 回滚点清理完成"
|
||
return 0
|
||
}
|
||
|
||
# 显示使用帮助
|
||
# 显示使用帮助
|
||
usage() {
|
||
cat << EOF
|
||
用法: $0 [选项] [回滚点]
|
||
|
||
选项:
|
||
-l, --list 列出可用的回滚点
|
||
-d, --dry-run 干跑模式(只验证不回滚)
|
||
-c, --cleanup 回滚后清理资源
|
||
-p, --purge 清理所有旧回滚点
|
||
-a, --all 回滚所有补丁
|
||
-v, --verbose 详细输出
|
||
-q, --quiet 安静模式
|
||
-h, --help 显示此帮助
|
||
-V, --version 显示版本
|
||
|
||
参数:
|
||
回滚点可以是以下格式:
|
||
latest 最新的回滚点
|
||
<数字> 回滚点编号(使用-l查看)
|
||
<文件名> 回滚信息文件路径
|
||
<补丁名> 补丁名称
|
||
|
||
示例:
|
||
$0 -l # 列出所有回滚点
|
||
$0 latest # 回滚最新的补丁
|
||
$0 1 # 回滚编号为1的补丁
|
||
$0 --dry-run latest # 干跑模式测试回滚
|
||
$0 --cleanup latest # 回滚并清理资源
|
||
$0 --purge # 清理旧回滚点
|
||
|
||
环境变量:
|
||
PATCH_CONFIG 配置文件路径
|
||
BACKUP_DIR 备份目录
|
||
MAX_ROLLBACK_POINTS 最大保留回滚点数
|
||
EOF
|
||
}
|
||
|
||
# 显示版本信息
|
||
show_version() {
|
||
echo "patch_rollback.sh v1.0.0"
|
||
echo "企业级补丁回滚系统"
|
||
echo "支持原子性回滚、多回滚点管理、验证等功能"
|
||
}
|
||
|
||
# 主函数
|
||
main() {
|
||
local rollback_point=""
|
||
local dry_run="false"
|
||
local cleanup_after="false"
|
||
local list_only="false"
|
||
local purge_all="false"
|
||
local rollback_all="false"
|
||
local verbose_mode="false"
|
||
|
||
# 解析命令行参数
|
||
while [[ $# -gt 0 ]]; do
|
||
case $1 in
|
||
-l|--list)
|
||
list_only="true"
|
||
shift
|
||
;;
|
||
-d|--dry-run)
|
||
dry_run="true"
|
||
shift
|
||
;;
|
||
-c|--cleanup)
|
||
cleanup_after="true"
|
||
shift
|
||
;;
|
||
-p|--purge)
|
||
purge_all="true"
|
||
shift
|
||
;;
|
||
-a|--all)
|
||
rollback_all="true"
|
||
shift
|
||
;;
|
||
-v|--verbose)
|
||
verbose_mode="true"
|
||
shift
|
||
;;
|
||
-q|--quiet)
|
||
exec >/dev/null 2>&1
|
||
shift
|
||
;;
|
||
-h|--help)
|
||
usage
|
||
exit 0
|
||
;;
|
||
-V|--version)
|
||
show_version
|
||
exit 0
|
||
;;
|
||
--)
|
||
shift
|
||
break
|
||
;;
|
||
-*)
|
||
error "未知选项: $1"
|
||
usage
|
||
exit 1
|
||
;;
|
||
*)
|
||
rollback_point="$1"
|
||
shift
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# 初始化
|
||
acquire_lock
|
||
load_config
|
||
setup_environment
|
||
check_dependencies
|
||
|
||
# 处理特殊操作
|
||
if [[ "$list_only" == "true" ]]; then
|
||
list_rollback_points
|
||
exit $?
|
||
fi
|
||
|
||
if [[ "$purge_all" == "true" ]]; then
|
||
cleanup_old_rollback_points 0
|
||
exit $?
|
||
fi
|
||
|
||
if [[ "$rollback_all" == "true" ]]; then
|
||
rollback_all_patches "$dry_run" "$cleanup_after"
|
||
exit $?
|
||
fi
|
||
|
||
# 执行回滚操作
|
||
if rollback_patch "$rollback_point" "$dry_run" "$cleanup_after"; then
|
||
if [[ "$dry_run" == "true" ]]; then
|
||
info "🎉 干跑模式完成 - 可安全执行回滚"
|
||
else
|
||
info "🎉 回滚操作成功完成"
|
||
fi
|
||
exit 0
|
||
else
|
||
error "💥 回滚操作失败"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
# 批量回滚所有补丁
|
||
rollback_all_patches() {
|
||
local dry_run="$1"
|
||
local cleanup_after="$2"
|
||
|
||
info "🔄 开始批量回滚所有补丁"
|
||
|
||
if ! list_rollback_points; then
|
||
error "没有可用的回滚点"
|
||
return 1
|
||
fi
|
||
|
||
local total_count=${#points[@]}
|
||
local success_count=0
|
||
local fail_count=0
|
||
|
||
info "📊 找到 $total_count 个回滚点,开始批量回滚..."
|
||
|
||
# 按时间倒序回滚(最新的先回滚)
|
||
for ((i=total_count-1; i>=0; i--)); do
|
||
local info_file="${points[$i]}"
|
||
local patch_name=$(basename "$info_file" .json)
|
||
|
||
info "🔄 回滚 ($((total_count-i))/$total_count): $patch_name"
|
||
|
||
if rollback_patch "$info_file" "$dry_run" "$cleanup_after"; then
|
||
success_count=$((success_count+1))
|
||
info "✅ 回滚成功: $patch_name"
|
||
else
|
||
fail_count=$((fail_count+1))
|
||
error "❌ 回滚失败: $patch_name"
|
||
# 继续尝试下一个
|
||
fi
|
||
|
||
echo "---"
|
||
done
|
||
|
||
info "📊 批量回滚完成:"
|
||
info " 成功: $success_count"
|
||
info " 失败: $fail_count"
|
||
info " 总计: $total_count"
|
||
|
||
if [[ $fail_count -eq 0 ]]; then
|
||
info "✅ 所有回滚操作成功"
|
||
return 0
|
||
else
|
||
error "⚠️ 部分回滚操作失败"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# 异常处理
|
||
trap 'error "脚本执行中断"; exit 1' INT TERM
|
||
|
||
# 执行主函数
|
||
main "$@" |