提交 c552a6b3 authored 作者: 陈泽健's avatar 陈泽健

feat(service-monitor): 落地服务监测模块(自包含子包,核心子集)

新增 service_monitor/ 自包含子包(与问题排查助手代码物理隔离):
- routes/services/utils 三层 + 7 个 bash 检测模块(system 01-04 + docker/mysql/redis basic)
- 本机 subprocess / 远程 paramiko SSH 双模式执行器
- SSE 实时进度 + 取消 + 报告异常置顶分模块折叠 + MD/JSON 导出
- Fernet 加密 SSH 凭据,容器名模糊匹配,快速/全量两套件
- 角色控制:普通用户仅查看,管理员可操作
- 4 个前端模板(index/targets/run/report),移动端适配
- 55 个私有测试,全套 218 绿,未破坏现有测试

配套变更:
- deploy/upload_to_server.py: 新增递归目录上传(service_monitor 多层子目录)
- requirements.txt: +paramiko +cryptography
- pytest.ini: testpaths 追加 web/service_monitor/tests
- .gitignore: 忽略运行时数据(targets.json/reports)
- CLAUDE.md: 更新项目结构说明

文档:Docs/需求文档/服务监测/(PRD + 计划执行 + HANDOFF)+ 项目根 HANDOFF.md
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 b94d0230
......@@ -57,4 +57,10 @@ temp/
# Claude Code
.claude/skills/*/tmp/
.claude/worktrees/
\ No newline at end of file
.claude/worktrees/
# 服务监测模块运行时数据(含加密凭据与报告,勿入库)
skill/code/web/service_monitor/data/targets.json
skill/code/web/service_monitor/data/reports/*.json
!skill/code/web/service_monitor/data/.gitkeep
!skill/code/web/service_monitor/data/reports/.gitkeep
\ No newline at end of file
......@@ -30,9 +30,16 @@ skill/code/web/ # Web 服务(唯一开发源)
│ ├── logger.py # 通用日志(控制台+文件双输出)
│ ├── error_codes.py # 统一错误码
│ └── response.py # success/error 响应封装
├── templates/ # index.html / login.html
├── templates/ # index.html / login.html / platform.html / service_monitor/
├── config.json # 运行配置
└── users.json # 用户数据
├── users.json # 用户数据
└── service_monitor/ # 服务监测模块(自包含子包,与问题排查助手物理隔离)
├── __init__.py / routes.py # 导出 bp + 路由(/service-monitor/* + /api/service-monitor/*)
├── services/ # target_service / runner_service(SSE) / report_service
├── utils/ # paths / crypto(Fernet) / check_modules / parser / thresholds / executor(Local/SSH paramiko)
├── assets/ # bash 检测模块(config.sh.template + common.sh + system/ + service/)
├── data/ # 运行时数据(targets.json 加密凭据 + reports/,.gitignore 忽略)
└── tests/ # 模块私有测试(55 用例)
skill/code/tests/ # 单元测试(pytest,143 用例)
├── conftest.py # sys.path 注入 web/ + 索引路径 autouse fixture + 向量 fixture
......
# HANDOFF — 服务监测模块实施进度
> 最后更新:2026-07-16 | 分支:troubleshoot-ai-assistant | 模块:service-monitor
> 状态:**代码全部完成,218 测试全绿,待部署到 5.60**
---
## 1. 我们在做什么
为运维平台落地「服务监测」模块:监控本机/远程服务器系统资源与服务状态,输出巡检报告。
**只做监测不做修复**(修复接口预留,点击返回"开发中")。
需求与计划文档:
- `Docs/需求文档/服务监测/PRD_需求文档_服务监测模块.md`
- `Docs/需求文档/服务监测/PRD_计划执行_服务监测模块.md`
### 关键决策(已与用户对齐)
- 代码**完全隔离**:所有代码进 `skill/code/web/service_monitor/` 子包,不动 `routes/`/`services/`/`utils/`/`config.json`/`container.py`,对外仅 `server.py` 一行注册
- 本期**核心子集**:system(01/02/03/04) + Docker-basic + MySQL-basic + Redis-basic
- bash 模块**搬进项目**当静态资源 + config 外置模板
- 容器名**模糊匹配**(不同服务器容器名不一致,用 grep -iE 模式发现)
- 远程 SSH 用 **paramiko**;SSH 密码 **Fernet 加密存储**
- 进度反馈 **SSE**;报告默认保留 **14 天**可配置
- **快速巡检 / 全量巡检** 两套件
- **角色控制**:普通用户仅查看(隐藏操作入口),管理员可操作
---
## 2. 已完成(全部 7 阶段)
### ✅ 阶段一:子包骨架 + bash 资产搬迁
- 建子包目录 `service_monitor/{__init__,routes,services/,utils/,assets/,data/,tests/}`
- 搬迁核心 bash 模块到 `assets/`:common.sh + config.sh.template + system/{01,02,03,04} + service/{20,22,24}
- 改造模块头部 `LIB_DIR="${LIB_DIR:-/tmp/check_modules}"`(7个模块,支持注入)
- config.sh.template:容器名改匹配模式、密码改占位符、阈值可覆盖、新增 resolve_container
- 端到端本地执行验证通过(KEY:VALUE 输出正常)
### ✅ 阶段二:utils 层
- `paths.py` — 模块私有路径常量 + ensure_dirs()
- `crypto.py` — Fernet 加密/解密(MONITOR_ENC_KEY > 派生自 SECRET_KEY > 兜底)
- `check_modules.py` — CheckModule 清单 + get_suite() + render_config()(shlex.quote 防注入)
- `parser.py` — 移植 Parse-ModuleResult(过滤脏行 + KEY:VALUE 解析 + 状态判定,优先 *_LEVEL)
- `thresholds.py` — 阈值表 + judge_status()(数值/百分比比较,严重/警告分级)
- `display_names.py` — KEY→中文显示名(核心子集)
- `executor.py` — BaseExecutor/LocalExecutor(subprocess)/SSHExecutor(paramiko)
### ✅ 阶段三:services 层
- `target_service.py` — 目标 CRUD + 连通性测试 + make_executor + resolve_credentials(解密)
- `runner_service.py` — 巡检编排(生成器 yield SSE 事件)+ cancel_run + get_run_status
- `report_service.py` — 报告 save/get/list/delete/cleanup_expired/export_md/export_json
### ✅ 阶段四:routes 层 + 接入 + 删旧占位
- `routes.py` — 16 条路由(4页面 + 12API),角色控制完整
- 删除旧占位 `routes/service_monitor.py` + `templates/service_monitor.html`
- `server.py` 已有 `from service_monitor import bp`(之前占位就改好了)
### ✅ 阶段五:前端模板(UI 已与用户确认)
- `index.html` — 目标卡片 + 报告列表(普通用户隐藏操作按钮)
- `targets.html` — 目标 CRUD 弹窗 + 连通性测试
- `run.html` — 进度条 + 模块清单 + SSE 实时反馈 + 完成跳报告
- `report.html` — 汇总卡片 + 异常置顶 + 分模块折叠 + 导出MD/JSON + 修复预留按钮
### ✅ 阶段六:测试
- 模块私有测试 55 用例(parser/crypto/check_modules/target_service/report_service/routes_sm)
- **全套 218 测试全绿**(原 163 + 新增 55,未破坏现有测试)
- `pytest.ini` 新增 testpath `web/service_monitor/tests`
- 子包 conftest 注入 tmp 数据目录隔离
### ✅ 阶段七:部署与依赖
- `requirements.txt` 加 paramiko + cryptography
- `.env.example` 加 MONITOR_ENC_KEY 说明(含生成命令)
- `deploy/upload_to_server.py``RECURSIVE_DIRS_TO_UPLOAD` + `_upload_dir_recursive()`(递归上传 service_monitor,排除 tests/data/__pycache__)
- `.gitignore` 加服务监测运行时数据忽略(targets.json/reports/*.json,保留 .gitkeep)
- `CLAUDE.md` 更新项目结构说明
- 本机 test_client 冒烟全通过(页面200/权限403/API正常)
---
## 3. 待办(部署)
代码完成,**尚未部署到 5.60**。部署步骤:
```bash
# 1. 服务器需装依赖(paramiko 已在 deploy 用,cryptography 需确认)
# 服务器执行:pip install paramiko cryptography
# (或确认 paramiko 已装则 cryptography 作为其依赖已存在)
# 2. 上传代码(用 ! 前缀在会话内执行,SSH_PASSWORD 环境变量)
! cd deploy && SSH_PASSWORD='***' python upload_to_server.py
# 3. 权威验证
cd deploy && python verify_deployment.py
# 4. 手动验证服务监测
# - 浏览器访问 http://192.168.5.60:8088/service-monitor
# - 本地目标快速巡检 → 看报告
# - (可选)新增远程目标测连通 + 远程巡检
```
---
## 4. 任务进度
| # | 阶段 | 状态 |
|---|------|------|
| 1 | 子包骨架 + bash 资产搬迁 | ✅ |
| 2 | utils 层 | ✅ |
| 3 | services 层 | ✅ |
| 4 | routes 层 + 接入 + 删旧占位 | ✅ |
| 5 | 前端模板 | ✅ |
| 6 | 测试(55 用例,全套 218 绿) | ✅ |
| 7 | 部署与依赖 | ✅ 代码完成,待部署 |
---
## 5. 踩过的坑 / 注意事项
1. **模块头部 LIB_DIR 写死**:原脚本 `LIB_DIR="/tmp/check_modules"` 覆盖环境变量,改 `${LIB_DIR:-...}` 才能注入
2. **common.sh 加载路径**:source `$LIB_DIR/lib/config.sh`,executor 必须布置成 `<workdir>/lib/` 结构
3. **MySQL/Redis 模块已内置模糊匹配**`grep -i "${CONTAINERS[mysql]}"`,config 容器名值直接当模式用
4. **config 凭据占位符**:模板里 `MYSQL_PASSWORD=__MYSQL_PASSWORD__`(不加引号),渲染时用 shlex.quote 输出安全引用;若模板自带引号会双引号套单引号出错
5. **shlex.quote 空值**:返回 `''`(空字符串带引号),bash 合法
6. **测试 fixture session 覆盖**:user/admin 不能共用同一 client 实例(session 互相覆盖),改为各自独立 test_client()
7. **测试断言 `'password' not in json`**:太严,`has_password` 字段含子串;改为检查不返回 `password_enc`/`password` 明文字段
8. **upload 递归上传**:原 `DIRS_TO_UPLOAD` 只上传一层文件,service_monitor 多层子目录需 `_upload_dir_recursive`;排除 tests/data/__pycache__
9. **运行时 data 目录**:服务器首次部署无 data/,靠 `ensure_dirs()` 自动创建(mkdir parents=True)
10. **Blueprint 端点名**`service-monitor.xxx`(横线),url_for 用全名
11. **Docker basic 的 check_container_status**:用精确名 `grep -q "^${name}$"`,模式下可能匹配不到逐容器项;本期可接受(还有 service 状态/资源等通用项)
---
## 6. 关键文件速查
### 新增子包
```
skill/code/web/service_monitor/
├── __init__.py # from .routes import bp
├── routes.py # 16 条路由
├── services/{__init__,target_service,runner_service,report_service}.py
├── utils/{__init__,paths,crypto,check_modules,parser,thresholds,display_names,executor}.py
├── assets/{config.sh.template, common.sh, system/{01,02,03,04}*.sh, service/{20,22,24}*.sh}
├── data/{.gitkeep, reports/.gitkeep} # 运行时数据,gitignore
└── tests/{__init__,conftest,test_parser,test_crypto,test_check_modules,test_target_service,test_report_service,test_routes_sm}.py
templates/service_monitor/{index,targets,run,report}.html
```
### 修改的全局文件(仅这些)
- `server.py``from service_monitor import bp as service_monitor_bp`(占位时就改好)
- `skill/code/requirements.txt` — +paramiko +cryptography
- `skill/code/pytest.ini` — testpaths 加 web/service_monitor/tests
- `deploy/upload_to_server.py` — +RECURSIVE_DIRS_TO_UPLOAD + _upload_dir_recursive
- `.env.example` — +MONITOR_ENC_KEY
- `.gitignore` — +服务监测运行时数据忽略
- `CLAUDE.md` — 项目结构说明
### 已删除
- `skill/code/web/routes/service_monitor.py`(旧占位路由)
- `skill/code/web/templates/service_monitor.html`(旧占位模板)
此差异已折叠。
......@@ -51,9 +51,46 @@ DIRS_TO_UPLOAD = [
('utils', 'web/utils'), # 含 modules.py / vector_builder.py 等
('routes', 'web/routes'), # 含 platform.py(平台首页)
('services', 'web/services'), # P1-3:ai_service / record_service
('templates', 'web/templates'), # 含 platform.html
('templates', 'web/templates'), # 含 platform.html + service_monitor/ 子目录
]
# 需要递归上传的目录(含多层子目录,如服务监测子包)
# service_monitor 是自包含子包:代码 + bash 资产 + 模板均在内
# 排除 tests/(测试)、data/(运行时数据,勿覆盖服务器已有报告/目标)、__pycache__
RECURSIVE_DIRS_TO_UPLOAD = [
('service_monitor', 'web/service_monitor'),
]
# 递归上传时排除的目录名与文件后缀
_EXCLUDE_DIRS = {'__pycache__', 'tests', 'data', '.pytest_cache'}
_EXCLUDE_SUFFIXES = ('.pyc', '.pyo')
def _upload_dir_recursive(ssh, sftp, local_dir, remote_dir, remote_rel):
"""递归上传目录(service_monitor 子包用)。
排除 __pycache__ / tests / data / .pyc 等运行期与测试产物。
"""
# 远程目录可能含中文/多层,先确保存在
ssh.exec_command(f'mkdir -p "{remote_dir}"')[1].channel.recv_exit_status()
for fname in os.listdir(local_dir):
if fname in _EXCLUDE_DIRS:
continue
lpath = os.path.join(local_dir, fname)
rpath = remote_dir + "/" + fname
if os.path.isfile(lpath):
if fname.endswith(_EXCLUDE_SUFFIXES):
continue
try:
sftp.put(lpath, rpath)
print(" [OK] " + remote_rel + "/" + os.path.relpath(lpath, LOCAL_BASE).replace("\\", "/"))
except Exception as e:
print(" [FAIL] " + fname + ": " + str(e))
elif os.path.isdir(lpath):
ssh.exec_command(f'mkdir -p "{rpath}"')[1].channel.recv_exit_status()
_upload_dir_recursive(ssh, sftp, lpath, rpath, remote_rel)
def upload_files():
print("="*60)
print(" Troubleshoot - Upload Files")
......@@ -124,6 +161,16 @@ def upload_files():
sftp.put(lpath, rpath)
print(" [OK] " + remote_rel + "/" + fname)
# 递归上传子包目录(service_monitor 等含多层子目录)
for local_rel, remote_rel in RECURSIVE_DIRS_TO_UPLOAD:
local_dir = os.path.join(LOCAL_BASE, local_rel)
remote_dir = REMOTE_BASE + "/" + remote_rel
if not os.path.isdir(local_dir):
print(" [FAIL] Dir not found: " + local_dir)
continue
print(" Uploading package (recursive): " + local_rel + "/")
_upload_dir_recursive(ssh, sftp, local_dir, remote_dir, remote_rel)
sftp.close()
print("\n[OK] Files uploaded")
......
[pytest]
testpaths = tests
testpaths = tests web/service_monitor/tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
......
......@@ -8,6 +8,12 @@ requests>=2.31.0
python-docx>=0.8.11
werkzeug>=2.3.0
# 服务监测模块依赖
# paramiko: SSH 远程巡检执行
# cryptography: SSH 凭据 Fernet 加密(paramiko 间接依赖,显式声明便于独立使用)
paramiko>=3.4.0
cryptography>=42.0.0
# 测试依赖(P1-2 单元测试)
pytest>=7.0.0
pytest-cov>=4.0.0
......
# -*- coding: utf-8 -*-
"""
service_monitor.py — 服务监测模块路由
占位模块,后续实现 SSH 连接监测服务器服务状态、输出报告功能。
"""
from flask import Blueprint, render_template, session
from decorators import page_login_required
from utils.logger import get_logger
logger = get_logger(__name__)
bp = Blueprint('service-monitor', __name__)
@bp.route('/service-monitor')
@page_login_required
def service_monitor_page():
"""服务监测主页(占位)"""
user = session.get('user', {})
return render_template('service_monitor.html', user=user)
......@@ -87,7 +87,7 @@ def create_app():
from routes.auth import bp as auth_bp
from routes.troubleshoot import bp as troubleshoot_bp
from routes.service_manage import bp as service_manage_bp
from routes.service_monitor import bp as service_monitor_bp
from service_monitor import bp as service_monitor_bp
from routes.cache import bp as cache_bp
from routes.export import bp as export_bp
from routes.submit import bp as submit_bp
......
# -*- coding: utf-8 -*-
"""
service_monitor — 服务监测模块(自包含子包)
监控本机/远程服务器的系统资源与服务状态,输出巡检报告。
只做监测不做修复(修复接口预留)。
对外仅暴露 Blueprint:
from service_monitor import bp
app.register_blueprint(bp)
所有代码、资产、数据均在本子包内,与问题排查助手(routes/services/utils)物理隔离。
"""
from .routes import bp
__all__ = ["bp"]
#!/bin/bash
################################################################################
# 通用函数库
# 说明: 提供可复用的工具函数,包括日志、Docker、系统信息、工具和数据处理
################################################################################
# 只在LIB_DIR未设置时才计算
if [ -z "$LIB_DIR" ]; then
# 获取脚本所在目录
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_DIR="$(dirname "$SCRIPT_DIR")"
fi
# 加载配置文件
if [ -f "$LIB_DIR/lib/config.sh" ]; then
source "$LIB_DIR/lib/config.sh"
else
echo "ERROR: 配置文件不存在: $LIB_DIR/lib/config.sh" >&2
exit 1
fi
# ==================== 日志函数 ====================
# 输出信息日志
log_info() {
echo "[INFO] $*"
}
# 输出错误日志
log_error() {
echo "[ERROR] $*" >&2
}
# 输出警告日志
log_warn() {
echo "[WARN] $*"
}
# 输出调试日志
log_debug() {
if [ "$DEBUG" = "1" ]; then
echo "[DEBUG] $*"
fi
}
# ==================== Docker通用函数 ====================
# 检查容器是否运行
is_container_running() {
local container=$1
docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${container}$"
return $?
}
# 获取容器IP
get_container_ip() {
local container=$1
docker inspect --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$container" 2>/dev/null
}
# 在容器中执行命令
exec_in_container() {
local container=$1
shift
local cmd="$@"
if ! is_container_running "$container"; then
log_error "容器 $container 未运行"
return 1
fi
docker exec "$container" sh -c "$cmd" 2>/dev/null
}
# 获取容器状态
get_container_status() {
local container=$1
docker inspect --format='{{.State.Status}}' "$container" 2>/dev/null
}
# 获取容器运行时间
get_container_uptime() {
local container=$1
docker inspect --format='{{.State.StartedAt}}' "$container" 2>/dev/null | \
awk -F'T' '{print $1}' | \
xargs -I {} date -d {} +%s | \
awk '{print systime()-$1}' | \
awk '{print int($1/86400)}'
}
# ==================== 系统信息函数 ====================
# 获取系统运行时间(天数)
get_uptime_days() {
cat /proc/uptime | awk '{print int($1/86400)}'
}
# 获取系统负载
get_loadavg() {
cat /proc/loadavg | awk '{print $1, $2, $3}'
}
# 获取主机名
get_hostname() {
hostname
}
# 获取操作系统版本
get_os_version() {
if [ -f /etc/os-release ]; then
. /etc/os-release
echo "$PRETTY_NAME"
elif [ -f /etc/redhat-release ]; then
cat /etc/redhat-release
elif [ -f /etc/lsb-release ]; then
. /etc/lsb-release
echo "$DISTRIB_DESCRIPTION"
else
uname -s
fi
}
# 获取内核版本
get_kernel_version() {
uname -r
}
# ==================== 工具函数 ====================
# 检查命令是否存在
require_command() {
local cmd=$1
if ! command -v "$cmd" &> /dev/null; then
log_error "命令不存在: $cmd"
return 1
fi
return 0
}
# 检查端口是否开放
check_port() {
local port=$1
local protocol=${2:-tcp}
if [ "$protocol" = "tcp" ]; then
netstat -tuln 2>/dev/null | grep -q ":$port " || ss -tuln 2>/dev/null | grep -q ":$port "
else
netstat -tuln 2>/dev/null | grep -q "\.$port " || ss -tuln 2>/dev/null | grep -q "\.$port "
fi
return $?
}
# 检查进程是否存在
check_process() {
local pname=$1
pgrep -x "$pname" > /dev/null 2>&1
return $?
}
# 数字比较辅助函数
num_compare() {
local val1=$1
local op=$2
local val2=$3
awk "BEGIN {exit !($val1 $op $val2)}"
}
# ==================== 数据处理函数 ====================
# 格式化字节数
format_bytes() {
local bytes=$1
local units=("B" "KB" "MB" "GB" "TB")
local unit=0
while [ $(num_compare "$bytes" ">=" 1024 && echo $?) -eq 0 ] && [ $unit -lt 4 ]; do
bytes=$(awk "BEGIN {printf \"%.2f\", $bytes/1024}")
unit=$((unit + 1))
done
echo "${bytes}${units[$unit]}"
}
# 格式化百分比
format_percent() {
local value=$1
echo "${value}%"
}
# 计算百分比
calc_percent() {
local used=$1
local total=$2
if [ "$total" -eq 0 ]; then
echo "0"
return
fi
awk "BEGIN {printf \"%.2f\", ($used/$total)*100}"
}
# 格式化数字(添加千位分隔符)
format_number() {
local num=$1
echo "$num" | awk '{printf "%'\''d\n", $0}'
}
# ==================== 输出函数 ====================
# 输出检测结果(标准格式)
output_result() {
local key=$1
local value=$2
echo "${key}:${value}"
}
# 输出错误信息(标准格式)
output_error() {
local msg=$1
echo "ERROR:${msg}" >&2
}
# ==================== 验证函数 ====================
# 验证数字
validate_number() {
local value=$1
[[ "$value" =~ ^[0-9]+(\.[0-9]+)?$ ]]
}
# 验证IP地址
validate_ip() {
local ip=$1
[[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]
}
# ==================== 文件操作函数 ====================
# 检查文件是否存在且可读
check_file_readable() {
local file=$1
if [ ! -f "$file" ]; then
log_error "文件不存在: $file"
return 1
fi
if [ ! -r "$file" ]; then
log_error "文件不可读: $file"
return 1
fi
return 0
}
# 获取文件最后修改时间(天数)
get_file_age_days() {
local file=$1
if [ ! -f "$file" ]; then
echo "-1"
return
fi
local mtime=$(stat -c %Y "$file" 2>/dev/null || stat -f %m "$file" 2>/dev/null)
local current=$(date +%s)
echo $(( (current - mtime) / 86400 ))
}
# ==================== 时间函数 ====================
# 获取当前时间戳
get_timestamp() {
date +%s
}
# 格式化时间戳
format_timestamp() {
local ts=$1
local format=${2:-%Y-%m-%d %H:%M:%S}
date -d "@$ts" +"$format" 2>/dev/null || date -r "$ts" +"$format" 2>/dev/null
}
#!/bin/bash
################################################################################
# config.sh.template — 服务监测模块配置模板
#
# 说明:
# - 此文件为模板,由 executor 在运行时渲染(替换 __占位符__)后下发到目标机
# - 容器名采用「模糊匹配模式」:MySQL/Redis 等模块用 grep -i 模式匹配实际容器名
# (不同服务器容器名可能不同,因此用模式而非精确名)
# - 密码为占位符,渲染时注入解密后的实际凭据(不在模板中硬编码)
# - 阈值带默认值,可被目标级覆盖(executor 渲染时注入环境变量)
################################################################################
# ==================== 容器匹配模式 ====================
# 值为 grep -iE 的模式,模块内会用 docker ps | grep -iE "<pattern>" 模糊发现容器
declare -A CONTAINERS
CONTAINERS[mysql]="mysql"
CONTAINERS[redis]="redis"
CONTAINERS[emqx]="emqx"
CONTAINERS[java]="java|api"
CONTAINERS[nginx]="nginx"
CONTAINERS[nacos]="nacos"
CONTAINERS[python]="python"
CONTAINERS[python_voice]="python_voice"
# ==================== 凭据(占位符,渲染时注入;值由 Python 端做 shell 安全引用) ====================
MYSQL_PASSWORD=__MYSQL_PASSWORD__
REDIS_PASSWORD=__REDIS_PASSWORD__
# ==================== 阈值(默认值,可被环境变量覆盖) ====================
# CPU 阈值(使用率百分比)
CPU_WARNING="${CPU_WARNING:-85}"
CPU_CRITICAL="${CPU_CRITICAL:-100}"
# 内存阈值
MEMORY_WARNING="${MEMORY_WARNING:-85}"
MEMORY_CRITICAL="${MEMORY_CRITICAL:-95}"
# 磁盘阈值
DISK_WARNING="${DISK_WARNING:-90}"
DISK_CRITICAL="${DISK_CRITICAL:-95}"
# 线程阈值
THREAD_WARNING="${THREAD_WARNING:-1000}"
THREAD_CRITICAL="${THREAD_CRITICAL:-3000}"
# ==================== 路径配置(可选,用于日志类模块,本期核心子集未用到) ====================
JAVA_LOG_PATH="${JAVA_LOG_PATH:-/data/services/api/*/log}"
PYTHON_LOG_PATH="${PYTHON_LOG_PATH:-/data/services/api/python*/log}"
NGINX_LOG_PATH="${NGINX_LOG_PATH:-/data/middleware/nginx/log}"
NACOS_LOG_PATH="${NACOS_LOG_PATH:-/data/middleware/nacos/logs}"
# ==================== 函数 ====================
# 获取配置值
get_config() {
local key=$1
echo "${!key}"
}
# 检查容器是否存在(精确匹配,保留兼容)
check_container() {
local container=$1
docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${container}$"
return $?
}
# 获取容器 IP
get_container_ip() {
local container=$1
docker inspect --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$container" 2>/dev/null
}
# 获取 MySQL 密码
get_mysql_password() {
echo "$MYSQL_PASSWORD"
}
# 获取 Redis 密码
get_redis_password() {
echo "$REDIS_PASSWORD"
}
# 获取容器匹配模式
get_container_name() {
local key=$1
echo "${CONTAINERS[$key]}"
}
# 检查容器是否运行(精确匹配,保留兼容)
is_container_running() {
local container=$1
docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${container}$"
return $?
}
# 模糊匹配发现容器:返回第一个匹配 grep -iE 模式的运行中容器名
# 用法:resolve_container <key> (key 为 CONTAINERS 中的键,如 mysql)
resolve_container() {
local key=$1
local pattern="${CONTAINERS[$key]}"
[ -z "$pattern" ] && return 1
docker ps --format '{{.Names}}' 2>/dev/null | grep -iE "$pattern" | head -1
}
#!/bin/bash
################################################################################
# 系统基础信息检测模块
# 功能: 检测主机名、操作系统、内核版本、运行时间、负载等基础信息
# 作者: Claude Code
# 日期: 2026-05-09
################################################################################
# 获取脚本所在目录并加载依赖
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_DIR="${LIB_DIR:-/tmp/check_modules}"
# 加载配置文件和通用函数库
if [ -f "$LIB_DIR/lib/config.sh" ]; then
source "$LIB_DIR/lib/config.sh"
else
echo "ERROR: 配置文件不存在: $LIB_DIR/lib/config.sh"
exit 1
fi
if [ -f "$LIB_DIR/lib/common.sh" ]; then
source "$LIB_DIR/lib/common.sh"
else
echo "ERROR: 通用函数库不存在: $LIB_DIR/lib/common.sh"
exit 1
fi
# ==================== 检测函数 ====================
# 检测主机名
check_hostname() {
local hostname
hostname=$(hostname -f 2>/dev/null || hostname)
output_result "HOSTNAME" "$hostname"
}
# 检测操作系统版本
check_os_version() {
local os_version=""
if [ -f /etc/os-release ]; then
source /etc/os-release
os_version="$PRETTY_NAME"
elif [ -f /etc/redhat-release ]; then
os_version=$(cat /etc/redhat-release)
elif [ -f /etc/lsb-release ]; then
source /etc/lsb-release
os_version="$DISTRIB_DESCRIPTION"
else
os_version=$(uname -s)
fi
output_result "OS_VERSION" "$os_version"
}
# 检测内核版本
check_kernel_version() {
local kernel
kernel=$(uname -r)
output_result "KERNEL_VERSION" "$kernel"
}
# 检测运行时间(天数)
check_uptime_days() {
local uptime_days
uptime_days=$(get_uptime_days)
output_result "UPTIME_DAYS" "$uptime_days"
}
# 检测系统负载
check_loadavg() {
local load1 load5 load15
# 从 /proc/loadavg 读取负载
read load1 load5 load15 rest < /proc/loadavg
output_result "LOAD_1MIN" "$load1"
output_result "LOAD_5MIN" "$load5"
output_result "LOAD_15MIN" "$load15"
}
# 检测CPU核心数
check_cpu_cores() {
local cores
cores=$(nproc 2>/dev/null || echo "未知")
output_result "CPU_CORES" "$cores"
}
# 检测总内存
check_memory_total() {
local mem_total_bytes mem_total_gb
mem_total_bytes=$(free -b | grep Mem | awk '{print $2}')
if [ -n "$mem_total_bytes" ] && [ "$mem_total_bytes" -gt 0 ]; then
mem_total_gb=$(awk "BEGIN {printf \"%.2f\", $mem_total_bytes/1024/1024/1024}")
output_result "MEMORY_TOTAL" "${mem_total_gb}GB"
else
output_result "MEMORY_TOTAL" "未知"
fi
}
# 检测系统启动时间
check_boot_time() {
local boot_time
boot_time=$(uptime -s 2>/dev/null || echo "未知")
output_result "BOOT_TIME" "$boot_time"
}
# 检测架构
check_architecture() {
local arch
arch=$(uname -m)
output_result "ARCHITECTURE" "$arch"
}
# 检测系统资源限制
check_ulimit() {
local ulimit_info=""
if check_command ulimit; then
ulimit_info=$(ulimit -a 2>/dev/null | head -20 | tr '\n' ',' | sed 's/,$//')
fi
if [ -n "$ulimit_info" ]; then
output_result "ULIMIT_INFO" "$ulimit_info"
else
output_result "ULIMIT_INFO" "无法获取"
fi
}
# 检测内核启动参数
check_kernel_cmdline() {
local cmdline=""
if [ -f /proc/cmdline ]; then
cmdline=$(cat /proc/cmdline 2>/dev/null | tr ' ' ',')
# 截取前200个字符避免过长
cmdline=${cmdline:0:200}
if [ ${#cmdline} -eq 200 ]; then
cmdline="${cmdline}..."
fi
fi
if [ -n "$cmdline" ]; then
output_result "KERNEL_CMDLINE" "$cmdline"
else
output_result "KERNEL_CMDLINE" "无法读取"
fi
}
# 检测内核关键参数
check_kernel_params() {
# 文件描述符限制
local file_max=""
if [ -f /proc/sys/fs/file-max ]; then
file_max=$(cat /proc/sys/fs/file-max 2>/dev/null)
fi
[ -n "$file_max" ] && output_result "FS_FILE_MAX" "$file_max"
# inotify监控限制
local inotify_max_watches=""
if [ -f /proc/sys/fs/inotify/max_user_watches ]; then
inotify_max_watches=$(cat /proc/sys/fs/inotify/max_user_watches 2>/dev/null)
fi
[ -n "$inotify_max_watches" ] && output_result "INOTIFY_MAX_WATCHES" "$inotify_max_watches"
# TCP连接队列
local somaxconn=""
if [ -f /proc/sys/net/core/somaxconn ]; then
somaxconn=$(cat /proc/sys/net/core/somaxconn 2>/dev/null)
fi
[ -n "$somaxconn" ] && output_result "NET_SOMAXCONN" "$somaxconn"
# TCP TIME_WAIT超时
local tcp_tw_timeout=""
if [ -f /proc/sys/net/ipv4/tcp_fin_timeout ]; then
tcp_tw_timeout=$(cat /proc/sys/net/ipv4/tcp_fin_timeout 2>/dev/null)
fi
[ -n "$tcp_tw_timeout" ] && output_result "TCP_FIN_TIMEOUT" "$tcp_tw_timeout"
# TCP保活时间
local tcp_keepalive_time=""
if [ -f /proc/sys/net/ipv4/tcp_keepalive_time ]; then
tcp_keepalive_time=$(cat /proc/sys/net/ipv4/tcp_keepalive_time 2>/dev/null)
fi
[ -n "$tcp_keepalive_time" ] && output_result "TCP_KEEPALIVE_TIME" "$tcp_keepalive_time"
}
# ==================== 主检测流程 ====================
main() {
log_info "开始系统基础信息检测..."
# 执行各项检测
check_hostname
check_os_version
check_kernel_version
check_uptime_days
check_loadavg
check_cpu_cores
check_memory_total
check_boot_time
check_architecture
check_ulimit
check_kernel_cmdline
check_kernel_params
log_info "系统基础信息检测完成"
}
# 执行主函数
main
#!/bin/bash
################################################################################
# CPU资源检测模块
# 功能: 检测CPU使用率、各核心状态、CPU占用TOP5进程
# 作者: Claude Code
# 日期: 2026-05-09
################################################################################
# 获取脚本所在目录并加载依赖
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_DIR="${LIB_DIR:-/tmp/check_modules}"
# 加载配置文件和通用函数库
if [ -f "$LIB_DIR/lib/config.sh" ]; then
source "$LIB_DIR/lib/config.sh"
else
echo "ERROR: 配置文件不存在: $LIB_DIR/lib/config.sh"
exit 1
fi
if [ -f "$LIB_DIR/lib/common.sh" ]; then
source "$LIB_DIR/lib/common.sh"
else
echo "ERROR: 通用函数库不存在: $LIB_DIR/lib/common.sh"
exit 1
fi
# ==================== 检测函数 ====================
# 检测CPU使用率
check_cpu_usage() {
local cpu_output user_cpu sys_cpu total_cpu status
# 获取CPU信息
cpu_output=$(top -bn1 | grep "Cpu(s)" 2>/dev/null)
# 解析用户态和系统态CPU使用率
if echo "$cpu_output" | grep -q "us"; then
# 格式: %Cpu(s): 10.5 us, 7.0 sy, 0.0 ni, 80.7 id
# 使用awk更可靠地解析,字段是 "us," 格式
user_cpu=$(echo "$cpu_output" | awk '{for(i=1;i<=NF;i++) if($i=="us,") print $(i-1)}')
sys_cpu=$(echo "$cpu_output" | awk '{for(i=1;i<=NF;i++) if($i=="sy,") print $(i-1)}')
# 计算总使用率
if [ -n "$user_cpu" ] && [ -n "$sys_cpu" ]; then
total_cpu=$(awk "BEGIN {printf \"%.1f\", $user_cpu + $sys_cpu}")
# 判断状态
if (( $(awk "BEGIN {print ($total_cpu >= $CPU_CRITICAL)}") )); then
status="严重"
elif (( $(awk "BEGIN {print ($total_cpu >= $CPU_WARNING)}") )); then
status="警告"
else
status="正常"
fi
output_result "CPU_USAGE" "${total_cpu}%"
output_result "CPU_USER" "${user_cpu}%"
output_result "CPU_SYSTEM" "${sys_cpu}%"
output_result "CPU_STATUS" "$status"
# 如果状态异常,输出错误信息
if [ "$status" != "正常" ]; then
echo "ERROR:CPU使用率过高: ${total_cpu}%"
fi
else
output_result "CPU_USAGE" "未知"
output_result "CPU_STATUS" "未知"
fi
else
# 备用方案:从 /proc/stat 解析
local cpu_user cpu_nice cpu_system cpu_idle cpu_total
read cpu_user cpu_nice cpu_system cpu_idle rest < /proc/stat
# 计算使用率(简化版)
cpu_total=$((cpu_user + cpu_nice + cpu_system))
local usage=$(awk "BEGIN {printf \"%.1f\", ($cpu_total / ($cpu_total + $cpu_idle)) * 100}")
if (( $(awk "BEGIN {print ($usage >= $CPU_CRITICAL)}") )); then
status="严重"
elif (( $(awk "BEGIN {print ($usage >= $CPU_WARNING)}") )); then
status="警告"
else
status="正常"
fi
output_result "CPU_USAGE" "${usage}%"
output_result "CPU_STATUS" "$status"
fi
}
# 检测各核心CPU使用率
check_cpu_per_core() {
if command -v mpstat &> /dev/null; then
local core_info
core_info=$(mpstat -P ALL 1 1 2>/dev/null | grep -v "^$" | tail -n +4 | head -n -1)
if [ -n "$core_info" ]; then
output_result "CPU_PER_CORE" "已获取"
else
output_result "CPU_PER_CORE" "mpstat不可用"
fi
else
output_result "CPU_PER_CORE" "未安装mpstat"
fi
}
# 检测CPU占用TOP15进程
check_cpu_top15() {
local top15
top15=$(ps -eo pid,comm,%cpu --no-headers 2>/dev/null | sort -k3 -rn | head -15)
if [ -n "$top15" ]; then
# 格式化输出,只取前10个避免过长
local formatted=""
local count=0
while IFS= read -r line; do
if [ -n "$line" ] && [ $count -lt 10 ]; then
if [ -n "$formatted" ]; then
formatted="${formatted}; ${line}"
else
formatted="${line}"
fi
count=$((count + 1))
fi
done <<< "$top15"
output_result "CPU_TOP10_PROCESSES" "$formatted"
else
output_result "CPU_TOP10_PROCESSES" "获取失败"
fi
}
# 检测CPU上下文切换
check_cpu_context_switches() {
local ctxt
ctxt=$(awk '/ctxt/ {print $2}' /proc/stat 2>/dev/null)
if [ -n "$ctxt" ]; then
# 格式化为可读格式
local formatted=$(echo "$ctxt" | awk '{printf "%'\''d", $0}')
output_result "CPU_CONTEXT_SWITCHES" "$formatted"
else
output_result "CPU_CONTEXT_SWITCHES" "未知"
fi
}
# 检测CPU中断
check_cpu_interrupts() {
local intr
intr=$(awk '/intr/ {print $2}' /proc/stat 2>/dev/null)
if [ -n "$intr" ]; then
# 格式化为可读格式
local formatted=$(echo "$intr" | awk '{printf "%'\''d", $0}')
output_result "CPU_INTERRUPTS" "$formatted"
else
output_result "CPU_INTERRUPTS" "未知"
fi
}
# 检测详细中断统计(/proc/interrupts)
check_interrupts_detail() {
if [ -f /proc/interrupts ]; then
# 获取前10个中断的统计
local interrupts
interrupts=$(head -10 /proc/interrupts 2>/dev/null | grep -v "^$" | tr '\n' ',' | sed 's/,$//')
if [ -n "$interrupts" ]; then
output_result "IRQ_DETAIL" "$interrupts"
fi
fi
}
# 检测软中断统计
check_softirqs() {
if [ -f /proc/softirqs ]; then
local softirqs
softirqs=$(cat /proc/softirqs 2>/dev/null | tr '\n' ',' | sed 's/,$//')
if [ -n "$softirqs" ]; then
output_result "SOFTIRQS" "$softirqs"
fi
fi
}
# 检测CPU调度器运行队列长度
check_scheduler_runqueue() {
local runqueue
runqueue=$(awk '/runnable/ {print $2}' /proc/stat 2>/dev/null)
if [ -n "$runqueue" ]; then
output_result "SCHEDULER_RUNQUEUE" "$runqueue"
else
output_result "SCHEDULER_RUNQUEUE" "未知"
fi
}
# 检测进程CPU亲和性示例
check_cpu_affinity() {
# 获取init进程的CPU亲和性作为示例
local affinity=""
if [ -f /proc/1/status ]; then
affinity=$(grep "Cpus_allowed_list" /proc/1/status 2>/dev/null | awk '{print $2}')
fi
if [ -n "$affinity" ]; then
output_result "CPU_AFFINITY_SAMPLE" "$affinity"
else
output_result "CPU_AFFINITY_SAMPLE" "未知"
fi
}
# 检测CPU调度器阻塞进程数
check_scheduler_blocked() {
local runnables=0
local blocked_status="正常"
# 从/proc/stat获取可运行进程数
runnables=$(awk '/procs_running/ {print $2}' /proc/stat 2>/dev/null)
if [ -n "$runnables" ]; then
output_result "SCHEDULER_PROCS_RUNNING" "$runnables"
# 判断状态(可运行进程过多表示调度器压力大)
if [ "$runnables" -gt 100 ]; then
blocked_status="严重"
elif [ "$runnables" -gt 50 ]; then
blocked_status="警告"
fi
output_result "SCHEDULER_BLOCKED_STATUS" "$blocked_status"
else
output_result "SCHEDULER_BLOCKED_STATUS" "未知"
fi
}
# ==================== 主检测流程 ====================
main() {
log_info "开始CPU资源检测..."
# 执行各项检测
check_cpu_usage
check_cpu_per_core
check_cpu_top15
check_cpu_context_switches
check_cpu_interrupts
check_interrupts_detail
check_softirqs
check_scheduler_runqueue
check_cpu_affinity
check_scheduler_blocked
log_info "CPU资源检测完成"
}
# 执行主函数
main
#!/bin/bash
################################################################################
# 内存资源检测模块
# 功能: 检测内存使用率、Swap使用、NUMA架构等
# 作者: Claude Code
# 日期: 2026-05-09
################################################################################
# 获取脚本所在目录并加载依赖
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_DIR="${LIB_DIR:-/tmp/check_modules}"
# 加载配置文件和通用函数库
if [ -f "$LIB_DIR/lib/config.sh" ]; then
source "$LIB_DIR/lib/config.sh"
else
echo "ERROR: 配置文件不存在: $LIB_DIR/lib/config.sh"
exit 1
fi
if [ -f "$LIB_DIR/lib/common.sh" ]; then
source "$LIB_DIR/lib/common.sh"
else
echo "ERROR: 通用函数库不存在: $LIB_DIR/lib/common.sh"
exit 1
fi
# ==================== 检测函数 ====================
# 检测内存使用率
check_memory_usage() {
local mem_total mem_used mem_free mem_available usage_percent status
# 从 free 命令获取内存信息
read _ mem_total mem_used mem_free _ < <(free -b | grep "^Mem:")
if [ -n "$mem_total" ] && [ "$mem_total" -gt 0 ]; then
# 计算使用率
usage_percent=$(awk "BEGIN {printf \"%.1f\", ($mem_used / $mem_total) * 100}")
# 判断状态
if (( $(awk "BEGIN {print ($usage_percent >= $MEMORY_CRITICAL)}") )); then
status="严重"
elif (( $(awk "BEGIN {print ($usage_percent >= $MEMORY_WARNING)}") )); then
status="警告"
else
status="正常"
fi
# 格式化为GB
local total_gb=$(awk "BEGIN {printf \"%.2f\", $mem_total/1024/1024/1024}")
local used_gb=$(awk "BEGIN {printf \"%.2f\", $mem_used/1024/1024/1024}")
local free_gb=$(awk "BEGIN {printf \"%.2f\", $mem_free/1024/1024/1024}")
output_result "MEMORY_USAGE" "${usage_percent}%"
output_result "MEMORY_USED" "${used_gb}GB"
output_result "MEMORY_FREE" "${free_gb}GB"
output_result "MEMORY_TOTAL" "${total_gb}GB"
output_result "MEMORY_STATUS" "$status"
# 如果状态异常,输出错误信息
if [ "$status" != "正常" ]; then
echo "ERROR:内存使用率过高: ${usage_percent}%"
fi
else
output_result "MEMORY_USAGE" "未知"
output_result "MEMORY_STATUS" "未知"
fi
}
# 检测Swap使用
check_swap_usage() {
local swap_total swap_used swap_percent status
# 从 free 命令获取Swap信息
read _ swap_total swap_used _ < <(free -b | grep "^Swap:")
if [ -n "$swap_total" ] && [ "$swap_total" -gt 0 ]; then
if [ "$swap_used" -gt 0 ]; then
swap_percent=$(awk "BEGIN {printf \"%.1f\", ($swap_used / $swap_total) * 100}")
# Swap使用判断阈值
if [ "$swap_percent" -ge 20 ]; then
status="严重"
elif [ "$swap_percent" -ge 1 ]; then
status="警告"
else
status="正常"
fi
# 格式化为MB
local swap_used_mb=$(awk "BEGIN {printf \"%.2f\", $swap_used/1024/1024}")
output_result "SWAP_USAGE" "${swap_percent}%"
output_result "SWAP_USED" "${swap_used_mb}MB"
output_result "SWAP_STATUS" "$status"
if [ "$status" != "正常" ]; then
echo "ERROR:Swap已使用: ${swap_used_mb}MB"
fi
else
output_result "SWAP_USAGE" "0%"
output_result "SWAP_USED" "0MB"
output_result "SWAP_STATUS" "正常"
fi
else
output_result "SWAP_USAGE" "无Swap"
output_result "SWAP_STATUS" "正常"
fi
}
# 检测NUMA架构
check_numa() {
if command -v numactl &> /dev/null; then
local numa_nodes
numa_nodes=$(numactl --hardware 2>/dev/null | grep "node(s)" | awk '{print $1}')
if [ -n "$numa_nodes" ]; then
output_result "NUMA_NODES" "$numa_nodes"
output_result "NUMA_STATUS" "已启用"
else
output_result "NUMA_STATUS" "不支持"
fi
else
output_result "NUMA_STATUS" "未安装numactl"
fi
}
# 检测内存详情(buffers/cached)
check_memory_details() {
local buffers cached
# 从 /proc/meminfo 获取详细信息
buffers=$(awk '/^Buffers:/ {print $2}' /proc/meminfo 2>/dev/null)
cached=$(awk '/^Cached:/ {print $2}' /proc/meminfo 2>/dev/null)
if [ -n "$buffers" ] && [ -n "$cached" ]; then
local buffers_mb=$(awk "BEGIN {printf \"%.2f\", $buffers/1024}")
local cached_mb=$(awk "BEGIN {printf \"%.2f\", $cached/1024}")
output_result "MEMORY_BUFFERS" "${buffers_mb}MB"
output_result "MEMORY_CACHED" "${cached_mb}MB"
fi
}
# 检测内存占用TOP5进程
check_memory_top5() {
local top5
top5=$(ps -eo pid,comm,%mem,%cpu --no-headers 2>/dev/null | sort -k3 -rn | head -5)
if [ -n "$top5" ]; then
# 格式化输出
local formatted=""
while IFS= read -r line; do
if [ -n "$line" ]; then
if [ -n "$formatted" ]; then
formatted="${formatted}; ${line}"
else
formatted="${line}"
fi
fi
done <<< "$top5"
output_result "MEMORY_TOP5_PROCESSES" "$formatted"
else
output_result "MEMORY_TOP5_PROCESSES" "获取失败"
fi
}
# 检测内存压力
check_memory_pressure() {
if [ -f /proc/pressure/memory ]; then
local pressure_info
pressure_info=$(cat /proc/pressure/memory 2>/dev/null | head -1)
if [ -n "$pressure_info" ]; then
# 解析一些关键指标
local avg10=$(echo "$pressure_info" | grep -oP 'avg10=\K[\d.]+' || echo "0")
local avg60=$(echo "$pressure_info" | grep -oP 'avg60=\K[\d.]+' || echo "0")
output_result "MEMORY_PRESSURE_AVG10" "$avg10"
output_result "MEMORY_PRESSURE_AVG60" "$avg60"
fi
else
output_result "MEMORY_PRESSURE" "不支持"
fi
}
# 检测虚拟内存统计
check_vm_stat() {
if [ -f /proc/vmstat ]; then
# 获取一些关键指标
local pgmajfault pswpin pswpout
pgmajfault=$(awk '/pgmajfault/ {print $2}' /proc/vmstat 2>/dev/null || echo "0")
pswpin=$(awk '/pswpin/ {print $2}' /proc/vmstat 2>/dev/null || echo "0")
pswpout=$(awk '/pswpout/ {print $2}' /proc/vmstat 2>/dev/null || echo "0")
output_result "VM_PGMajFAULT" "$pgmajfault"
output_result "VM_PSWPIN" "$pswpin"
output_result "VM_PSWPOUT" "$pswpout"
fi
}
# 检测Slab缓存
check_slab_info() {
if [ -f /proc/meminfo ]; then
local slab_total slab_reclaimable
slab_total=$(awk '/^Slab:/ {print $2}' /proc/meminfo 2>/dev/null)
slab_reclaimable=$(awk '/^SReclaimable:/ {print $2}' /proc/meminfo 2>/dev/null)
if [ -n "$slab_total" ]; then
local slab_mb=$(awk "BEGIN {printf \"%.2f\", $slab_total/1024}")
output_result "SLAB_TOTAL" "${slab_mb}MB"
fi
if [ -n "$slab_reclaimable" ]; then
local reclaimable_mb=$(awk "BEGIN {printf \"%.2f\", $slab_reclaimable/1024}")
output_result "SLAB_RECLAIMABLE" "${reclaimable_mb}MB"
fi
fi
}
# 检测大页内存
check_hugepages() {
if [ -f /proc/meminfo ]; then
local huge_total huge_free huge_size
huge_total=$(awk '/^HugePages_Total:/ {print $2}' /proc/meminfo 2>/dev/null)
huge_free=$(awk '/^HugePages_Free:/ {print $2}' /proc/meminfo 2>/dev/null)
huge_size=$(awk '/^Hugepagesize:/ {print $2}' /proc/meminfo 2>/dev/null)
output_result "HUGEPAGES_TOTAL" "${huge_total:-0}"
output_result "HUGEPAGES_FREE" "${huge_free:-0}"
output_result "HUGEPAGES_SIZE" "${huge_size:-未知}"
# 检查是否启用了透明大页
if [ -f /sys/kernel/mm/transparent_hugepage/enabled ]; then
local thp_enabled=$(cat /sys/kernel/mm/transparent_hugepage/enabled 2>/dev/null)
output_result "TRANSPARENT_HUGEPAGE" "$thp_enabled"
fi
fi
}
# ==================== 主检测流程 ====================
main() {
log_info "开始内存资源检测..."
# 执行各项检测
check_memory_usage
check_swap_usage
check_memory_details
check_numa
check_memory_top5
check_memory_pressure
check_vm_stat
check_slab_info
check_hugepages
log_info "内存资源检测完成"
}
# 执行主函数
main
# -*- coding: utf-8 -*-
"""
routes.py — 服务监测路由层
页面路由:/service-monitor/*
API 路由:/api/service-monitor/*
权限:
- 页面/查看类:登录即可(普通用户仅查看)
- 操作类(目标增删改、触发巡检、取消):仅管理员
SSE:/api/service-monitor/run/stream 流式推送巡检进度
"""
from __future__ import annotations
import json
import logging
from flask import (
Blueprint, render_template, session, request, jsonify,
Response, redirect, url_for, stream_with_context,
)
from .services import target_service, report_service, runner_service
logger = logging.getLogger("service_monitor.routes")
bp = Blueprint('service-monitor', __name__)
# ============================================================
# 内部辅助:权限
# ============================================================
def _current_user():
return session.get('user', {})
def _role():
return _current_user().get('role', '')
def _require_login_json():
if 'user' not in session:
return jsonify({"success": False, "error": {"code": 401, "message": "未登录"}}), 401
return None
def _require_admin_json():
if 'user' not in session:
return jsonify({"success": False, "error": {"code": 401, "message": "未登录"}}), 401
if _role() != 'admin':
return jsonify({"success": False, "error": {"code": 403, "message": "权限不足,仅管理员可操作"}}), 403
return None
# ============================================================
# 页面路由
# ============================================================
@bp.route('/service-monitor')
def page_index():
"""监测主页:目标概览 + 最近报告。"""
if 'user' not in session:
return redirect(url_for('auth.login'))
user = _current_user()
targets = target_service.list_targets(role=user.get('role', ''))
reports = report_service.list_reports(limit=20)
return render_template(
'service_monitor/index.html',
user=user, targets=targets, reports=reports,
is_admin=(user.get('role') == 'admin'),
)
@bp.route('/service-monitor/targets')
def page_targets():
"""目标管理页(仅管理员可进入,普通用户重定向回主页)。"""
if 'user' not in session:
return redirect(url_for('auth.login'))
user = _current_user()
if user.get('role') != 'admin':
return redirect(url_for('service-monitor.page_index'))
targets = target_service.list_targets(role='admin')
return render_template('service_monitor/targets.html', user=user, targets=targets)
@bp.route('/service-monitor/run/<target_id>')
def page_run(target_id):
"""巡检执行页(仅管理员)。"""
if 'user' not in session:
return redirect(url_for('auth.login'))
user = _current_user()
if user.get('role') != 'admin':
return redirect(url_for('service-monitor.page_index'))
target = target_service.get_target_view(target_id, role='admin')
if not target:
return redirect(url_for('service-monitor.page_index'))
suite = request.args.get('suite', 'quick')
return render_template('service_monitor/run.html',
user=user, target=target, suite=suite)
@bp.route('/service-monitor/report/<report_id>')
def page_report(report_id):
"""报告详情页(所有登录用户可看)。"""
if 'user' not in session:
return redirect(url_for('auth.login'))
user = _current_user()
report = report_service.get_report(report_id)
if not report:
return redirect(url_for('service-monitor.page_index'))
return render_template('service_monitor/report.html', user=user, report=report)
# ============================================================
# API:目标管理(管理员)
# ============================================================
@bp.route('/api/service-monitor/targets', methods=['GET'])
def api_list_targets():
guard = _require_login_json()
if guard:
return guard
targets = target_service.list_targets(role=_role())
return jsonify({"success": True, "targets": targets})
@bp.route('/api/service-monitor/targets', methods=['POST'])
def api_create_target():
guard = _require_admin_json()
if guard:
return guard
try:
target = target_service.create_target(request.get_json(force=True) or {})
return jsonify({"success": True, "target": target})
except ValueError as e:
return jsonify({"success": False, "error": {"code": 400, "message": str(e)}}), 400
@bp.route('/api/service-monitor/targets/<target_id>', methods=['PUT'])
def api_update_target(target_id):
guard = _require_admin_json()
if guard:
return guard
try:
target = target_service.update_target(target_id, request.get_json(force=True) or {})
return jsonify({"success": True, "target": target})
except ValueError as e:
return jsonify({"success": False, "error": {"code": 400, "message": str(e)}}), 400
@bp.route('/api/service-monitor/targets/<target_id>', methods=['DELETE'])
def api_delete_target(target_id):
guard = _require_admin_json()
if guard:
return guard
try:
ok = target_service.delete_target(target_id)
if not ok:
return jsonify({"success": False, "error": {"code": 404, "message": "目标不存在"}}), 404
return jsonify({"success": True})
except ValueError as e:
return jsonify({"success": False, "error": {"code": 400, "message": str(e)}}), 400
@bp.route('/api/service-monitor/targets/test', methods=['POST'])
def api_test_connection():
guard = _require_admin_json()
if guard:
return guard
result = target_service.test_connection(request.get_json(force=True) or {})
return jsonify({"success": result["success"], "message": result["message"]})
# ============================================================
# API:巡检(管理员)
# ============================================================
@bp.route('/api/service-monitor/run/stream', methods=['GET'])
def api_run_stream():
"""SSE 流式巡检。参数:target_id, suite(quick/full)。"""
guard = _require_admin_json()
if guard:
return guard
target_id = request.args.get('target_id', '')
suite = request.args.get('suite', 'quick')
@stream_with_context
def generate():
try:
for evt in runner_service.run_inspection(target_id, suite):
yield f"data: {json.dumps(evt, ensure_ascii=False)}\n\n"
except Exception as e:
logger.error("SSE 流异常: %s", e)
err = {"event": "error", "message": f"服务端异常: {e}"}
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
return Response(generate(), mimetype='text/event-stream',
headers={'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
'Connection': 'keep-alive'})
@bp.route('/api/service-monitor/run/<run_id>/cancel', methods=['POST'])
def api_cancel_run(run_id):
guard = _require_admin_json()
if guard:
return guard
ok = runner_service.cancel_run(run_id)
return jsonify({"success": ok})
# ============================================================
# API:报告(登录即可查看)
# ============================================================
@bp.route('/api/service-monitor/reports', methods=['GET'])
def api_list_reports():
guard = _require_login_json()
if guard:
return guard
target_id = request.args.get('target_id')
reports = report_service.list_reports(target_id=target_id, limit=50)
return jsonify({"success": True, "reports": reports})
@bp.route('/api/service-monitor/reports/<report_id>', methods=['GET'])
def api_get_report(report_id):
guard = _require_login_json()
if guard:
return guard
report = report_service.get_report(report_id)
if not report:
return jsonify({"success": False, "error": {"code": 404, "message": "报告不存在"}}), 404
return jsonify({"success": True, "report": report})
@bp.route('/api/service-monitor/reports/<report_id>', methods=['DELETE'])
def api_delete_report(report_id):
guard = _require_admin_json()
if guard:
return guard
ok = report_service.delete_report(report_id)
return jsonify({"success": ok})
@bp.route('/api/service-monitor/reports/<report_id>/export', methods=['GET'])
def api_export_report(report_id):
guard = _require_login_json()
if guard:
return guard
fmt = request.args.get('format', 'md')
if fmt == 'json':
data = report_service.export_json(report_id)
if not data:
return jsonify({"success": False, "error": {"code": 404, "message": "报告不存在"}}), 404
resp = Response(json.dumps(data, ensure_ascii=False, indent=2),
mimetype='application/json')
resp.headers['Content-Disposition'] = f'attachment; filename=report_{report_id}.json'
return resp
else:
md = report_service.export_markdown(report_id)
if md is None:
return jsonify({"success": False, "error": {"code": 404, "message": "报告不存在"}}), 404
resp = Response(md, mimetype='text/markdown')
resp.headers['Content-Disposition'] = f'attachment; filename=report_{report_id}.md'
return resp
# ============================================================
# 修复预留(本期不实现)
# ============================================================
@bp.route('/api/service-monitor/fix', methods=['POST'])
def api_fix():
"""修复能力预留位——本期返回开发中提示。"""
guard = _require_admin_json()
if guard:
return guard
return jsonify({"success": False, "message": "修复能力开发中,敬请期待"})
# -*- coding: utf-8 -*-
"""service_monitor.services — 服务监测业务层"""
from . import target_service
from . import report_service
from . import runner_service
# -*- coding: utf-8 -*-
"""
report_service.py — 巡检报告管理
报告存储:service_monitor/data/reports/{report_id}.json
提供:保存 / 读取 / 列表 / 导出(MD/JSON) / 过期清理
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import datetime, timedelta
from typing import List, Optional
from ..utils.paths import REPORTS_DIR, ensure_dirs
logger = logging.getLogger("service_monitor.report_service")
# 报告默认保留天数(可被配置覆盖)
DEFAULT_RETENTION_DAYS = 14
NORMAL, WARNING, CRITICAL = "正常", "警告", "严重"
def _now() -> str:
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
def _report_path(report_id: str):
return REPORTS_DIR / f"{report_id}.json"
def save(target: dict, suite: str, modules_result: list,
started_at: str, finished_at: Optional[str] = None) -> str:
"""保存巡检报告,返回 report_id。
modules_result: [
{"id","name","category","items":[{key,name,value,threshold,status}], "summary":{...}}
]
"""
ensure_dirs()
report_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + str(uuid.uuid4())[:6]
# 汇总
total = {NORMAL: 0, WARNING: 0, CRITICAL: 0, "total": 0}
for m in modules_result:
for it in m.get("items", []):
total["total"] += 1
st = it.get("status", NORMAL)
if st in total:
total[st] += 1
report = {
"id": report_id,
"target_id": target.get("id"),
"target_name": target.get("name"),
"target_type": target.get("type"),
"suite": suite,
"started_at": started_at,
"finished_at": finished_at or _now(),
"summary": total,
"modules": modules_result,
}
_report_path(report_id).write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
logger.info("保存报告: %s (目标=%s, 套件=%s, 项=%d)",
report_id, target.get("name"), suite, total["total"])
return report_id
def get_report(report_id: str) -> Optional[dict]:
"""读取报告。"""
p = _report_path(report_id)
if not p.exists():
return None
try:
return json.loads(p.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as e:
logger.error("读取报告失败 %s: %s", report_id, e)
return None
def list_reports(target_id: Optional[str] = None, limit: int = 50) -> List[dict]:
"""列出报告摘要(按时间倒序)。"""
ensure_dirs()
summaries = []
for p in sorted(REPORTS_DIR.glob("*.json"), reverse=True):
try:
r = json.loads(p.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
if target_id and r.get("target_id") != target_id:
continue
summaries.append({
"id": r.get("id"),
"target_id": r.get("target_id"),
"target_name": r.get("target_name"),
"target_type": r.get("target_type"),
"suite": r.get("suite"),
"started_at": r.get("started_at"),
"finished_at": r.get("finished_at"),
"summary": r.get("summary", {}),
})
if len(summaries) >= limit:
break
return summaries
def delete_report(report_id: str) -> bool:
"""删除单份报告。"""
p = _report_path(report_id)
if p.exists():
p.unlink()
logger.info("删除报告: %s", report_id)
return True
return False
def cleanup_expired(retention_days: int = DEFAULT_RETENTION_DAYS) -> int:
"""清理过期报告,返回清理数量。"""
ensure_dirs()
cutoff = datetime.now() - timedelta(days=retention_days)
removed = 0
for p in REPORTS_DIR.glob("*.json"):
try:
r = json.loads(p.read_text(encoding="utf-8"))
finished = r.get("finished_at") or r.get("started_at")
if finished:
dt = datetime.strptime(finished[:19], "%Y-%m-%dT%H:%M:%S")
if dt < cutoff:
p.unlink()
removed += 1
except (json.JSONDecodeError, OSError, ValueError):
continue
if removed:
logger.info("清理过期报告 %d 份(保留 %d 天)", removed, retention_days)
return removed
# ============================================================
# 导出
# ============================================================
def export_json(report_id: str) -> Optional[dict]:
"""导出 JSON(即报告本身)。"""
return get_report(report_id)
def export_markdown(report_id: str) -> Optional[str]:
"""导出 Markdown 报告。"""
r = get_report(report_id)
if not r:
return None
s = r.get("summary", {})
lines = [
f"# 服务巡检报告",
"",
f"- **目标**:{r.get('target_name')}({r.get('target_type')})",
f"- **套件**:{'快速巡检' if r.get('suite') == 'quick' else '全量巡检'}",
f"- **开始时间**:{r.get('started_at')}",
f"- **完成时间**:{r.get('finished_at')}",
"",
f"## 汇总",
"",
f"| 总项数 | 正常 | 警告 | 严重 |",
f"|--------|------|------|------|",
f"| {s.get('total', 0)} | {s.get(NORMAL, 0)} | {s.get(WARNING, 0)} | {s.get(CRITICAL, 0)} |",
"",
]
# 异常项置顶
abnormal = []
for m in r.get("modules", []):
for it in m.get("items", []):
if it.get("status") in (WARNING, CRITICAL):
abnormal.append((m.get("name"), it))
if abnormal:
lines += ["## ⚠️ 异常项", "", "| 模块 | 检测项 | 当前值 | 阈值 | 状态 |",
"|------|--------|--------|------|------|"]
for mod_name, it in abnormal:
lines.append(
f"| {mod_name} | {it.get('name')} | {it.get('value')} | "
f"{it.get('threshold') or '-'} | {it.get('status')} |"
)
lines.append("")
# 各模块明细
lines += ["## 检测明细", ""]
for m in r.get("modules", []):
lines += [f"### {m.get('name')}", "",
"| 检测项 | 当前值 | 阈值 | 状态 |",
"|--------|--------|------|------|"]
for it in m.get("items", []):
lines.append(
f"| {it.get('name')} | {it.get('value')} | "
f"{it.get('threshold') or '-'} | {it.get('status')} |"
)
lines.append("")
return "\n".join(lines)
# -*- coding: utf-8 -*-
"""
runner_service.py — 巡检执行编排
职责:
- 选择执行器(local/SSH)
- 渲染 config、上传资产、逐模块执行
- 生成 SSE 事件流(progress / module_done / finished / error)
- 支持取消运行
运行模型:每次 run_id 独立工作目录与线程,结果累积后落盘。
"""
from __future__ import annotations
import logging
import threading
import time
import traceback
from datetime import datetime
from typing import Dict, Optional
from ..utils.check_modules import get_suite, render_config, CheckModule
from ..utils.parser import parse_module_output, summarize
from ..utils.executor import BaseExecutor
from ..services import target_service, report_service
logger = logging.getLogger("service_monitor.runner")
# 运行中任务注册表(支持取消)
_runs: Dict[str, dict] = {}
_runs_lock = threading.Lock()
def _now() -> str:
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
def _register_run(run_id: str, target_id: str, suite: str, total: int) -> None:
with _runs_lock:
_runs[run_id] = {
"target_id": target_id,
"suite": suite,
"total": total,
"done": 0,
"current": "",
"cancel": threading.Event(),
"finished": False,
"error": None,
}
def _update_run(run_id: str, **kwargs) -> None:
with _runs_lock:
if run_id in _runs:
_runs[run_id].update(kwargs)
def _pop_run(run_id: str) -> Optional[dict]:
with _runs_lock:
return _runs.get(run_id)
def cancel_run(run_id: str) -> bool:
"""请求取消运行。"""
with _runs_lock:
run = _runs.get(run_id)
if not run:
return False
run["cancel"].set()
logger.info("请求取消运行: %s", run_id)
return True
def get_run_status(run_id: str) -> Optional[dict]:
"""获取运行状态(供轮询/SSE 心跳)。"""
with _runs_lock:
run = _runs.get(run_id)
if not run:
return None
return {
"done": run["done"],
"total": run["total"],
"current": run["current"],
"finished": run["finished"],
"error": run["error"],
}
def run_inspection(target_id: str, suite: str):
"""巡检生成器:yield SSE 事件 dict。
事件格式:
{"event": "progress", "run_id", "done", "total", "current"}
{"event": "module_done", "run_id", "module", "items_count", "summary"}
{"event": "finished", "run_id", "report_id", "summary"}
{"event": "error", "run_id", "message"}
{"event": "cancelled", "run_id"}
"""
from datetime import datetime as _dt
run_id = _dt.now().strftime("%Y%m%d_%H%M%S_") + str(threading.get_ident())[-4:]
target = target_service.get_target(target_id)
if not target:
yield {"event": "error", "run_id": run_id, "message": "监测目标不存在"}
return
modules = get_suite(suite)
if not modules:
yield {"event": "error", "run_id": run_id, "message": f"套件 {suite} 无可用模块"}
return
_register_run(run_id, target_id, suite, len(modules))
started_at = _now()
# 准备 config(含解密后的凭据)
target_for_render = dict(target)
target_for_render["credential_overrides"] = target_service.resolve_credentials(target)
config_text = render_config(target_for_render)
executor: Optional[BaseExecutor] = None
modules_result = []
try:
executor = target_service.make_executor(target, run_id)
# 连通性检查(远程)
if not executor.is_local:
yield {"event": "progress", "run_id": run_id, "done": 0, "total": len(modules),
"current": "测试SSH连接..."}
if not executor.test_connection():
yield {"event": "error", "run_id": run_id, "message": "SSH 连接失败,请检查目标配置"}
_update_run(run_id, finished=True, error="SSH 连接失败")
return
yield {"event": "progress", "run_id": run_id, "done": 0, "total": len(modules),
"current": "上传检测模块..."}
executor.setup()
executor.upload_assets()
for idx, module in enumerate(modules):
# 检查取消
run = _pop_run(run_id)
if run and run["cancel"].is_set():
yield {"event": "cancelled", "run_id": run_id}
_update_run(run_id, finished=True)
return
_update_run(run_id, current=module.name, done=idx)
yield {"event": "progress", "run_id": run_id, "done": idx,
"total": len(modules), "current": module.name}
raw = executor.run_module(module, config_text, timeout=90)
items = parse_module_output(raw, module.id, module.category)
summary = summarize(items)
module_result = {
"id": module.id,
"name": module.name,
"category": module.category,
"items": [{"key": it.key, "name": it.name, "value": it.value,
"threshold": it.threshold, "status": it.status}
for it in items],
"summary": summary,
}
modules_result.append(module_result)
_update_run(run_id, done=idx + 1)
yield {"event": "module_done", "run_id": run_id,
"module": module.name, "items_count": len(items), "summary": summary}
report_id = report_service.save(target, suite, modules_result, started_at, _now())
_update_run(run_id, finished=True)
# 汇总
total_summary = {k: 0 for k in ("正常", "警告", "严重", "total")}
for m in modules_result:
ms = m["summary"]
for k in total_summary:
total_summary[k] += ms.get(k, 0)
yield {"event": "finished", "run_id": run_id, "report_id": report_id,
"summary": total_summary}
except Exception as e:
logger.error("巡检执行异常: %s\n%s", e, traceback.format_exc())
_update_run(run_id, finished=True, error=str(e))
yield {"event": "error", "run_id": run_id, "message": f"巡检执行异常: {e}"}
finally:
if executor:
try:
executor.cleanup()
except Exception as e:
logger.warning("执行器清理失败: %s", e)
# 延迟移除运行记录,给客户端最后取状态的机会
time.sleep(0)
# -*- coding: utf-8 -*-
"""service_monitor.tests — 服务监测模块私有测试(独立于 skill/code/tests)"""
# -*- coding: utf-8 -*-
"""
conftest.py — service_monitor 私有测试配置
把 skill/code/web/ 加入 sys.path,让测试可 import service_monitor 包。
测试用独立的 tmp 数据目录,不碰真实 data/。
"""
import sys
from pathlib import Path
# tests/ 位于 web/service_monitor/tests/
TESTS_DIR = Path(__file__).resolve().parent
WEB_DIR = TESTS_DIR.parents[1] # .../skill/code/web
if str(WEB_DIR) not in sys.path:
sys.path.insert(0, str(WEB_DIR))
import pytest # noqa: E402
@pytest.fixture
def tmp_data(tmp_path, monkeypatch):
"""把 service_monitor 的数据目录重定向到 tmp_path,隔离测试。"""
from service_monitor.utils import paths as sm_paths
from service_monitor.services import target_service, report_service
data_dir = tmp_path / "data"
reports_dir = data_dir / "reports"
reports_dir.mkdir(parents=True, exist_ok=True)
targets_file = data_dir / "targets.json"
monkeypatch.setattr(sm_paths, "DATA_DIR", data_dir)
monkeypatch.setattr(sm_paths, "REPORTS_DIR", reports_dir)
monkeypatch.setattr(sm_paths, "TARGETS_FILE", targets_file)
# service 模块在导入时已绑定常量引用,需同步 patch
monkeypatch.setattr(target_service, "TARGETS_FILE", targets_file)
monkeypatch.setattr(report_service, "REPORTS_DIR", reports_dir)
return {"data": data_dir, "reports": reports_dir, "targets": targets_file}
# -*- coding: utf-8 -*-
"""test_check_modules.py — 套件与配置渲染测试"""
from service_monitor.utils import check_modules as cm
def test_suites():
quick = [m.id for m in cm.get_suite("quick")]
full = [m.id for m in cm.get_suite("full")]
assert "01_system_basic" in quick
assert "22_mysql_basic" not in quick # MySQL 仅全量
assert "22_mysql_basic" in full
assert set(quick).issubset(set(full))
def test_get_module():
m = cm.get_module("20_docker_basic")
assert m is not None
assert m.category == "service"
assert m.relpath == "service/20_docker_basic.sh"
assert cm.get_module("nonexistent") is None
def test_render_config_credentials():
target = {"credential_overrides": {"mysql_password": "p@ss'w\"x", "redis_password": "r1"}}
out = cm.render_config(target)
# 密码被 shell 安全引用
assert "MYSQL_PASSWORD=" in out
assert "p@ss" in out # 值存在(引用形式)
# 不应残留占位符
assert "__MYSQL_PASSWORD__" not in out
assert "__REDIS_PASSWORD__" not in out
def test_render_config_container_override():
target = {"container_overrides": {"mysql": "umysql-prod"}}
out = cm.render_config(target)
assert 'CONTAINERS[mysql]="umysql-prod"' in out
def test_render_config_threshold_override():
target = {"thresholds": {"cpu_warning": 90, "evil_key": "x"}}
out = cm.render_config(target)
assert "export CPU_WARNING=90" in out
assert "evil_key" not in out.lower() or "EVIL_KEY" not in out
def test_render_config_injection_safe():
# 注入尝试:值含分号和命令
target = {"credential_overrides": {"mysql_password": "x'; rm -rf /; echo '"}}
out = cm.render_config(target)
# shlex.quote 应把整个值包在单引号里,分号不逃逸
line = [l for l in out.splitlines() if l.startswith("MYSQL_PASSWORD=")][0]
# 危险内容被引用,不作为独立命令
assert "rm -rf" in line # 内容还在,但被引用
assert line.startswith("MYSQL_PASSWORD='") # 以单引号开始包裹
def test_list_asset_files():
files = cm.list_asset_files()
names = [f.name for f in files]
assert "common.sh" in names
assert "config.sh.template" in names
assert "01_system_basic.sh" in names
# -*- coding: utf-8 -*-
"""test_crypto.py — 凭据加密测试"""
import os
from service_monitor.utils import crypto
def test_roundtrip(monkeypatch):
monkeypatch.setenv("MONITOR_ENC_KEY", "abcdefghijklmnopabcdefghijklmnop")
plain = "mySSHpw@123!"
token = crypto.encrypt_password(plain)
assert token.startswith("enc:")
assert crypto.decrypt_password(token) == plain
def test_empty():
assert crypto.encrypt_password("") == ""
assert crypto.decrypt_password("") == ""
def test_derive_from_secret_key(monkeypatch):
monkeypatch.delenv("MONITOR_ENC_KEY", raising=False)
monkeypatch.setenv("SECRET_KEY", "flask-secret-abc")
token = crypto.encrypt_password("pw")
assert crypto.decrypt_password(token) == "pw"
def test_plaintext_compatibility():
# 未加前缀视为历史明文
assert crypto.decrypt_password("rawpassword") == "rawpassword"
assert not crypto.is_encrypted("rawpassword")
assert crypto.is_encrypted("enc:abc")
def test_wrong_key_fails(monkeypatch):
monkeypatch.setenv("MONITOR_ENC_KEY", "abcdefghijklmnopabcdefghijklmnop")
token = crypto.encrypt_password("secret")
monkeypatch.setenv("MONITOR_ENC_KEY", "zyxwvutsrqponmlkjihgfedcba")
try:
crypto.decrypt_password(token)
assert False, "应抛 ValueError"
except ValueError:
pass
# -*- coding: utf-8 -*-
"""test_parser.py — 模块输出解析与状态判定测试"""
from service_monitor.utils.parser import (
parse_module_output, summarize, _should_skip,
NORMAL, WARNING, CRITICAL,
)
def test_parse_basic_kv():
raw = "HOSTNAME:web-01\nCPU_CORES:8\nOS_VERSION:CentOS 7"
items = parse_module_output(raw, "01_system_basic", "system")
assert len(items) == 3
assert items[0].key == "HOSTNAME"
assert items[0].value == "web-01"
assert items[0].name == "主机名"
def test_filter_log_and_error_lines():
raw = (
"[INFO] 开始检测...\n"
"HOSTNAME:web-01\n"
"grep: 警告: 找不到文件\n"
"需要整数表达式\n"
"01_system_basic.sh: 行 88: 语法错误\n"
"ERROR:某某异常\n"
"CPU_CORES:4\n"
"[INFO] 完成"
)
items = parse_module_output(raw, "01_system_basic", "system")
keys = [it.key for it in items]
assert keys == ["HOSTNAME", "CPU_CORES"]
def test_level_field_authoritative():
# 模块自带 _LEVEL 优先
raw = "CPU_USAGE:50%\nCPU_USAGE_LEVEL:严重"
items = parse_module_output(raw, "02_cpu_check", "system")
cpu = [it for it in items if it.key == "CPU_USAGE"][0]
assert cpu.status == CRITICAL
# _LEVEL 字段本身不作为独立项
assert all(not it.key.endswith("_LEVEL") for it in items)
def test_value_status_word_mapping():
raw = "MYSQL_CONNECTED:是\nDOCKER_SERVICE_STATUS:运行中"
items = parse_module_output(raw, "22_mysql_basic", "service")
for it in items:
assert it.status == NORMAL
def test_threshold_numeric_judgement():
# 无 _LEVEL 时用阈值表:CPU_USAGE 阈值 >85%
raw = "CPU_USAGE:92%"
items = parse_module_output(raw, "02_cpu_check", "system")
assert items[0].status in (WARNING, CRITICAL)
raw2 = "CPU_USAGE:30%"
items2 = parse_module_output(raw2, "02_cpu_check", "system")
assert items2[0].status == NORMAL
def test_summarize():
raw = "CPU_USAGE:92%\nCPU_USAGE_LEVEL:严重\nMEMORY_USAGE:50%\nHOSTNAME:x"
items = parse_module_output(raw, "m", "system")
s = summarize(items)
assert s["total"] == 3
assert s[CRITICAL] == 1
def test_should_skip():
assert _should_skip("[INFO] xxx")
assert _should_skip("# comment")
assert _should_skip("command not found: free")
assert not _should_skip("HOSTNAME:web-01")
# -*- coding: utf-8 -*-
"""test_report_service.py — 报告管理测试"""
from service_monitor.services import report_service as rs
def _sample_modules():
return [
{"id": "01_system_basic", "name": "系统基础信息", "category": "system",
"items": [
{"key": "HOSTNAME", "name": "主机名", "value": "web-01", "threshold": "", "status": "正常"},
{"key": "CPU_USAGE", "name": "CPU使用率", "value": "92%", "threshold": ">85%", "status": "严重"},
],
"summary": {"正常": 1, "警告": 0, "严重": 1, "total": 2}},
]
def _sample_target():
return {"id": "local", "name": "本机", "type": "local"}
def test_save_and_get(tmp_data):
rid = rs.save(_sample_target(), "quick", _sample_modules(), "2026-07-16T10:00:00")
r = rs.get_report(rid)
assert r is not None
assert r["target_name"] == "本机"
assert r["summary"]["严重"] == 1
assert r["summary"]["total"] == 2
def test_list_reports(tmp_data):
rs.save(_sample_target(), "quick", _sample_modules(), "2026-07-16T10:00:00")
rs.save(_sample_target(), "full", _sample_modules(), "2026-07-16T11:00:00")
reports = rs.list_reports()
assert len(reports) == 2
# 摘要不含明细
assert "modules" not in reports[0]
def test_delete_report(tmp_data):
rid = rs.save(_sample_target(), "quick", _sample_modules(), "2026-07-16T10:00:00")
assert rs.delete_report(rid) is True
assert rs.get_report(rid) is None
def test_export_markdown(tmp_data):
rid = rs.save(_sample_target(), "quick", _sample_modules(), "2026-07-16T10:00:00")
md = rs.export_markdown(rid)
assert "# 服务巡检报告" in md
assert "CPU使用率" in md
assert "异常项" in md # 有严重项,异常区块存在
def test_export_json(tmp_data):
rid = rs.save(_sample_target(), "quick", _sample_modules(), "2026-07-16T10:00:00")
data = rs.export_json(rid)
assert data["id"] == rid
def test_cleanup_expired(tmp_data):
# 保存一个旧报告(finished_at 很久以前)
rid = rs.save(_sample_target(), "quick", _sample_modules(),
"2020-01-01T10:00:00", "2020-01-01T10:05:00")
removed = rs.cleanup_expired(retention_days=14)
assert removed == 1
assert rs.get_report(rid) is None
此差异已折叠。
# -*- coding: utf-8 -*-
"""service_monitor.utils — 服务监测工具层"""
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
# -*- coding: utf-8 -*-
"""
paths.py — 服务监测模块私有路径常量
注意:本模块独立于全局 utils/paths.py,仅供 service_monitor 子包内部使用。
不在全局路径常量中追加服务监测相关路径,保持代码物理隔离。
"""
from pathlib import Path
# service_monitor/ 子包根目录
MODULE_DIR = Path(__file__).resolve().parent.parent
# 静态资源目录:bash 检测模块
ASSETS_DIR = MODULE_DIR / "assets"
SYSTEM_ASSETS_DIR = ASSETS_DIR / "system"
SERVICE_ASSETS_DIR = ASSETS_DIR / "service"
COMMON_SH = ASSETS_DIR / "common.sh"
CONFIG_TEMPLATE = ASSETS_DIR / "config.sh.template"
# 数据目录:目标列表 + 报告归档
DATA_DIR = MODULE_DIR / "data"
REPORTS_DIR = DATA_DIR / "reports"
TARGETS_FILE = DATA_DIR / "targets.json"
def ensure_dirs() -> None:
"""确保运行时所需目录存在(首次启动调用)。"""
DATA_DIR.mkdir(parents=True, exist_ok=True)
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论