Files
diskmanager/raid_operations.py
2026-02-03 15:18:43 +08:00

519 lines
27 KiB
Python
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.

# raid_operations.py
import logging
import subprocess
from PySide6.QtWidgets import QMessageBox, QInputDialog, QLineEdit # 导入 QInputDialog 和 QLineEdit
from PySide6.QtCore import Qt
from system_info import SystemInfoManager
import re
import pexpect # <--- 新增导入 pexpect
import sys
logger = logging.getLogger(__name__)
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):
"""
执行一个shell命令并返回stdout和stderr。
这个方法包装了 SystemInfoManager._run_command并统一处理日志和错误消息框。
它会通过 SystemInfoManager 自动处理 sudo。
:param command_list: 命令及其参数的列表。
:param error_msg_prefix: 命令失败时显示给用户的错误消息前缀。
:param suppress_critical_dialog_on_stderr_match: 一个字符串或字符串元组。
如果在 stderr 中找到此字符串(或元组中的任一字符串),
则在 CalledProcessError 发生时,只记录日志,不弹出 QMessagebox.critical。
:param input_to_command: 传递给命令stdin的数据 (str)。
:return: (bool success, str stdout, str stderr)
"""
full_cmd_str = ' '.join(command_list)
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, # 假设所有 RAID 操作都需要 root 权限
check_output=True,
input_data=input_to_command
)
if stdout: logger.debug(f"命令 '{full_cmd_str}' 标准输出:\n{stdout.strip()}")
if stderr: logger.debug(f"命令 '{full_cmd_str}' 标准错误:\n{stderr.strip()}")
return True, stdout, stderr
except subprocess.CalledProcessError as e:
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}")
# --- 修改开始 ---
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
except FileNotFoundError:
logger.error(f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。")
QMessageBox.critical(None, "错误", f"命令 '{command_list[0]}' 未找到。请确保已安装相关工具。")
return False, "", "Command not found."
except Exception as e:
logger.error(f"{error_msg_prefix} 命令: {full_cmd_str} 发生未知错误: {e}")
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, ...)。
"""
existing_md_numbers = set()
try:
raid_arrays = self.system_manager.get_mdadm_arrays()
for array in raid_arrays:
device_path = array.get('device')
if device_path and device_path.startswith('/dev/md'):
match = re.match(r'/dev/md(\d+)', device_path)
if match:
existing_md_numbers.add(int(match.group(1)))
except Exception as e:
logger.warning(f"获取现有 RAID 阵列信息失败,可能无法找到最优的下一个设备名: {e}")
next_md_num = 0
while next_md_num in existing_md_numbers:
next_md_num += 1
return f"/dev/md{next_md_num}"
def create_raid_array(self, devices, level, chunk_size):
"""
创建 RAID 阵列。
:param devices: 成员设备列表,例如 ['/dev/sdb1', '/dev/sdc1']
:param level: RAID 级别,例如 'raid0', 'raid1', 'raid5' (也可以是整数 0, 1, 5)
:param chunk_size: Chunk 大小 (KB)
"""
# --- 标准化 RAID 级别输入 ---
if isinstance(level, int):
level_str = f"raid{level}"
elif isinstance(level, str):
if level in ["0", "1", "5", "6", "10"]:
level_str = f"raid{level}"
else:
level_str = level
else:
QMessageBox.critical(None, "错误", f"不支持的 RAID 级别类型: {type(level)}")
return False
level = level_str
# --- 标准化 RAID 级别输入结束 ---
if not devices or len(devices) < 1: # RAID0 理论上可以一个设备,但通常至少两个
QMessageBox.critical(None, "错误", "创建 RAID 阵列至少需要一个设备RAID0")
return False
# 检查其他 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: # RAID10 至少需要 2 个设备 (例如 1+0)
QMessageBox.critical(None, "错误", "RAID10 至少需要两个设备。")
return False
# 确认操作
reply = QMessageBox.question(None, "确认创建 RAID 阵列",
f"你确定要使用设备 {', '.join(devices)} 创建 RAID {level} 阵列吗?\n"
"此操作将销毁设备上的所有数据!",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.No:
logger.info("用户取消了创建 RAID 阵列的操作。")
return False
logger.info(f"尝试创建 RAID {level} 阵列,成员设备: {', '.join(devices)}, Chunk 大小: {chunk_size}KB。")
# 1. 清除设备上的旧 RAID 超级块(如果有)
for dev in devices:
clear_cmd = ["mdadm", "--zero-superblock", "--force", dev]
no_superblock_error_match = "Unrecognised md component device"
success, _, stderr = self._execute_shell_command(
clear_cmd,
f"清除设备 {dev} 上的旧 RAID 超级块失败",
suppress_critical_dialog_on_stderr_match=no_superblock_error_match
)
if no_superblock_error_match in stderr:
logger.info(f"设备 {dev} 上未找到旧 RAID 超级块,已确保其干净。")
elif success:
logger.info(f"已清除设备 {dev} 上的旧 RAID 超级块。")
else:
logger.error(f"清除设备 {dev} 上的旧 RAID 超级块失败,中断 RAID 创建。")
return False
# 2. 创建 RAID 阵列
array_name = self._get_next_available_md_device_name()
logger.info(f"将使用自动生成的 RAID 阵列名称: {array_name}")
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_interactive_mdadm_command(
create_cmd,
f"创建 RAID 阵列失败"
)
if not success_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
logger.info(f"成功创建 RAID {level} 阵列 {array_name}")
QMessageBox.information(None, "成功", f"成功创建 RAID {level} 阵列 {array_name}")
# 3. 刷新 mdadm 配置并等待阵列激活
examine_scan_cmd = ["mdadm", "--examine", "--scan"]
success_scan, scan_stdout, _ = self._execute_shell_command(examine_scan_cmd, "扫描 mdadm 配置失败")
if success_scan:
# --- 新增:确保 /etc 目录存在 ---
mkdir_cmd = ["mkdir", "-p", "/etc"]
success_mkdir, _, stderr_mkdir = self._execute_shell_command(
mkdir_cmd,
"创建 /etc 目录失败"
)
if not success_mkdir:
logger.error(f"无法创建 /etc 目录,更新 mdadm.conf 失败。错误: {stderr_mkdir}")
QMessageBox.critical(None, "错误", f"无法创建 /etc 目录,更新 mdadm.conf 失败。\n详细信息: {stderr_mkdir}")
return False # 如果连目录都无法创建,则认为创建阵列失败,因为无法保证重启后自动识别
logger.info("已确保 /etc 目录存在。")
# --- 新增结束 ---
# 这里需要确保 /etc/mdadm.conf 存在且可写入
# _execute_shell_command 会自动添加 sudo
success_append, _, _ = self._execute_shell_command(
["bash", "-c", f"echo '{scan_stdout.strip()}' | tee -a /etc/mdadm.conf > /dev/null"],
"更新 /etc/mdadm/mdadm.conf 失败"
)
if not success_append:
logger.warning("更新 /etc/mdadm.conf 失败。")
else:
logger.info("已成功更新 /etc/mdadm.conf。")
else:
logger.warning("未能扫描到 mdadm 配置,跳过更新 mdadm.conf。")
return True
def stop_raid_array(self, array_path):
"""
停止一个 RAID 阵列。
:param array_path: RAID 阵列的设备路径,例如 /dev/md0
"""
reply = QMessageBox.question(None, "确认停止 RAID 阵列",
f"你确定要停止 RAID 阵列 {array_path} 吗?\n"
"停止阵列将使其无法访问,并可能需要重新组装。",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.No:
logger.info(f"用户取消了停止 RAID 阵列 {array_path} 的操作。")
return False
logger.info(f"尝试停止 RAID 阵列: {array_path}")
# 尝试卸载阵列(如果已挂载),不显示错误对话框
# _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
logger.info(f"成功停止 RAID 阵列 {array_path}")
QMessageBox.information(None, "成功", f"成功停止 RAID 阵列 {array_path}")
return True
def delete_active_raid_array(self, array_path, member_devices, uuid): # <--- 修改方法名并添加 uuid 参数
"""
删除一个活动的 RAID 阵列。
此操作将停止阵列、清除成员设备上的超级块,并删除 mdadm.conf 中的配置。
:param array_path: RAID 阵列的设备路径,例如 /dev/md0
:param member_devices: 成员设备列表,例如 ['/dev/sdb1', '/dev/sdc1']
:param uuid: RAID 阵列的 UUID
"""
reply = QMessageBox.question(None, "确认删除 RAID 阵列",
f"你确定要删除 RAID 阵列 {array_path} 吗?\n"
"此操作将停止阵列,清除成员设备上的 RAID 超级块,并删除其配置,数据将无法访问!",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.No:
logger.info(f"用户取消了删除 RAID 阵列 {array_path} 的操作。")
return False
logger.info(f"尝试删除活动 RAID 阵列: {array_path} (UUID: {uuid})")
# 1. 停止阵列
if not self.stop_raid_array(array_path):
QMessageBox.critical(None, "错误", f"删除 RAID 阵列 {array_path} 失败,因为无法停止阵列。")
return False
# 2. 清除成员设备上的超级块
success_all_cleared = True
for dev in member_devices:
clear_cmd = ["mdadm", "--zero-superblock", "--force", dev]
no_superblock_error_match = "Unrecognised md component device"
success, _, stderr = self._execute_shell_command(
clear_cmd,
f"清除设备 {dev} 上的 RAID 超级块失败",
suppress_critical_dialog_on_stderr_match=no_superblock_error_match
)
if no_superblock_error_match in stderr:
logger.info(f"设备 {dev} 上未找到旧 RAID 超级块,已确保其干净。")
elif success:
logger.info(f"已清除设备 {dev} 上的旧 RAID 超级块。")
else:
success_all_cleared = False
logger.warning(f"未能清除设备 {dev} 上的 RAID 超级块,请手动检查。")
# 3. 从 mdadm.conf 中删除配置条目
try:
self.system_manager.delete_raid_array_config(uuid)
logger.info(f"已成功从 mdadm.conf 中删除 UUID 为 {uuid} 的 RAID 阵列配置。")
except Exception as e:
QMessageBox.critical(None, "错误", f"删除 RAID 阵列 {array_path} 的配置失败: {e}")
logger.error(f"删除 RAID 阵列 {array_path} 的配置失败: {e}")
return False # 如果配置文件删除失败,也视为整体操作失败
if success_all_cleared:
logger.info(f"成功删除 RAID 阵列 {array_path} 并清除了成员设备超级块。")
QMessageBox.information(None, "成功", f"成功删除 RAID 阵列 {array_path}")
return True
else:
QMessageBox.critical(None, "错误", f"删除 RAID 阵列 {array_path} 完成,但部分成员设备超级块未能完全清除。")
return False
def delete_configured_raid_array(self, uuid): # <--- 新增方法
"""
只删除 mdadm.conf 中停止状态 RAID 阵列的配置条目。
:param uuid: 要删除的 RAID 阵列的 UUID。
:return: True 如果成功False 如果失败。
"""
reply = QMessageBox.question(None, "确认删除 RAID 阵列配置",
f"您确定要删除 UUID 为 {uuid} 的 RAID 阵列配置吗?\n"
"此操作将从配置文件中移除该条目,但不会影响实际的磁盘数据或活动阵列。",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.No:
logger.info(f"用户取消了删除 UUID 为 {uuid} 的 RAID 阵列配置的操作。")
return False
logger.info(f"尝试删除 UUID 为 {uuid} 的 RAID 阵列配置文件条目。")
try:
self.system_manager.delete_raid_array_config(uuid)
QMessageBox.information(None, "成功", f"成功删除 UUID 为 {uuid} 的 RAID 阵列配置。")
return True
except Exception as e:
QMessageBox.critical(None, "错误", f"删除 RAID 阵列配置失败: {e}")
logger.error(f"删除 RAID 阵列配置失败: {e}")
return False
def activate_raid_array(self, array_path, array_uuid): # 添加 array_uuid 参数
"""
激活一个已停止的 RAID 阵列。
使用 mdadm --assemble <device> --uuid=<uuid>
"""
if not array_uuid or array_uuid == 'N/A':
QMessageBox.critical(None, "错误", f"无法激活 RAID 阵列 {array_path}:缺少 UUID 信息。")
logger.error(f"激活 RAID 阵列 {array_path} 失败:缺少 UUID。")
return False
logger.info(f"尝试激活 RAID 阵列: {array_path} (UUID: {array_uuid})")
# 使用 mdadm --assemble <device> --uuid=<uuid>
# 这样可以确保只激活用户选择的特定阵列
success, stdout, stderr = self._execute_shell_command(
["mdadm", "--assemble", array_path, "--uuid", array_uuid],
f"激活 RAID 阵列 {array_path} 失败",
# 抑制一些常见的非致命错误,例如阵列已经激活
suppress_critical_dialog_on_stderr_match=("mdadm: device /dev/md", "already active", "No such file or directory")
)
if success:
logger.info(f"成功激活 RAID 阵列 {array_path} (UUID: {array_uuid})。")
QMessageBox.information(None, "成功", f"成功激活 RAID 阵列 {array_path}")
return True
else:
logger.error(f"激活 RAID 阵列 {array_path} 失败。")
return False