862 lines
26 KiB
Bash
862 lines
26 KiB
Bash
#!/usr/bin/env bash
|
||
|
||
#===============================================================================
|
||
# 名称: lvm_extend_with_disk.sh
|
||
# 描述: 使用新增裸盘扩展指定挂载目录对应的 LVM LV(ext4 / xfs)
|
||
# 作者: WDD
|
||
# 版本: 1.0.0
|
||
# 许可证: MIT
|
||
#
|
||
# 依赖(命令): bash(>=5.0), coreutils, util-linux, lvm2, gdisk 或 parted,
|
||
# findmnt, lsblk, blkid, wipefs, partprobe, udevadm,
|
||
# resize2fs(用于ext4), xfs_growfs(用于xfs)
|
||
#
|
||
# 安全说明:
|
||
# - 本脚本会对“新增裸盘”进行 GPT 分区、清空签名(wipefs)、创建 PV 等破坏性操作。
|
||
# - 默认会做严格安全检查:若检测到磁盘疑似在使用/含签名/已有分区,将拒绝执行(可用 --force 覆盖部分检查)。
|
||
#===============================================================================
|
||
|
||
set -euo pipefail
|
||
IFS=$'\n\t'
|
||
|
||
#===============================================================================
|
||
# 全局常量定义区
|
||
#===============================================================================
|
||
readonly SCRIPT_NAME="$(basename "$0")"
|
||
readonly SCRIPT_VERSION="1.0.0"
|
||
|
||
# 日志级别:DEBUG/INFO/WARN/ERROR
|
||
readonly LOG_LEVEL_DEFAULT="INFO"
|
||
|
||
#===============================================================================
|
||
# 全局变量(由参数控制)
|
||
#===============================================================================
|
||
log_level="${LOG_LEVEL_DEFAULT}"
|
||
dry_run="0"
|
||
force="0"
|
||
raw_disk="" # 例如 /dev/sdb
|
||
target_mount="" # 例如 /data
|
||
|
||
# 运行时探测结果
|
||
fs_type="" # ext4 | xfs
|
||
mount_source="" # /dev/mapper/vg-lv 或 /dev/vg/lv
|
||
lv_path="" # 规范化后的 LV 真实路径
|
||
vg_name="" # VG 名称
|
||
new_part="" # 新建分区路径:/dev/sdb1 或 /dev/nvme0n1p1
|
||
|
||
# 回滚动作栈(先进后出)
|
||
rollback_stack=()
|
||
committed="0"
|
||
|
||
#===============================================================================
|
||
# 函数调用关系图(ASCII)
|
||
#===============================================================================
|
||
# main
|
||
# ├─ parse_args
|
||
# ├─ init_traps
|
||
# ├─ require_root
|
||
# ├─ check_dependencies
|
||
# ├─ detect_target_lvm_by_mount
|
||
# │ ├─ get_mount_info
|
||
# │ ├─ normalize_lv_and_vg
|
||
# ├─ validate_raw_disk_safe
|
||
# ├─ prepare_gpt_partition_for_lvm
|
||
# │ ├─ make_partition_path
|
||
# │ ├─ create_gpt_partition (sgdisk|parted)
|
||
# ├─ create_and_attach_pv_to_vg
|
||
# │ ├─ pvcreate
|
||
# │ ├─ vgextend
|
||
# ├─ extend_lv_and_grow_fs
|
||
# │ ├─ lvextend -r
|
||
# ├─ verify_result
|
||
# └─ success_exit
|
||
#
|
||
# on_error (trap ERR)
|
||
# └─ rollback_all
|
||
#===============================================================================
|
||
|
||
#===============================================================================
|
||
# 日志模块
|
||
#===============================================================================
|
||
|
||
### 输出日志(统一入口)
|
||
# @param level string 日志级别(DEBUG/INFO/WARN/ERROR)
|
||
# @param message string 日志内容
|
||
# @return 0 成功
|
||
# @require date, printf
|
||
log() {
|
||
local level="$1"; shift
|
||
local message="$*"
|
||
local ts
|
||
ts="$(date '+%Y-%m-%d %H:%M:%S')"
|
||
|
||
# > 级别过滤:DEBUG < INFO < WARN < ERROR
|
||
local order_current order_target
|
||
order_current="$(log_level_to_int "$log_level")"
|
||
order_target="$(log_level_to_int "$level")"
|
||
if (( order_target < order_current )); then
|
||
return 0
|
||
fi
|
||
|
||
printf '[%s] [%s] %s\n' "$ts" "$level" "$message" >&2
|
||
}
|
||
|
||
### 将日志级别映射为数字
|
||
# @param level string 日志级别
|
||
# @return 0 成功(echo输出数字)
|
||
# @require printf
|
||
log_level_to_int() {
|
||
local level="$1"
|
||
case "$level" in
|
||
DEBUG) printf '10' ;;
|
||
INFO) printf '20' ;;
|
||
WARN) printf '30' ;;
|
||
ERROR) printf '40' ;;
|
||
*) printf '20' ;; # 默认 INFO
|
||
esac
|
||
}
|
||
|
||
log_debug(){ log "DEBUG" "$*"; }
|
||
log_info(){ log "INFO" "$*"; }
|
||
log_warn(){ log "WARN" "$*"; }
|
||
log_error(){ log "ERROR" "$*"; }
|
||
|
||
#===============================================================================
|
||
# 工具/执行模块
|
||
#===============================================================================
|
||
|
||
### 执行命令(支持 dry-run)
|
||
# @param cmd string 要执行的命令(以参数形式传入)
|
||
# @return 0 成功;非0 失败
|
||
# @require printf, bash
|
||
run_cmd() {
|
||
if [[ "$dry_run" == "1" ]]; then
|
||
log_info "[DRY-RUN] $*"
|
||
return 0
|
||
fi
|
||
log_debug "RUN: $*"
|
||
"$@"
|
||
}
|
||
|
||
### 检查命令是否存在
|
||
# @param cmd string 命令名
|
||
# @return 0 存在;非0 不存在
|
||
# @require command
|
||
require_cmd() {
|
||
local cmd="$1"
|
||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||
log_error "缺少依赖命令: $cmd"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
### 推入回滚动作(字符串命令)
|
||
# @param action string 回滚动作(将通过 bash -c 执行)
|
||
# @return 0 成功
|
||
# @require none
|
||
push_rollback() {
|
||
local action="$1"
|
||
rollback_stack+=("$action")
|
||
log_debug "已登记回滚动作: $action"
|
||
}
|
||
|
||
### 执行所有回滚动作(LIFO)
|
||
# @return 0 成功(尽最大努力)
|
||
# @require bash
|
||
rollback_all() {
|
||
local i
|
||
if (( ${#rollback_stack[@]} == 0 )); then
|
||
log_warn "无回滚动作可执行。"
|
||
return 0
|
||
fi
|
||
|
||
log_warn "开始回滚(共 ${#rollback_stack[@]} 步)..."
|
||
for (( i=${#rollback_stack[@]}-1; i>=0; i-- )); do
|
||
local action="${rollback_stack[$i]}"
|
||
log_warn "回滚: $action"
|
||
if [[ "$dry_run" == "1" ]]; then
|
||
log_info "[DRY-RUN] 跳过回滚执行"
|
||
continue
|
||
fi
|
||
# > 回滚尽力而为:失败不再中断后续回滚
|
||
bash -c "$action" || log_warn "回滚动作执行失败(已忽略): $action"
|
||
done
|
||
log_warn "回滚结束。"
|
||
}
|
||
|
||
#===============================================================================
|
||
# Trap/错误处理模块
|
||
#===============================================================================
|
||
|
||
### 初始化 trap
|
||
# @return 0 成功
|
||
# @require trap
|
||
init_traps() {
|
||
trap 'on_error $? $LINENO' ERR
|
||
trap 'on_exit $?' EXIT
|
||
}
|
||
|
||
### ERR trap 处理
|
||
# @param exit_code int 退出码
|
||
# @param line_no int 行号
|
||
# @return 0 成功
|
||
# @require none
|
||
on_error() {
|
||
local exit_code="$1"
|
||
local line_no="$2"
|
||
|
||
log_error "脚本执行失败(exit=$exit_code, line=$line_no)。"
|
||
|
||
if [[ "${committed}" == "1" ]]; then
|
||
log_error "检测到已提交变更(LV/FS 已扩容),为避免破坏性操作:将跳过自动回滚。"
|
||
exit "$exit_code"
|
||
fi
|
||
|
||
log_error "将进行回滚..."
|
||
rollback_all
|
||
exit "$exit_code"
|
||
}
|
||
|
||
|
||
### EXIT trap 处理(用于输出调试信息,不做回滚)
|
||
# @param exit_code int 退出码
|
||
# @return 0 成功
|
||
# @require none
|
||
on_exit() {
|
||
local exit_code="$1"
|
||
if [[ "$exit_code" == "0" ]]; then
|
||
log_info "完成:扩展流程已成功结束。"
|
||
else
|
||
log_error "退出:脚本以非0退出码结束(exit=$exit_code)。"
|
||
fi
|
||
}
|
||
|
||
#===============================================================================
|
||
# 参数解析与使用说明
|
||
#===============================================================================
|
||
|
||
### 使用说明
|
||
# @return 0 成功
|
||
# @require cat
|
||
usage() {
|
||
cat <<EOF
|
||
用法:
|
||
$SCRIPT_NAME --disk /dev/sdX --mount /path/to/mount [--force] [--dry-run] [--log-level DEBUG|INFO|WARN|ERROR]
|
||
|
||
参数:
|
||
--disk, -d 需要格式化并用于扩展的裸盘(例如 /dev/sdb, /dev/nvme1n1)
|
||
--mount, -m 需要扩展的挂载目录(例如 /data)
|
||
--force, -f 强制执行:当检测到磁盘含签名/已有分区等风险时仍继续(仍会拒绝“已挂载/已作为PV”的磁盘)
|
||
--dry-run 演练模式:仅打印将执行的操作,不实际改动
|
||
--log-level 日志级别(默认 INFO)
|
||
|
||
示例:
|
||
$SCRIPT_NAME -d /dev/sdb -m /data
|
||
$SCRIPT_NAME -d /dev/nvme1n1 -m /data --force
|
||
EOF
|
||
}
|
||
|
||
### 解析参数
|
||
# @param args string 参数列表
|
||
# @return 0 成功
|
||
# @require shift
|
||
parse_args() {
|
||
while (( $# > 0 )); do
|
||
case "$1" in
|
||
-d|--disk) raw_disk="${2:-}"; shift 2 ;;
|
||
-m|--mount) target_mount="${2:-}"; shift 2 ;;
|
||
-f|--force) force="1"; shift ;;
|
||
--dry-run) dry_run="1"; shift ;;
|
||
--log-level) log_level="${2:-INFO}"; shift 2 ;;
|
||
-h|--help) usage; exit 0 ;;
|
||
*)
|
||
log_error "未知参数: $1"
|
||
usage
|
||
exit 2
|
||
;;
|
||
esac
|
||
done
|
||
|
||
if [[ -z "$raw_disk" || -z "$target_mount" ]]; then
|
||
log_error "必须提供 --disk 与 --mount"
|
||
usage
|
||
exit 2
|
||
fi
|
||
}
|
||
|
||
#===============================================================================
|
||
# 前置检查模块
|
||
#===============================================================================
|
||
|
||
### 检查是否 root
|
||
# @return 0 成功;非0 失败
|
||
# @require id
|
||
require_root() {
|
||
if [[ "$(id -u)" != "0" ]]; then
|
||
log_error "必须以 root 运行(需要分区/LVM 操作权限)。"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
### 检查依赖命令
|
||
# @return 0 成功;非0 失败
|
||
# @require command
|
||
check_dependencies() {
|
||
# 基础
|
||
require_cmd findmnt
|
||
require_cmd lsblk
|
||
require_cmd blkid
|
||
require_cmd wipefs
|
||
require_cmd partprobe
|
||
require_cmd udevadm
|
||
|
||
# LVM
|
||
require_cmd pvs
|
||
require_cmd vgs
|
||
require_cmd lvs
|
||
require_cmd pvcreate
|
||
require_cmd pvremove
|
||
require_cmd vgextend
|
||
require_cmd vgreduce
|
||
require_cmd lvextend
|
||
|
||
# 分区工具:优先 sgdisk(gdisk套件),否则 parted
|
||
if command -v sgdisk >/dev/null 2>&1; then
|
||
:
|
||
else
|
||
require_cmd parted
|
||
fi
|
||
|
||
# 文件系统扩展工具(由探测类型决定)
|
||
# > 在探测 fs_type 后再校验(此处先不强制)
|
||
}
|
||
|
||
#===============================================================================
|
||
# 目标挂载点探测模块
|
||
#===============================================================================
|
||
|
||
### 获取挂载信息(source 与 fstype)
|
||
# @param mount_path string 挂载目录
|
||
# @return 0 成功
|
||
# @require findmnt
|
||
get_mount_info() {
|
||
local mount_path="$1"
|
||
|
||
if ! findmnt -T "$mount_path" >/dev/null 2>&1; then
|
||
log_error "挂载目录不存在或未挂载: $mount_path"
|
||
return 1
|
||
fi
|
||
|
||
mount_source="$(findmnt -nr -T "$mount_path" -o SOURCE)"
|
||
fs_type="$(findmnt -nr -T "$mount_path" -o FSTYPE)"
|
||
|
||
if [[ -z "$mount_source" || -z "$fs_type" ]]; then
|
||
log_error "无法获取挂载信息: mount=$mount_path"
|
||
return 1
|
||
fi
|
||
|
||
case "$fs_type" in
|
||
ext4|xfs) ;;
|
||
*)
|
||
log_error "仅支持 ext4 或 xfs,当前检测到: $fs_type"
|
||
return 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
### 获取块设备 MAJ:MIN(主:次设备号)
|
||
# @param dev string 块设备路径
|
||
# @return 0 成功(echo 输出 253:0);非0 失败
|
||
# @require lsblk, printf
|
||
get_maj_min() {
|
||
local dev="$1"
|
||
local mm
|
||
mm="$(lsblk -dn -o MAJ:MIN "$dev" 2>/dev/null | awk '{$1=$1;print}')"
|
||
if [[ -z "$mm" ]]; then
|
||
log_error "无法获取 MAJ:MIN: dev=$dev"
|
||
return 1
|
||
fi
|
||
printf '%s' "$mm"
|
||
}
|
||
|
||
### 通过扫描 lvs 输出匹配 LV/VG(优先 dm_path,其次 MAJ:MIN)
|
||
# @param src_dev string 解析后的挂载设备(如 /dev/mapper/vg-lv 或 /dev/dm-0)
|
||
# @param real_dev string 真实设备(readlink -f 后,如 /dev/dm-0)
|
||
# @return 0 成功(echo 输出 "lv_path|vg_name")
|
||
# @require lvs, awk, readlink, lsblk
|
||
lookup_lv_vg_from_lvs() {
|
||
local src_dev="$1"
|
||
local real_dev="$2"
|
||
local mm
|
||
mm="$(get_maj_min "$real_dev")"
|
||
|
||
# A) 尝试:lvs 输出包含 lv_dm_path 时,用 dm_path 直接匹配(最稳)
|
||
# > 不用 --select,避免 selection 兼容性问题
|
||
if out="$(lvs --noheadings --separator '|' -o lv_path,vg_name,lv_dm_path 2>/dev/null)"; then
|
||
local hit
|
||
hit="$(
|
||
echo "$out" | awk -F'|' -v s="$src_dev" -v r="$real_dev" '
|
||
function trim(x){gsub(/^[ \t]+|[ \t]+$/,"",x); return x}
|
||
{
|
||
lv=trim($1); vg=trim($2); dm=trim($3)
|
||
if(lv=="" || vg=="") next
|
||
# 直接匹配 dm_path / src_dev / real_dev
|
||
if(dm==s || dm==r) {print lv "|" vg; exit}
|
||
}
|
||
'
|
||
)"
|
||
if [[ -n "$hit" ]]; then
|
||
printf '%s' "$hit"
|
||
return 0
|
||
fi
|
||
|
||
# 再放宽:readlink -f(dm_path) 与 real_dev 比较(处理 dm_path 是 /dev/mapper/* 的情况)
|
||
hit="$(
|
||
echo "$out" | while IFS= read -r line; do
|
||
[[ -z "$line" ]] && continue
|
||
local lv vg dm dm_real
|
||
lv="$(echo "$line" | awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/,"",$1);print $1}')"
|
||
vg="$(echo "$line" | awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/,"",$2);print $2}')"
|
||
dm="$(echo "$line" | awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/,"",$3);print $3}')"
|
||
[[ -z "$lv" || -z "$vg" || -z "$dm" ]] && continue
|
||
dm_real="$(readlink -f "$dm" 2>/dev/null || true)"
|
||
if [[ "$dm_real" == "$real_dev" ]]; then
|
||
echo "${lv}|${vg}"
|
||
break
|
||
fi
|
||
done
|
||
)"
|
||
if [[ -n "$hit" ]]; then
|
||
printf '%s' "$hit"
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
# B) 回退:用 lv_kernel_major/minor 匹配 MAJ:MIN(跨版本最通用)
|
||
# > 注意:部分环境列名可能不同,但大多数 lvm2 支持这两个字段
|
||
if out2="$(lvs --noheadings --separator '|' -o lv_path,vg_name,lv_kernel_major,lv_kernel_minor 2>/dev/null)"; then
|
||
local major minor
|
||
major="${mm%%:*}"
|
||
minor="${mm##*:}"
|
||
local hit2
|
||
hit2="$(
|
||
echo "$out2" | awk -F'|' -v mj="$major" -v mn="$minor" '
|
||
function trim(x){gsub(/^[ \t]+|[ \t]+$/,"",x); return x}
|
||
{
|
||
lv=trim($1); vg=trim($2); kmj=trim($3); kmn=trim($4)
|
||
if(lv=="" || vg=="") next
|
||
if(kmj==mj && kmn==mn) {print lv "|" vg; exit}
|
||
}
|
||
'
|
||
)"
|
||
if [[ -n "$hit2" ]]; then
|
||
printf '%s' "$hit2"
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
# C) 兜底:lsblk 直接读 VG/LV 字段(某些 util-linux 会提供)
|
||
if vg="$(lsblk -dn -o VG "$real_dev" 2>/dev/null | awk '{$1=$1;print}')" \
|
||
&& lvname="$(lsblk -dn -o LV "$real_dev" 2>/dev/null | awk '{$1=$1;print}')"; then
|
||
if [[ -n "$vg" && -n "$lvname" ]]; then
|
||
printf '/dev/%s/%s|%s' "$vg" "$lvname" "$vg"
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
return 1
|
||
}
|
||
|
||
|
||
|
||
### 将 findmnt 的 SOURCE 解析为可用的块设备路径(兼容 UUID/LABEL)
|
||
# @param source string 挂载源(/dev/xxx, UUID=..., LABEL=...)
|
||
# @return 0 成功(echo 输出 /dev/xxx);非0 失败
|
||
# @require blkid, printf
|
||
resolve_mount_source_device() {
|
||
local source="$1"
|
||
local dev=""
|
||
|
||
case "$source" in
|
||
/dev/*)
|
||
dev="$source"
|
||
;;
|
||
UUID=*|LABEL=*)
|
||
# > 关键步骤:把 UUID=xxxx / LABEL=xxx 解析成 /dev/xxx
|
||
dev="$(blkid -o device -t "$source" 2>/dev/null | head -n1 || true)"
|
||
;;
|
||
*)
|
||
# 少数情况下 SOURCE 可能是 mapper 名称等,尝试补全
|
||
if [[ -e "/dev/$source" ]]; then
|
||
dev="/dev/$source"
|
||
fi
|
||
;;
|
||
esac
|
||
|
||
if [[ -z "$dev" || ! -e "$dev" ]]; then
|
||
log_error "无法将挂载源解析为设备路径: source=$source"
|
||
return 1
|
||
fi
|
||
|
||
if [[ ! -b "$dev" ]]; then
|
||
log_error "解析后的路径不是块设备: dev=$dev (from source=$source)"
|
||
return 1
|
||
fi
|
||
|
||
printf '%s' "$dev"
|
||
}
|
||
|
||
### 规范化 LV 路径并获取 VG 名称(强健版:避免 lvs --select 兼容性坑)
|
||
# @param source string 挂载源(findmnt SOURCE)
|
||
# @return 0 成功
|
||
# @require readlink, lvs, awk, lsblk, blkid
|
||
normalize_lv_and_vg() {
|
||
local source="$1"
|
||
local src_dev real_dev out
|
||
|
||
src_dev="$(resolve_mount_source_device "$source")"
|
||
real_dev="$(readlink -f "$src_dev")"
|
||
|
||
if [[ -z "$real_dev" || ! -b "$real_dev" ]]; then
|
||
log_error "无法解析真实设备路径: src_dev=$src_dev real_dev=$real_dev"
|
||
return 1
|
||
fi
|
||
|
||
log_debug "mount source resolve: source=$source src_dev=$src_dev real_dev=$real_dev"
|
||
|
||
if ! out="$(lookup_lv_vg_from_lvs "$src_dev" "$real_dev")"; then
|
||
log_error "无法从挂载源反查 LVM LV/VG:source=$source src_dev=$src_dev real_dev=$real_dev"
|
||
log_error "诊断建议:执行 `lvs -a -o lv_path,vg_name,lv_dm_path,lv_kernel_major,lv_kernel_minor` 查看映射。"
|
||
return 1
|
||
fi
|
||
|
||
lv_path="${out%%|*}"
|
||
vg_name="${out##*|}"
|
||
|
||
if [[ -z "$lv_path" || -z "$vg_name" ]]; then
|
||
log_error "解析 LV/VG 失败: out=$out"
|
||
return 1
|
||
fi
|
||
|
||
# 额外校验:lv_path 设备存在(有的环境返回的是 /dev/vg/lv,应该存在)
|
||
if [[ ! -e "$lv_path" ]]; then
|
||
log_warn "lvs 返回的 lv_path 不存在,尝试 readlink/替代路径: lv_path=$lv_path"
|
||
# > 这里不强制失败,后续 lvextend 会更明确报错
|
||
fi
|
||
|
||
log_info "已解析目标: vg=$vg_name lv=$lv_path (from source=$source)"
|
||
}
|
||
|
||
|
||
### 综合探测目标 LVM
|
||
# @return 0 成功
|
||
# @require none
|
||
detect_target_lvm_by_mount() {
|
||
get_mount_info "$target_mount"
|
||
normalize_lv_and_vg "$mount_source"
|
||
|
||
# fs 工具依赖补充检查
|
||
if [[ "$fs_type" == "ext4" ]]; then
|
||
require_cmd resize2fs
|
||
else
|
||
require_cmd xfs_growfs
|
||
fi
|
||
|
||
log_info "目标挂载点: $target_mount"
|
||
log_info "挂载源设备: $mount_source"
|
||
log_info "文件系统类型: $fs_type"
|
||
log_info "LV 路径: $lv_path"
|
||
log_info "VG 名称: $vg_name"
|
||
}
|
||
|
||
#===============================================================================
|
||
# 新增裸盘安全检查模块
|
||
#===============================================================================
|
||
|
||
### 校验裸盘是否安全可用
|
||
# @param disk string 裸盘路径(/dev/sdX 或 /dev/nvmeXnY)
|
||
# @return 0 成功
|
||
# @require lsblk, pvs, wipefs
|
||
validate_raw_disk_safe() {
|
||
local disk="$1"
|
||
|
||
if [[ ! -b "$disk" ]]; then
|
||
log_error "指定磁盘不是块设备: $disk"
|
||
return 1
|
||
fi
|
||
|
||
# 必须是 disk 类型
|
||
local dtype
|
||
dtype="$(lsblk -dn -o TYPE "$disk" 2>/dev/null || true)"
|
||
if [[ "$dtype" != "disk" ]]; then
|
||
log_error "指定设备不是磁盘(disk)类型: $disk (type=$dtype)"
|
||
return 1
|
||
fi
|
||
|
||
# 不能已作为 PV
|
||
if pvs --noheadings -o pv_name 2>/dev/null | awk '{$1=$1;print}' | grep -Fxq "$disk"; then
|
||
log_error "磁盘已是 LVM PV,拒绝操作: $disk"
|
||
return 1
|
||
fi
|
||
|
||
# 不能存在已挂载分区
|
||
local mps
|
||
mps="$(lsblk -nr -o MOUNTPOINT "$disk" 2>/dev/null | awk 'NF{print}')"
|
||
if [[ -n "$mps" ]]; then
|
||
log_error "检测到磁盘或其分区已挂载,拒绝操作: $disk"
|
||
log_error "挂载点: $mps"
|
||
return 1
|
||
fi
|
||
|
||
# 若存在子分区/签名,默认拒绝(--force 可放宽)
|
||
local has_children has_sig
|
||
has_children="$(lsblk -nr "$disk" -o NAME | awk 'NR>1{print}' | wc -l | awk '{$1=$1;print}')"
|
||
has_sig="0"
|
||
if wipefs -n "$disk" | awk 'NR>1{exit 0} END{exit 1}'; then
|
||
has_sig="1"
|
||
fi
|
||
|
||
if [[ "$force" != "1" ]]; then
|
||
if [[ "$has_children" != "0" ]]; then
|
||
log_error "磁盘存在分区/子设备(默认拒绝)。可加 --force 继续: $disk"
|
||
return 1
|
||
fi
|
||
if [[ "$has_sig" == "1" ]]; then
|
||
log_error "磁盘存在文件系统/签名(默认拒绝)。可加 --force 继续: $disk"
|
||
return 1
|
||
fi
|
||
else
|
||
# --force 仍然禁止“已作为PV的分区”
|
||
if pvs --noheadings -o pv_name 2>/dev/null | awk '{$1=$1;print}' | grep -q "^${disk}"; then
|
||
log_error "检测到磁盘相关分区已是 PV(即使 --force 也拒绝): $disk"
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
log_info "新增裸盘安全检查通过: $disk"
|
||
}
|
||
|
||
#===============================================================================
|
||
# 分区与 PV/VG/LV 扩展模块
|
||
#===============================================================================
|
||
|
||
### 生成分区路径(兼容 nvme 设备命名)
|
||
# @param disk string 磁盘路径
|
||
# @return 0 成功(echo 输出分区路径)
|
||
# @require printf
|
||
make_partition_path() {
|
||
local disk="$1"
|
||
# > nvme/mmc 通常以数字结尾,需要 p1
|
||
if [[ "$disk" =~ [0-9]$ ]]; then
|
||
printf '%sp1' "$disk"
|
||
else
|
||
printf '%s1' "$disk"
|
||
fi
|
||
}
|
||
|
||
### 创建 GPT 分区并设置为 LVM 类型(1号分区使用全盘)
|
||
# @param disk string 裸盘
|
||
# @return 0 成功
|
||
# @require sgdisk 或 parted, partprobe, udevadm, wipefs
|
||
prepare_gpt_partition_for_lvm() {
|
||
local disk="$1"
|
||
|
||
new_part="$(make_partition_path "$disk")"
|
||
|
||
# 回滚:尽量恢复到“无分区表/无签名”的状态
|
||
# > 注意:这是破坏性回滚,仅针对“新增裸盘”
|
||
push_rollback "wipefs -a '$disk' || true"
|
||
if command -v sgdisk >/dev/null 2>&1; then
|
||
push_rollback "sgdisk --zap-all '$disk' || true"
|
||
else
|
||
# parted 无直接 zap-all;用 dd 清前 10MiB(尽力而为)
|
||
push_rollback "dd if=/dev/zero of='$disk' bs=1M count=10 conv=fsync || true"
|
||
fi
|
||
|
||
log_info "开始为裸盘创建 GPT + LVM 分区: disk=$disk part=$new_part"
|
||
|
||
# > 关键步骤:先清签名,再分区
|
||
run_cmd wipefs -a "$disk"
|
||
|
||
if command -v sgdisk >/dev/null 2>&1; then
|
||
# > 关键步骤:清旧分区表并创建新 GPT
|
||
run_cmd sgdisk --zap-all "$disk"
|
||
run_cmd sgdisk -og "$disk"
|
||
# > 关键步骤:创建 1号分区:全盘,类型 8e00(Linux LVM)
|
||
run_cmd sgdisk -n 1:0:0 -t 1:8e00 -c 1:"lvm-pv" "$disk"
|
||
else
|
||
# parted 方式
|
||
# > 关键步骤:创建 GPT + 单分区 + LVM 标记
|
||
run_cmd parted -s "$disk" mklabel gpt
|
||
run_cmd parted -s "$disk" mkpart primary 1MiB 100%
|
||
run_cmd parted -s "$disk" set 1 lvm on
|
||
fi
|
||
|
||
run_cmd partprobe "$disk"
|
||
run_cmd udevadm settle
|
||
|
||
if [[ ! -b "$new_part" ]]; then
|
||
log_error "分区设备未出现(可能需要稍等或内核未识别): $new_part"
|
||
return 1
|
||
fi
|
||
|
||
log_info "分区创建完成: $new_part"
|
||
}
|
||
|
||
### 创建 PV 并扩展到目标 VG
|
||
# @param part string 新分区
|
||
# @param vg string 目标 VG 名称
|
||
# @return 0 成功
|
||
# @require pvcreate, vgextend, vgreduce, pvremove
|
||
create_and_attach_pv_to_vg() {
|
||
local part="$1"
|
||
local vg="$2"
|
||
|
||
# 防止误用:新分区不应已是 PV
|
||
if pvs --noheadings -o pv_name 2>/dev/null | awk '{$1=$1;print}' | grep -Fxq "$part"; then
|
||
log_error "分区已是 PV,拒绝重复操作: $part"
|
||
return 1
|
||
fi
|
||
|
||
log_info "创建 PV: $part"
|
||
run_cmd pvcreate -y "$part"
|
||
push_rollback "pvremove -ff -y '$part' || true"
|
||
|
||
log_info "扩展 VG: vgextend $vg $part"
|
||
run_cmd vgextend "$vg" "$part"
|
||
push_rollback "vgreduce '$vg' '$part' || true"
|
||
}
|
||
|
||
### 扩展 LV 并增长文件系统(使用 -r 自动处理)
|
||
# @param lv string LV 路径
|
||
# @return 0 成功
|
||
# @require lvextend
|
||
extend_lv_and_grow_fs() {
|
||
local lv="$1"
|
||
|
||
log_info "扩展 LV 并自动扩容文件系统: lvextend -l +100%FREE -r $lv"
|
||
run_cmd lvextend -l +100%FREE -r "$lv"
|
||
|
||
# > 关键步骤:一旦走到这里,磁盘变更已提交,不应再做自动回滚
|
||
committed="1"
|
||
log_info "已提交变更(LV/FS 扩容已完成),后续将禁止自动回滚。"
|
||
}
|
||
|
||
|
||
#===============================================================================
|
||
# 结果校验模块
|
||
#===============================================================================
|
||
### 校验扩展结果(修复:PV->VG 校验误判)
|
||
# @param mount_path string 挂载目录
|
||
# @param lv string LV 路径
|
||
# @param part string 新增 PV 分区
|
||
# @param vg string VG 名称
|
||
# @return 0 成功
|
||
# @require df, lvs, vgs, pvs, awk, readlink
|
||
verify_result() {
|
||
local mount_path="$1"
|
||
local lv="$2"
|
||
local part="$3"
|
||
local vg="$4"
|
||
|
||
log_info "开始校验扩展结果..."
|
||
|
||
# 1) PV 是否在 VG 中(结构化解析 + 归一化路径)
|
||
local part_real
|
||
part_real="$(readlink -f "$part" 2>/dev/null || true)"
|
||
[[ -z "$part_real" ]] && part_real="$part"
|
||
|
||
local found="0"
|
||
while IFS='|' read -r pv_name pv_vg; do
|
||
pv_name="$(awk '{$1=$1;print}' <<<"${pv_name:-}")"
|
||
pv_vg="$(awk '{$1=$1;print}' <<<"${pv_vg:-}")"
|
||
[[ -z "$pv_name" || -z "$pv_vg" ]] && continue
|
||
|
||
local pv_real
|
||
pv_real="$(readlink -f "$pv_name" 2>/dev/null || true)"
|
||
[[ -z "$pv_real" ]] && pv_real="$pv_name"
|
||
|
||
if [[ "$pv_real" == "$part_real" && "$pv_vg" == "$vg" ]]; then
|
||
found="1"
|
||
break
|
||
fi
|
||
done < <(pvs --noheadings --separator '|' -o pv_name,vg_name 2>/dev/null || true)
|
||
|
||
if [[ "$found" != "1" ]]; then
|
||
log_error "校验失败:新增 PV 未正确加入 VG: part=$part (real=$part_real) vg=$vg"
|
||
log_error "诊断信息:pvs -o pv_name,vg_name 输出如下(供排查):"
|
||
if [[ "$dry_run" == "1" ]]; then
|
||
log_info "[DRY-RUN] 跳过诊断输出"
|
||
else
|
||
pvs --noheadings --separator '|' -o pv_name,vg_name >&2 || true
|
||
fi
|
||
return 1
|
||
fi
|
||
|
||
# 2) LV 是否可查询
|
||
if ! lvs --noheadings -o lv_path 2>/dev/null | awk '{$1=$1;print}' | grep -Fxq "$lv"; then
|
||
log_error "校验失败:无法查询到 LV: $lv"
|
||
return 1
|
||
fi
|
||
|
||
# 3) df 输出(人类可读)
|
||
log_info "df -h($mount_path):"
|
||
if [[ "$dry_run" == "1" ]]; then
|
||
log_info "[DRY-RUN] 跳过 df -h"
|
||
else
|
||
df -h "$mount_path" >&2
|
||
fi
|
||
|
||
log_info "校验通过。"
|
||
}
|
||
|
||
|
||
#===============================================================================
|
||
# 主流程
|
||
#===============================================================================
|
||
|
||
### 主函数
|
||
# @return 0 成功
|
||
# @require none
|
||
main() {
|
||
parse_args "$@"
|
||
init_traps
|
||
require_root
|
||
check_dependencies
|
||
|
||
detect_target_lvm_by_mount
|
||
validate_raw_disk_safe "$raw_disk"
|
||
|
||
prepare_gpt_partition_for_lvm "$raw_disk"
|
||
create_and_attach_pv_to_vg "$new_part" "$vg_name"
|
||
extend_lv_and_grow_fs "$lv_path"
|
||
|
||
if ! verify_result "$target_mount" "$lv_path" "$new_part" "$vg_name"; then
|
||
log_warn "校验未通过,但扩容步骤已完成。请人工复核:pvs/vgs/lvs/df。"
|
||
exit 0
|
||
fi
|
||
|
||
# 成功后:清空回滚栈(避免 EXIT 误触发回滚;本脚本回滚仅在 ERR trap)
|
||
rollback_stack=()
|
||
log_info "扩展成功:mount=$target_mount lv=$lv_path vg=$vg_name new_pv=$new_part"
|
||
}
|
||
|
||
main "$@"
|
||
|
||
|
||
# pvs -o pv_name,vg_name,pv_size,pv_free
|
||
# vgs datavg -o vg_name,vg_size,vg_free
|
||
# lvs datavg/lvdata -o lv_path,lv_size,seg_count
|
||
# df -hT /var/lib/docker
|
||
|
||
|
||
|
||
# 综合目的是为了,支持linux环境下新增磁盘逻辑卷的拓展,请实现如下的功能
|
||
# 1. 参数变量
|
||
# 1. 设置需要格式化的裸盘名称
|
||
# 2. 设置需要扩展的磁盘目录
|
||
# 2. 实际脚本
|
||
# 1. 检查脚本需要使用的依赖
|
||
# 2. 根据需要扩展的磁盘目录,检测到相应的PV LV名称,磁盘格式为ext4或者XFS
|
||
# 3. 需要将新格式化的裸盘,进行GPT格式的修改,PV创建,然后将PV扩展至需要扩展的磁盘目录的LV逻辑卷中
|
||
# 4. 检查实际的扩展是否成功,如果失败,恢复新格式化的裸盘清除的功能,恢复至原本状态 |