242 lines
8.7 KiB
Python
242 lines
8.7 KiB
Python
import os
|
||
import platform
|
||
import socket
|
||
import time
|
||
from dataclasses import dataclass
|
||
from importlib import metadata
|
||
from typing import Any
|
||
|
||
from cli.exit_codes import RUNTIME_ERROR
|
||
|
||
from .gateway import OperationsInfrastructureGateway
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class OperationsDependencySpec:
|
||
"""
|
||
运维依赖检查项定义。
|
||
|
||
:param package_name: CLI 展示用包名
|
||
:param distribution: Python 分发名称
|
||
:param required: 是否为必需依赖
|
||
:param version: 显式版本,存在时跳过环境读取
|
||
"""
|
||
|
||
package_name: str
|
||
distribution: str
|
||
required: bool
|
||
version: str | None = None
|
||
|
||
|
||
class OperationsDependencyInspector:
|
||
"""
|
||
运维依赖检查器。
|
||
|
||
该对象负责核心依赖声明、版本探测与依赖检查结果构建,
|
||
使运行时 facade 不再直接维护细碎依赖定义。
|
||
"""
|
||
|
||
@staticmethod
|
||
def read_package_version(distribution_name: str) -> str | None:
|
||
"""
|
||
读取指定 Python 包的已安装版本号。
|
||
|
||
:param distribution_name: Python 包分发名称
|
||
:return: 已安装版本号,未安装时返回 `None`
|
||
"""
|
||
try:
|
||
return metadata.version(distribution_name)
|
||
except metadata.PackageNotFoundError:
|
||
return None
|
||
|
||
@staticmethod
|
||
def build_dependency_specs(*, include_dev: bool = False) -> list[OperationsDependencySpec]:
|
||
"""
|
||
构建当前依赖检查项列表。
|
||
|
||
:param include_dev: 是否附带开发阶段依赖
|
||
:return: 依赖检查项列表
|
||
"""
|
||
dependency_specs = [
|
||
OperationsDependencySpec('python', '', True, platform.python_version()),
|
||
OperationsDependencySpec('fastapi', 'fastapi', True),
|
||
OperationsDependencySpec('uvicorn', 'uvicorn', True),
|
||
OperationsDependencySpec('sqlalchemy', 'SQLAlchemy', True),
|
||
OperationsDependencySpec('alembic', 'alembic', True),
|
||
OperationsDependencySpec('redis', 'redis', True),
|
||
OperationsDependencySpec('pydantic', 'pydantic', True),
|
||
OperationsDependencySpec('pydantic-settings', 'pydantic-settings', True),
|
||
OperationsDependencySpec('typer', 'typer', True),
|
||
]
|
||
if include_dev:
|
||
dependency_specs.extend(
|
||
[
|
||
OperationsDependencySpec('pytest', 'pytest', False),
|
||
OperationsDependencySpec('ruff', 'ruff', False),
|
||
]
|
||
)
|
||
return dependency_specs
|
||
|
||
def inspect(self, *, include_dev: bool = False) -> dict[str, Any]:
|
||
"""
|
||
读取 CLI 和后端运行所依赖的核心 Python 包版本。
|
||
|
||
:param include_dev: 是否附带开发阶段依赖
|
||
:return: 依赖检查结果
|
||
"""
|
||
packages: dict[str, dict[str, Any]] = {}
|
||
missing_required: list[str] = []
|
||
for dependency_spec in self.build_dependency_specs(include_dev=include_dev):
|
||
version = dependency_spec.version or self.read_package_version(dependency_spec.distribution)
|
||
installed = bool(version)
|
||
package_payload = {
|
||
'installed': installed,
|
||
'version': version or '',
|
||
'required': dependency_spec.required,
|
||
}
|
||
if dependency_spec.distribution:
|
||
package_payload['distribution'] = dependency_spec.distribution
|
||
packages[dependency_spec.package_name] = package_payload
|
||
if dependency_spec.required and not installed:
|
||
missing_required.append(dependency_spec.package_name)
|
||
|
||
if missing_required:
|
||
return {
|
||
'ok': False,
|
||
'message': '存在缺失的核心运行依赖',
|
||
'missingRequired': missing_required,
|
||
'includeDev': include_dev,
|
||
'packages': packages,
|
||
'exit_code': RUNTIME_ERROR,
|
||
}
|
||
|
||
return {
|
||
'ok': True,
|
||
'message': '核心运行依赖已安装',
|
||
'missingRequired': [],
|
||
'includeDev': include_dev,
|
||
'packages': packages,
|
||
}
|
||
|
||
|
||
class OperationsServerInfoSupport:
|
||
"""
|
||
运维服务器信息支持对象。
|
||
|
||
该对象负责 CLI 兜底服务器信息构建与 IP 解析逻辑,
|
||
避免主运行时服务继续承载大量平台采集细节。
|
||
|
||
:param infrastructure_gateway: 运维基础设施网关
|
||
"""
|
||
|
||
def __init__(self, infrastructure_gateway: OperationsInfrastructureGateway) -> None:
|
||
"""
|
||
初始化服务器信息支持对象。
|
||
|
||
:param infrastructure_gateway: 运维基础设施网关
|
||
:return: None
|
||
"""
|
||
self.infrastructure_gateway = infrastructure_gateway
|
||
|
||
def resolve_server_ip(self, hostname: str) -> str:
|
||
"""
|
||
解析服务器 IP,优先使用主机名解析,失败时回退到网卡地址。
|
||
|
||
:param hostname: 当前主机名
|
||
:return: 可用的 IPv4 地址
|
||
"""
|
||
psutil = self.infrastructure_gateway.get_psutil_module()
|
||
try:
|
||
resolved_ip = socket.gethostbyname(hostname)
|
||
if resolved_ip:
|
||
return resolved_ip
|
||
except OSError:
|
||
pass
|
||
|
||
for interface_addresses in psutil.net_if_addrs().values():
|
||
for address_info in interface_addresses:
|
||
if (
|
||
address_info.family == socket.AF_INET
|
||
and address_info.address
|
||
and not address_info.address.startswith('127.')
|
||
):
|
||
return address_info.address
|
||
|
||
return '127.0.0.1'
|
||
|
||
def build_server_info_fallback(self) -> dict[str, Any]:
|
||
"""
|
||
构建 CLI 使用的服务器运行信息兜底数据。
|
||
|
||
:return: 与原服务输出结构兼容的服务器运行信息字典
|
||
"""
|
||
psutil = self.infrastructure_gateway.get_psutil_module()
|
||
bytes2human = self.infrastructure_gateway.get_bytes2human()
|
||
cpu_num = psutil.cpu_count(logical=True)
|
||
cpu_usage_percent = psutil.cpu_times_percent()
|
||
memory_info = psutil.virtual_memory()
|
||
|
||
hostname = socket.gethostname()
|
||
current_process = psutil.Process(os.getpid())
|
||
start_time_stamp = current_process.create_time()
|
||
current_time_stamp = time.time()
|
||
difference = current_time_stamp - start_time_stamp
|
||
days = int(difference // (24 * 60 * 60))
|
||
hours = int((difference % (24 * 60 * 60)) // (60 * 60))
|
||
minutes = int((difference % (60 * 60)) // 60)
|
||
process_memory_info = current_process.memory_info()
|
||
|
||
sys_files: list[dict[str, Any]] = []
|
||
for partition in psutil.disk_partitions():
|
||
try:
|
||
disk_usage = psutil.disk_usage(partition.mountpoint)
|
||
except Exception:
|
||
continue
|
||
|
||
mountpoint = partition.mountpoint.replace('\\', '')
|
||
sys_files.append(
|
||
{
|
||
'dirName': partition.device,
|
||
'sysTypeName': partition.fstype,
|
||
'typeName': f'本地固定磁盘({mountpoint})',
|
||
'total': bytes2human(disk_usage.total),
|
||
'used': bytes2human(disk_usage.used),
|
||
'free': bytes2human(disk_usage.free),
|
||
'usage': f'{disk_usage.percent}%',
|
||
}
|
||
)
|
||
|
||
return {
|
||
'cpu': {
|
||
'cpuNum': cpu_num,
|
||
'used': cpu_usage_percent.user,
|
||
'sys': cpu_usage_percent.system,
|
||
'free': cpu_usage_percent.idle,
|
||
},
|
||
'mem': {
|
||
'total': bytes2human(memory_info.total),
|
||
'used': bytes2human(memory_info.used),
|
||
'free': bytes2human(memory_info.free),
|
||
'usage': memory_info.percent,
|
||
},
|
||
'sys': {
|
||
'computerIp': self.resolve_server_ip(hostname),
|
||
'computerName': platform.node(),
|
||
'osArch': platform.machine(),
|
||
'osName': platform.platform(),
|
||
'userDir': os.getcwd(),
|
||
},
|
||
'py': {
|
||
'name': current_process.name(),
|
||
'version': platform.python_version(),
|
||
'startTime': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(start_time_stamp)),
|
||
'runTime': f'{days}天{hours}小时{minutes}分钟',
|
||
'home': current_process.exe(),
|
||
'total': bytes2human(memory_info.available),
|
||
'used': bytes2human(process_memory_info.rss),
|
||
'free': bytes2human(memory_info.available - process_memory_info.rss),
|
||
'usage': round((process_memory_info.rss / memory_info.available) * 100, 2),
|
||
},
|
||
'sysFiles': sys_files,
|
||
}
|