Files
CmiiDeploy/998-常用脚本/磁盘脚本/3-扩展磁盘.sh

862 lines
26 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
#===============================================================================
# 名称: lvm_extend_with_disk.sh
# 描述: 使用新增裸盘扩展指定挂载目录对应的 LVM LVext4 / 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
# 分区工具:优先 sgdiskgdisk套件否则 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/VGsource=$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. 检查实际的扩展是否成功,如果失败,恢复新格式化的裸盘清除的功能,恢复至原本状态