This commit is contained in:
zj
2026-02-03 03:25:29 +08:00
parent 30ab5711be
commit 10cc7d1c91
9 changed files with 1624 additions and 83 deletions

View File

@@ -1,9 +1,12 @@
# raid_operations.py
import logging
import subprocess
from PySide6.QtWidgets import QMessageBox
from PySide6.QtWidgets import QMessageBox, QInputDialog, QLineEdit # 导入 QInputDialog 和 QLineEdit
from PySide6.QtCore import Qt
from system_info import SystemInfoManager
import re # 导入正则表达式模块
import re
import pexpect # <--- 新增导入 pexpect
import sys
logger = logging.getLogger(__name__)
@@ -11,14 +14,16 @@ class RaidOperations:
def __init__(self):
self.system_manager = SystemInfoManager()
def _execute_shell_command(self, command_list, error_msg_prefix, suppress_critical_dialog_on_stderr_match=None, input_to_command=None): # <--- 添加 input_to_command 参数
def _execute_shell_command(self, command_list, error_msg_prefix, suppress_critical_dialog_on_stderr_match=None, input_to_command=None):
"""
执行一个shell命令并返回stdout和stderr。
这个方法包装了 SystemInfoManager._run_command并统一处理日志和错误消息框。
它会通过 SystemInfoManager 自动处理 sudo。
:param command_list: 命令及其参数的列表。
:param error_msg_prefix: 命令失败时显示给用户的错误消息前缀。
:param suppress_critical_dialog_on_stderr_match: 一个字符串,如果在 stderr 中找到此字符串,
:param suppress_critical_dialog_on_stderr_match: 一个字符串或字符串元组。
如果在 stderr 中找到此字符串(或元组中的任一字符串),
则在 CalledProcessError 发生时,只记录日志,不弹出 QMessagebox.critical。
:param input_to_command: 传递给命令stdin的数据 (str)。
:return: (bool success, str stdout, str stderr)
@@ -27,11 +32,12 @@ class RaidOperations:
logger.info(f"执行命令: {full_cmd_str}")
try:
# SystemInfoManager._run_command 应该负责在 root_privilege=True 时添加 sudo
stdout, stderr = self.system_manager._run_command(
command_list,
root_privilege=True,
check_output=True, # RAID操作通常需要检查输出
input_data=input_to_command # <--- 将 input_to_command 传递给 _run_command
root_privilege=True, # 假设所有 RAID 操作都需要 root 权限
check_output=True,
input_data=input_to_command
)
if stdout: logger.debug(f"命令 '{full_cmd_str}' 标准输出:\n{stdout.strip()}")
@@ -39,18 +45,31 @@ class RaidOperations:
return True, stdout, stderr
except subprocess.CalledProcessError as e:
stderr_output = e.stderr.strip() # 获取标准错误输出
stderr_output = e.stderr.strip()
logger.error(f"{error_msg_prefix} 命令: {full_cmd_str}")
logger.error(f"退出码: {e.returncode}")
logger.error(f"标准输出: {e.stdout.strip()}")
logger.error(f"标准错误: {stderr_output}")
# 根据 suppress_critical_dialog_on_stderr_match 参数决定是否弹出错误对话框
if suppress_critical_dialog_on_stderr_match and suppress_critical_dialog_on_stderr_match in stderr_output:
logger.info(f"特定错误 '{suppress_critical_dialog_on_stderr_match}' 匹配,已抑制错误对话框。")
# --- 修改开始 ---
should_suppress_dialog = False
if suppress_critical_dialog_on_stderr_match:
if isinstance(suppress_critical_dialog_on_stderr_match, str):
if suppress_critical_dialog_on_stderr_match in stderr_output:
should_suppress_dialog = True
elif isinstance(suppress_critical_dialog_on_stderr_match, (list, tuple)):
for pattern in suppress_critical_dialog_on_stderr_match:
if pattern in stderr_output:
should_suppress_dialog = True
break # 找到一个匹配就足够了
if should_suppress_dialog:
logger.info(f"特定错误 '{stderr_output}' 匹配抑制条件,已抑制错误对话框。")
else:
QMessageBox.critical(None, "错误", f"{error_msg_prefix}\n详细信息: {stderr_output}")
return False, e.stdout, stderr_output # 返回 stderr_output
# --- 修改结束 ---
return False, e.stdout, stderr_output
except FileNotFoundError:
logger.error(f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。")
QMessageBox.critical(None, "错误", f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。")
@@ -60,6 +79,143 @@ class RaidOperations:
QMessageBox.critical(None, "错误", f"{error_msg_prefix}\n未知错误: {e}")
return False, "", str(e)
def _execute_interactive_mdadm_command(self, command_list, error_msg_prefix):
"""
专门处理 mdadm --create 命令的交互式执行。
通过 pexpect 监听提示并弹出 QMessageBox 让用户选择。
"""
# pexpect.spawn 需要一个字符串命令,并且 sudo 应该包含在内
# 假设 SystemInfoManager._run_command 在 root_privilege=True 时会添加 sudo
# 但 pexpect.spawn 需要完整的命令字符串,所以这里手动添加 sudo
full_cmd_str = "sudo " + ' '.join(command_list)
logger.info(f"执行交互式命令: {full_cmd_str}")
stdout_buffer = []
try:
# 增加 timeout因为用户交互可能需要时间
child = pexpect.spawn(full_cmd_str, encoding='utf-8', timeout=300) # 增加超时到 5 分钟
child.logfile = sys.stdout
# --- 1. 优先处理 sudo 密码提示 ---
try:
# 尝试匹配 sudo 密码提示,设置较短的超时,如果没出现就继续
index = child.expect(['[pP]assword for', pexpect.TIMEOUT], timeout=5)
if index == 0: # 匹配到密码提示
stdout_buffer.append(child.before) # 捕获提示前的输出
password, ok = QInputDialog.getText(
None, "Sudo 密码", "请输入您的 sudo 密码:", QLineEdit.Password
)
if not ok or not password:
QMessageBox.critical(None, "错误", "sudo 密码未提供或取消。")
child.close()
return False, "".join(stdout_buffer), "Sudo 密码未提供或取消。"
child.sendline(password)
stdout_buffer.append(child.after) # 捕获发送密码后的输出
logger.info("已发送 sudo 密码。")
elif index == 1: # 超时,未出现密码提示 (可能是 NOPASSWD 或已缓存)
logger.info("Sudo 未提示输入密码(可能已配置 NOPASSWD 或密码已缓存)。")
stdout_buffer.append(child.before) # 捕获任何初始输出
except pexpect.exceptions.EOF:
stdout_buffer.append(child.before)
logger.error("命令在 sudo 密码提示前退出。")
child.close()
return False, "".join(stdout_buffer), "命令在 sudo 密码提示前退出。"
except pexpect.exceptions.TIMEOUT:
# 再次捕获超时,确保日志记录
logger.info("Sudo 密码提示超时,可能已配置 NOPASSWD 或密码已缓存。")
stdout_buffer.append(child.before)
# 定义交互式提示及其处理方式
prompts_data = [ # 重命名为 prompts_data 以避免与循环变量混淆
(r"To optimalize recovery speed, .* write-(?:intent|indent) bitmap, do you want to enable it now\? \[y/N\]\s*",
"mdadm 建议启用写入意图位图以优化恢复速度。您希望现在启用它吗?"),
(r"Continue creating array \[y/N\]\s*",
"mdadm 警告:检测到驱动器大小不一致或分区表。创建阵列将覆盖数据。是否仍要继续创建阵列?")
]
# 提取所有模式,以便 pexpect.expect 可以同时监听它们
patterns_to_expect = [p for p, _ in prompts_data]
# 循环处理所有可能的提示,直到没有更多提示或命令结束
while True:
try:
# 尝试匹配任何一个预定义的提示
# pexpect 会等待直到匹配到其中一个模式,或者超时,或者遇到 EOF
index = child.expect(patterns_to_expect, timeout=10) # 每次等待10秒
stdout_buffer.append(child.before) # 捕获到提示前的输出
# 获取匹配到的提示的描述
matched_description = prompts_data[index][1]
# 弹出对话框询问用户
reply = QMessageBox.question(None, "mdadm 提示", matched_description,
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
if reply == QMessageBox.Yes:
user_choice = 'y'
logger.info(f"用户对提示 '{matched_description}' 选择 ''")
else:
user_choice = 'n'
logger.info(f"用户对提示 '{matched_description}' 选择 ''")
child.sendline(user_choice)
stdout_buffer.append(user_choice + '\n') # 记录用户输入
# 等待一小段时间,确保 mdadm 接收并处理了输入,并清空缓冲区
# 避免在下一次 expect 之前,旧的输出再次被匹配
child.expect(pexpect.TIMEOUT, timeout=1)
except pexpect.exceptions.TIMEOUT:
# 如果在等待任何提示时超时,说明没有更多提示了,或者命令已完成。
logger.debug("在等待 mdadm 提示时超时,可能没有更多交互。")
stdout_buffer.append(child.before) # 捕获当前为止的输出
break # 跳出 while 循环,进入等待 EOF 阶段
except pexpect.exceptions.EOF:
# 如果在等待提示时遇到 EOF说明命令已经执行完毕或失败
logger.warning("在等待 mdadm 提示时遇到 EOF。")
stdout_buffer.append(child.before)
break # 跳出 while 循环,进入等待 EOF 阶段
try:
# 增加一个合理的超时时间,以防 mdadm 在后台进行长时间初始化
child.expect(pexpect.EOF, timeout=120) # 例如,等待 120 秒
stdout_buffer.append(child.before) # 捕获命令结束前的任何剩余输出
logger.info("mdadm 命令已正常结束 (通过 EOF 捕获)。")
except pexpect.exceptions.TIMEOUT:
stdout_buffer.append(child.before)
logger.error(f"mdadm 命令在所有交互完成后,等待 EOF 超时。")
QMessageBox.critical(None, "错误", f"{error_msg_prefix}\n详细信息: mdadm 命令在交互完成后未正常退出,等待 EOF 超时。")
child.close()
return False, "".join(stdout_buffer), "mdadm 命令等待 EOF 超时。"
except pexpect.exceptions.EOF:
stdout_buffer.append(child.before)
logger.info("mdadm 命令已正常结束 (通过 EOF 捕获)。")
child.close()
final_output = "".join(stdout_buffer)
if child.exitstatus == 0:
logger.info(f"交互式命令 {full_cmd_str} 成功完成。")
return True, final_output, "" # pexpect 很难区分 stdout/stderr都视为 stdout
else:
logger.error(f"交互式命令 {full_cmd_str} 失败,退出码: {child.exitstatus}")
QMessageBox.critical(None, "错误", f"{error_msg_prefix}\n详细信息: 命令执行失败,退出码 {child.exitstatus}\n输出: {final_output}")
return False, final_output, f"命令执行失败,退出码 {child.exitstatus}"
except pexpect.exceptions.ExceptionPexpect as e:
logger.error(f"pexpect 内部错误: {e}")
QMessageBox.critical(None, "错误", f"{error_msg_prefix}\n详细信息: pexpect 内部错误: {e}")
return False, "", str(e)
except Exception as e:
logger.error(f"执行交互式命令 {full_cmd_str} 时发生未知错误: {e}")
QMessageBox.critical(None, "错误", f"{error_msg_prefix}\n未知错误: {e}")
return False, "", str(e)
def _get_next_available_md_device_name(self):
"""
查找下一个可用的 /dev/mdX 设备名称(例如 /dev/md0, /dev/md1, ...)。
@@ -70,7 +226,6 @@ class RaidOperations:
for array in raid_arrays:
device_path = array.get('device')
if device_path and device_path.startswith('/dev/md'):
# 匹配 /dev/mdX 形式的设备名
match = re.match(r'/dev/md(\d+)', device_path)
if match:
existing_md_numbers.add(int(match.group(1)))
@@ -94,7 +249,7 @@ class RaidOperations:
if isinstance(level, int):
level_str = f"raid{level}"
elif isinstance(level, str):
if level in ["0", "1", "5", "6", "10"]: # 增加 "6", "10" 确保全面
if level in ["0", "1", "5", "6", "10"]:
level_str = f"raid{level}"
else:
level_str = level
@@ -104,22 +259,21 @@ class RaidOperations:
level = level_str
# --- 标准化 RAID 级别输入结束 ---
if not devices or len(devices) < 2:
QMessageBox.critical(None, "错误", "创建 RAID 阵列至少需要两个成员设备")
if not devices or len(devices) < 1: # RAID0 理论上可以一个设备,但通常至少两个
QMessageBox.critical(None, "错误", "创建 RAID 阵列至少需要一个设备RAID0")
return False
# 检查 RAID 5 至少需要 3 个设备
if level == "raid5" and len(devices) < 3:
QMessageBox.critical(None, "错误", "RAID5 至少需要三个设备。")
return False
# 补充其他 RAID 级别设备数量检查,与 dialogs.py 保持一致
# 检查其他 RAID 级别所需的设备数量
if level == "raid1" and len(devices) < 2:
QMessageBox.critical(None, "错误", "RAID1 至少需要两个设备。")
return False
if level == "raid5" and len(devices) < 3:
QMessageBox.critical(None, "错误", "RAID5 至少需要三个设备。")
return False
if level == "raid6" and len(devices) < 4:
QMessageBox.critical(None, "错误", "RAID6 至少需要四个设备。")
return False
if level == "raid10" and len(devices) < 2:
if level == "raid10" and len(devices) < 2: # RAID10 至少需要 2 个设备 (例如 1+0)
QMessageBox.critical(None, "错误", "RAID10 至少需要两个设备。")
return False
@@ -137,10 +291,7 @@ class RaidOperations:
# 1. 清除设备上的旧 RAID 超级块(如果有)
for dev in devices:
# 使用 --force 选项,避免交互式提示
clear_cmd = ["mdadm", "--zero-superblock", "--force", dev]
# 定义表示设备上没有超级块的错误信息,根据日志调整
no_superblock_error_match = "Unrecognised md component device"
success, _, stderr = self._execute_shell_command(
@@ -149,7 +300,6 @@ class RaidOperations:
suppress_critical_dialog_on_stderr_match=no_superblock_error_match
)
# 检查 stderr 是否包含“未识别的 MD 组件设备”信息
if no_superblock_error_match in stderr:
logger.info(f"设备 {dev} 上未找到旧 RAID 超级块,已确保其干净。")
elif success:
@@ -159,41 +309,30 @@ class RaidOperations:
return False
# 2. 创建 RAID 阵列
# --- 修改点:自动生成唯一的阵列名称 ---
array_name = self._get_next_available_md_device_name()
logger.info(f"将使用自动生成的 RAID 阵列名称: {array_name}")
# --- 修改点结束 ---
if level == "raid0":
create_cmd = ["mdadm", "--create", array_name, "--level=raid0",
f"--raid-devices={len(devices)}", f"--chunk={chunk_size}K"] + devices
elif level == "raid1":
create_cmd = ["mdadm", "--create", array_name, "--level=raid1",
f"--raid-devices={len(devices)}"] + devices
elif level == "raid5":
create_cmd = ["mdadm", "--create", array_name, "--level=raid5",
f"--raid-devices={len(devices)}"] + devices
elif level == "raid6": # 增加 RAID6
create_cmd = ["mdadm", "--create", array_name, "--level=raid6",
f"--raid-devices={len(devices)}", f"--chunk={chunk_size}K"] + devices
elif level == "raid10": # 增加 RAID10
create_cmd = ["mdadm", "--create", array_name, "--level=raid10",
f"--raid-devices={len(devices)}", f"--chunk={chunk_size}K"] + devices
else:
QMessageBox.critical(None, "错误", f"不支持的 RAID 级别: {level}")
return False
create_cmd_base = ["mdadm", "--create", array_name, "--level=" + level.replace("raid", ""),
f"--raid-devices={len(devices)}"]
# 添加 chunk size (RAID0, RAID5, RAID6, RAID10 通常需要)
if level in ["raid0", "raid5", "raid6", "raid10"]:
create_cmd_base.append(f"--chunk={chunk_size}K")
create_cmd = create_cmd_base + devices
# 在 --create 命令中添加 --force 选项
create_cmd.insert(2, "--force")
success_create, stdout_create, stderr_create = self._execute_shell_command(
# 调用新的交互式方法
success_create, stdout_create, stderr_create = self._execute_interactive_mdadm_command(
create_cmd,
f"创建 RAID 阵列失败",
input_to_command='y\n' # 尝试通过 stdin 传递 'y'
f"创建 RAID 阵列失败"
)
if not success_create:
# 检查是否是由于 "Array name ... is in use already." 导致的失败
if "Array name" in stderr_create and "is in use already" in stderr_create:
# pexpect 捕获的输出可能都在 stdout_create 中
if "Array name" in stdout_create and "is in use already" in stdout_create:
QMessageBox.critical(None, "错误", f"创建 RAID 阵列失败:阵列名称 {array_name} 已被占用。请尝试停止或删除现有阵列。")
return False
@@ -204,9 +343,29 @@ class RaidOperations:
examine_scan_cmd = ["mdadm", "--examine", "--scan"]
success_scan, scan_stdout, _ = self._execute_shell_command(examine_scan_cmd, "扫描 mdadm 配置失败")
if success_scan:
append_to_conf_cmd = ["bash", "-c", f"echo '{scan_stdout.strip()}' >> /etc/mdadm/mdadm.conf"]
if not self._execute_shell_command(append_to_conf_cmd, "更新 /etc/mdadm/mdadm.conf 失败")[0]:
# --- 新增:确保 /etc/mdadm 目录存在 ---
mkdir_cmd = ["mkdir", "-p", "/etc/mdadm"]
success_mkdir, _, stderr_mkdir = self._execute_shell_command(
mkdir_cmd,
"创建 /etc/mdadm 目录失败"
)
if not success_mkdir:
logger.error(f"无法创建 /etc/mdadm 目录,更新 mdadm.conf 失败。错误: {stderr_mkdir}")
QMessageBox.critical(None, "错误", f"无法创建 /etc/mdadm 目录,更新 mdadm.conf 失败。\n详细信息: {stderr_mkdir}")
return False # 如果连目录都无法创建,则认为创建阵列失败,因为无法保证重启后自动识别
logger.info("已确保 /etc/mdadm 目录存在。")
# --- 新增结束 ---
# 这里需要确保 /etc/mdadm/mdadm.conf 存在且可写入
# _execute_shell_command 会自动添加 sudo
success_append, _, _ = self._execute_shell_command(
["bash", "-c", f"echo '{scan_stdout.strip()}' | tee -a /etc/mdadm/mdadm.conf > /dev/null"],
"更新 /etc/mdadm/mdadm.conf 失败"
)
if not success_append:
logger.warning("更新 /etc/mdadm/mdadm.conf 失败。")
else:
logger.info("已成功更新 /etc/mdadm/mdadm.conf。")
else:
logger.warning("未能扫描到 mdadm 配置,跳过更新 mdadm.conf。")
@@ -227,15 +386,14 @@ class RaidOperations:
return False
logger.info(f"尝试停止 RAID 阵列: {array_path}")
# 尝试卸载阵列(如果已挂载),不显示错误对话框
# 注意:这里需要调用 disk_operations 的 unmount_partition
# 由于 RaidOperations 不直接持有 DiskOperations 实例,需要通过某种方式获取或传递
# 暂时先直接调用 umount 命令,不处理 fstab
# 或者,更好的方式是让 MainWindow 调用 disk_ops.unmount_partition
# 此处简化处理,只执行 umount 命令
# 这里的 suppress_critical_dialog_on_stderr_match 应该与 DiskOperations 中的定义保持一致
self._execute_shell_command(["umount", array_path], f"尝试卸载 {array_path} 失败",
suppress_critical_dialog_on_stderr_match="not mounted") # 仅抑制英文,因为这里没有访问 DiskOperations 的 already_unmounted_errors
# _execute_shell_command 会自动添加 sudo
self._execute_shell_command(
["umount", array_path],
f"尝试卸载 {array_path} 失败",
suppress_critical_dialog_on_stderr_match=("not mounted", "未挂载") # 修改这里,添加中文错误信息
)
if not self._execute_shell_command(["mdadm", "--stop", array_path], f"停止 RAID 阵列 {array_path} 失败")[0]:
return False
@@ -269,10 +427,7 @@ class RaidOperations:
# 2. 清除成员设备上的超级块
success_all_cleared = True
for dev in member_devices:
# 使用 --force 选项,避免交互式提示
clear_cmd = ["mdadm", "--zero-superblock", "--force", dev]
# 定义表示设备上没有超级块的错误信息,根据日志调整
no_superblock_error_match = "Unrecognised md component device"
success, _, stderr = self._execute_shell_command(
@@ -287,7 +442,6 @@ class RaidOperations:
logger.info(f"已清除设备 {dev} 上的旧 RAID 超级块。")
else:
success_all_cleared = False
# 错误对话框已由 _execute_shell_command 弹出,这里只记录警告
logger.warning(f"未能清除设备 {dev} 上的 RAID 超级块,请手动检查。")
if success_all_cleared: