# 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 # NEW: 导入 DiskOperations from disk_operations import DiskOperations logger = logging.getLogger(__name__) class RaidOperations: def __init__(self, system_manager: SystemInfoManager, disk_ops: DiskOperations): # <--- Add system_manager and disk_ops parameters self.system_manager = system_manager self.disk_ops = disk_ops # Store disk_ops instance 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) # This line might consume output needed for next prompt 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 # NEW: 尝试解决所有成员设备的占用问题 for dev in devices: if not self.disk_ops._resolve_device_occupation(dev, action_description=f"创建 RAID 阵列,成员 {dev}"): logger.info(f"用户取消或未能解决设备 {dev} 的占用问题,取消创建 RAID 阵列。") 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 # 使用 tee -a 而不是 >> 来确保 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}") # NEW: 尝试卸载阵列(如果已挂载),利用 DiskOperations 的增强卸载功能 # unmount_partition 内部会尝试解决设备占用问题 if not self.disk_ops.unmount_partition(array_path, show_dialog_on_error=False): logger.warning(f"未能成功卸载 RAID 阵列 {array_path}。尝试继续停止操作。") # 即使卸载失败,我们仍然尝试停止 mdadm 阵列,因为有时 mdadm --stop 可以在设备忙时强制停止。 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. 停止阵列 # stop_raid_array 内部会调用 self.disk_ops.unmount_partition 来尝试卸载并解决占用 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 --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 --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