#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 系统命令兼容性模块 处理不同 Linux 发行版之间系统命令的差异 支持 Arch Linux 和 CentOS/RHEL 8 """ import subprocess import json import logging import re import os from platform_compat import get_platform_compat logger = logging.getLogger(__name__) class SystemCommandCompat: """系统命令兼容性处理类""" def __init__(self): self._lsblk_version = None self._lvm_version = None self._parted_version = None self._compat_cache = {} def _get_lsblk_version(self): """获取 lsblk 版本""" if self._lsblk_version is None: try: # Python 3.6 兼容 result = subprocess.run( ['lsblk', '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE ) output = result.stdout.decode('utf-8', errors='ignore') + result.stderr.decode('utf-8', errors='ignore') # 解析版本号,例如 "lsblk from util-linux 2.37.4" match = re.search(r'(\d+)\.(\d+)(?:\.(\d+))?', output) if match: self._lsblk_version = tuple(int(x) if x else 0 for x in match.groups()) logger.debug(f"检测到 lsblk 版本: {self._lsblk_version}") else: self._lsblk_version = (0, 0, 0) except Exception as e: logger.warning(f"无法检测 lsblk 版本: {e}") self._lsblk_version = (0, 0, 0) return self._lsblk_version def _get_lvm_version(self): """获取 LVM 版本""" if self._lvm_version is None: try: # Python 3.6 兼容 result = subprocess.run( ['lvm', 'version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE ) output = result.stdout.decode('utf-8', errors='ignore') + result.stderr.decode('utf-8', errors='ignore') # 解析版本号,例如 "LVM version: 2.03.14" match = re.search(r'(\d+)\.(\d+)\.(\d+)', output) if match: self._lvm_version = tuple(int(x) for x in match.groups()) logger.debug(f"检测到 LVM 版本: {self._lvm_version}") else: self._lvm_version = (0, 0, 0) except Exception as e: logger.warning(f"无法检测 LVM 版本: {e}") self._lvm_version = (0, 0, 0) return self._lvm_version def supports_lsblk_json(self): """检查 lsblk 是否支持 JSON 输出 (util-linux 2.23+)""" version = self._get_lsblk_version() # util-linux 2.23 引入 JSON 支持 return version >= (2, 23) def supports_lvm_json(self): """检查 LVM 是否支持 JSON 报告格式 (2.02.166+)""" version = self._get_lvm_version() # LVM 2.02.166 引入 JSON 报告格式 return version >= (2, 2, 166) or version >= (2, 3, 0) def get_lsblk_command(self): """ 获取适用于当前系统的 lsblk 命令 返回: (命令列表, 是否需要手动解析) """ # 注意: PATH 列在较新的 lsblk 中可用,但在 CentOS 8 等旧系统中不可用 # 使用 KNAME 代替,它是内部内核设备名称,总是存在 if self.supports_lsblk_json(): # 现代系统支持 JSON return [ "lsblk", "-J", "-o", "NAME,KNAME,FSTYPE,SIZE,MOUNTPOINT,RO,TYPE,UUID,PARTUUID,VENDOR,MODEL,SERIAL,MAJ:MIN,PKNAME" ], False else: # 旧系统回退到普通输出 logger.warning("lsblk 不支持 JSON 输出,使用回退方案") return [ "lsblk", "-o", "NAME,KNAME,FSTYPE,SIZE,MOUNTPOINT,RO,TYPE,UUID,PARTUUID,VENDOR,MODEL,SERIAL,MAJ:MIN,PKNAME", "-n", "-p" # -n 不打印标题,-p 显示完整路径 ], True def parse_lsblk_output(self, stdout, use_manual_parse=False): """ 解析 lsblk 输出 :param stdout: 命令输出 :param use_manual_parse: 是否需要手动解析(非 JSON) :return: 设备列表 """ if not use_manual_parse: try: data = json.loads(stdout) devices = data.get('blockdevices', []) # 统一处理 PATH 字段 # 注意: 使用 kname (内核设备名) 来生成 path,因为 PATH 列在某些系统中不可用 def normalize_device(dev): # 优先使用 kname (内核设备名),因为它总是存在且是规范的 if 'kname' in dev: dev['path'] = f"/dev/{dev['kname']}" elif 'path' not in dev and 'name' in dev: dev['path'] = f"/dev/{dev['name']}" # 清理旧字段 if 'PATH' in dev: dev.pop('PATH') if 'KNAME' in dev: dev['kname'] = dev.pop('KNAME') if 'children' in dev: for child in dev['children']: normalize_device(child) return dev for dev in devices: normalize_device(dev) return devices except json.JSONDecodeError as e: logger.error(f"JSON 解析失败,尝试手动解析: {e}") use_manual_parse = True if use_manual_parse: return self._manual_parse_lsblk(stdout) return [] def _manual_parse_lsblk(self, stdout): """ 手动解析 lsblk 的普通输出 用于不支持 JSON 的旧系统 """ devices = [] device_stack = [] # 用于处理层次结构 lines = stdout.strip().split('\n') for line in lines: if not line.strip(): continue # 解析缩进级别(每两个空格或一个制表符表示一个层级) indent_level = 0 stripped_line = line while stripped_line.startswith(' ') or stripped_line.startswith('├') or stripped_line.startswith('└') or stripped_line.startswith('│'): if stripped_line[0] in '├└│': indent_level += 1 stripped_line = stripped_line[1:] if stripped_line.startswith('─'): stripped_line = stripped_line[1:] if stripped_line.startswith(' '): stripped_line = stripped_line[1:] # 分割字段 parts = stripped_line.split() if len(parts) < 6: continue device = { 'name': parts[0], 'path': f"/dev/{parts[0]}" if not parts[0].startswith('/') else parts[0], 'fstype': parts[1] if parts[1] != '' else None, 'size': parts[2], 'mountpoint': parts[3] if parts[3] != '' else None, 'ro': parts[4] == '1', 'type': parts[5], } # 添加可选字段 if len(parts) > 6: device['uuid'] = parts[6] if parts[6] != '' else None if len(parts) > 7: device['partuuid'] = parts[7] if parts[7] != '' else None # 处理层次结构 if indent_level == 0: devices.append(device) device_stack = [device] else: # 找到正确的父设备 while len(device_stack) > indent_level: device_stack.pop() if device_stack: parent = device_stack[-1] if 'children' not in parent: parent['children'] = [] parent['children'].append(device) device_stack.append(device) else: devices.append(device) device_stack = [device] return devices def get_lvm_commands(self): """ 获取适用于当前系统的 LVM 命令 返回: { 'pvs': (命令列表, 是否需要手动解析), 'vgs': (命令列表, 是否需要手动解析), 'lvs': (命令列表, 是否需要手动解析) } """ if self.supports_lvm_json(): return { 'pvs': (['pvs', '--reportformat', 'json'], False), 'vgs': (['vgs', '--reportformat', 'json'], False), 'lvs': (['lvs', '-o', 'lv_name,vg_name,lv_uuid,lv_size,lv_attr,origin,snap_percent,lv_path', '--reportformat', 'json'], False), } else: logger.warning("LVM 不支持 JSON 输出,使用回退方案") return { 'pvs': (['pvs', '--noheadings', '--separator', '|', '-o', 'pv_name,vg_name,pv_uuid,pv_size,pv_free,pv_attr,pv_fmt'], True), 'vgs': (['vgs', '--noheadings', '--separator', '|', '-o', 'vg_name,vg_uuid,vg_size,vg_free,vg_attr,pv_count,lv_count,vg_alloc_percent,vg_fmt'], True), 'lvs': (['lvs', '--noheadings', '--separator', '|', '-o', 'lv_name,vg_name,lv_uuid,lv_size,lv_attr,origin,snap_percent,lv_path'], True), } def parse_pvs_output(self, stdout, use_manual_parse=False): """解析 pvs 输出""" if not use_manual_parse: try: data = json.loads(stdout) if 'report' in data and data['report']: return [{ 'pv_name': pv.get('pv_name'), 'vg_name': pv.get('vg_name'), 'pv_uuid': pv.get('pv_uuid'), 'pv_size': pv.get('pv_size'), 'pv_free': pv.get('pv_free'), 'pv_attr': pv.get('pv_attr'), 'pv_fmt': pv.get('pv_fmt') } for pv in data['report'][0].get('pv', [])] return [] except json.JSONDecodeError: use_manual_parse = True if use_manual_parse: pvs = [] for line in stdout.strip().split('\n'): parts = line.strip().split('|') if len(parts) >= 7: pvs.append({ 'pv_name': parts[0].strip(), 'vg_name': parts[1].strip() if parts[1].strip() else None, 'pv_uuid': parts[2].strip() if len(parts) > 2 else None, 'pv_size': parts[3].strip() if len(parts) > 3 else None, 'pv_free': parts[4].strip() if len(parts) > 4 else None, 'pv_attr': parts[5].strip() if len(parts) > 5 else None, 'pv_fmt': parts[6].strip() if len(parts) > 6 else None, }) return pvs return [] def parse_vgs_output(self, stdout, use_manual_parse=False): """解析 vgs 输出""" if not use_manual_parse: try: data = json.loads(stdout) if 'report' in data and data['report']: return [{ 'vg_name': vg.get('vg_name'), 'vg_uuid': vg.get('vg_uuid'), 'vg_size': vg.get('vg_size'), 'vg_free': vg.get('vg_free'), 'vg_attr': vg.get('vg_attr'), 'pv_count': vg.get('pv_count'), 'lv_count': vg.get('lv_count'), 'vg_alloc_percent': vg.get('vg_alloc_percent'), 'vg_fmt': vg.get('vg_fmt') } for vg in data['report'][0].get('vg', [])] return [] except json.JSONDecodeError: use_manual_parse = True if use_manual_parse: vgs = [] for line in stdout.strip().split('\n'): parts = line.strip().split('|') if len(parts) >= 9: vgs.append({ 'vg_name': parts[0].strip(), 'vg_uuid': parts[1].strip() if len(parts) > 1 else None, 'vg_size': parts[2].strip() if len(parts) > 2 else None, 'vg_free': parts[3].strip() if len(parts) > 3 else None, 'vg_attr': parts[4].strip() if len(parts) > 4 else None, 'pv_count': parts[5].strip() if len(parts) > 5 else None, 'lv_count': parts[6].strip() if len(parts) > 6 else None, 'vg_alloc_percent': parts[7].strip() if len(parts) > 7 else None, 'vg_fmt': parts[8].strip() if len(parts) > 8 else None, }) return vgs return [] def parse_lvs_output(self, stdout, use_manual_parse=False): """解析 lvs 输出""" if not use_manual_parse: try: data = json.loads(stdout) if 'report' in data and data['report']: return [{ 'lv_name': lv.get('lv_name'), 'vg_name': lv.get('vg_name'), 'lv_uuid': lv.get('lv_uuid'), 'lv_size': lv.get('lv_size'), 'lv_attr': lv.get('lv_attr'), 'origin': lv.get('origin'), 'snap_percent': lv.get('snap_percent'), 'lv_path': lv.get('lv_path') } for lv in data['report'][0].get('lv', [])] return [] except json.JSONDecodeError: use_manual_parse = True if use_manual_parse: lvs = [] for line in stdout.strip().split('\n'): parts = line.strip().split('|') if len(parts) >= 8: lvs.append({ 'lv_name': parts[0].strip(), 'vg_name': parts[1].strip(), 'lv_uuid': parts[2].strip() if len(parts) > 2 else None, 'lv_size': parts[3].strip() if len(parts) > 3 else None, 'lv_attr': parts[4].strip() if len(parts) > 4 else None, 'origin': parts[5].strip() if len(parts) > 5 else None, 'snap_percent': parts[6].strip() if len(parts) > 6 else None, 'lv_path': parts[7].strip() if len(parts) > 7 else None, }) return lvs return [] # 全局实例 _compat_instance = None def get_system_compat(): """获取全局系统兼容性实例""" global _compat_instance if _compat_instance is None: _compat_instance = SystemCommandCompat() return _compat_instance