diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..21db30598d51a87d45e1b37dfc367daaccc25f2b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# 测试产物(由 command-execute-docker.sh 运行时生成) +result/ +logs/cronie/ +logs/vim/ +# 通用:忽略各待测包独立日志目录(按需添加) +logs/*/ +!logs/.ts.sysdir/ +!logs/testcase/ diff --git a/DOCKER_TEST_README.md b/DOCKER_TEST_README.md new file mode 100644 index 0000000000000000000000000000000000000000..6390538a0807dd505db7d5601e291f0fc83db2d6 --- /dev/null +++ b/DOCKER_TEST_README.md @@ -0,0 +1,152 @@ +# 基于 Docker 的软件包并发功能测试(command-execute-docker.sh) + +本脚本对应 issue:【2026犀牛鸟issue】基于 docker 对软件包进行并发测试。 +复用 `tst_lib/ts_common.sh` 的底层判定逻辑与结果格式,将原有的 QEMU 串行测试改造为 +Docker 容器并发测试,为每个待测包启动独立容器,在容器内完成 +「安装 → 命令验证 → 服务识别 → 卸载」全流程,测试后自动销毁容器。 + +--- + +## 1. 使用方法 + +```bash +# 基本用法 +bash command-execute-docker.sh <包列表文件> [桌面白名单文件] [服务包列表文件] [-j 并发数] + +# 示例 +bash command-execute-docker.sh pkg-to-test.list desktop-white.pkg service-white.pkg -j 8 +``` + +### 参数说明 + +| 参数 | 含义 | 必填 | +|------|------|------| +| `$1` 包列表文件 | 每行一个待测软件包名(如 `vim` / `curl.x86_64`) | 是 | +| `$2` 桌面白名单文件 | 命中则跳过该包的命令测试 | 否 | +| `$3` 服务包列表文件 | 命中则服务启动标记为 skipped(需特殊配置) | 否 | +| `-j N` | 并发容器数,默认 4(必须为正整数) | 否 | +| `-t SEC` | 容器拉起阶段超时秒数,默认 600(各步命令另有独立 timeout) | 否 | +| `-i SEC` | `yum install` 超时秒数,默认 300 | 否 | +| `-r SEC` | 卸载超时秒数,默认 120 | 否 | +| `-m MEM` | 单容器内存上限(可选,默认不限制) | 否 | +| `--image NAME` | 测试镜像,默认 `ocos-test-image` | 否 | + +### 输入文件格式 + +- **包列表文件**:纯文本,每行一个包名,支持 `#` 注释与空行; + **兼容 Windows CRLF**(`\r` 会在读取时剔除)。 +- **桌面白名单文件**:纯文本,每行一个包名,精确匹配(整行相等) +- **服务包列表文件**:纯文本,每行一个包名,精确匹配 + +--- + +## 2. 结果输出 + +脚本在 `result/` 与 `logs/` 下产出: + +1. **逐包明细** `result/package-command-result-docker-<时间戳>.txt` + 格式为 TSV(制表符分隔),共 6 列,与现有 `package-command-result-*.txt` 对齐并新增 `service` 列: + + ``` + # source-package binary_package install command service remove + vim-9.0.2092-21.oc9.src.rpm vim-enhanced-9.0.2092-21.oc9.x86_64 PASS PASS PASS PASS + cronie-1.6.1-6.oc9.src.rpm cronie-1.6.1-6.oc9.x86_64 PASS PASS SKIPPED PASS + ``` + + 列含义:`source-package`(源码包名) / `binary_package`(实际安装的主包名) / + `install` / `command` / `service` / `remove`,取值 `PASS` / `FAIL` / `SKIP` / `SKIPPED`。 + +2. **分类汇总** `result/docker-test-summary-<时间戳>.txt` + 按 issue 验收标准格式输出:安装失败 / 卸载失败 / 命令测试失败 / 服务测试失败 / 跳过项, + 每类含**包名与真实失败/跳过原因**(从原因明细文件提取),并给出 + 「通过 + 失败 + 跳过 = 待测总数」对账。 + +3. **原因明细** `result/package-command-reason-docker-<时间戳>.txt` + 独立记录每个包的安装/命令/服务/跳过/卸载五类原因文本,供汇总报告输出(不影响 6 列明细兼容性)。 + +4. **按包日志** `logs/<包名>/install.log`、`remove.log` 等,便于排查单包失败原因。 + +> 说明:每次运行开始时会**先清理上一轮的 `result/` 与 `logs/`**(删除历史),确保结果只反映本轮运行;单轮内部文件仍以时间戳命名区分明细与汇总。 + +--- + +## 3. 关键设计 + +### 3.1 并发模型 +- 使用 `xargs -P N` 实现容器级并发,同时运行 N 个独立容器。 +- 每个待测包在**独立容器**中测试,互不污染(解决原 QEMU 串行共享环境的隔离问题)。 +- 单容器失败(`install` / `command` / `remove` 任一步 FAIL)**不影响其他并发任务**。 +- 逐包结果先写入独立临时文件,最后统一汇总,并对明细文件追加加 `flock` 锁,避免竞态导致对账不准。 +- 并发容器 stdout 通过 `flock` 串行化输出(fd 8 锁文件),避免多包日志交错难以阅读。 +- 数值参数(`-j/-t/-i/-r`)强制正整数校验,避免 `xargs -P 0` / 非法 `timeout` 等误用。 + +### 3.2 容器级超时与资源限制 +- **容器拉起超时**:`docker run` 阶段被 `-t` 指定的 `timeout` 包裹,拉起失败会强制清理, + 不拖垮整轮。 +- **分步超时**:包管理器安装(`-i`,自动识别 `yum`/`dnf`)、`rpm -e` 卸载(`-r`)各自独立超时;服务测试的 + `systemctl daemon-reload/enable/start/is-active/stop` 与 `is-system-running` 探测 + 均有 `timeout` 保护(20~30s),避免挂死。 +- **资源限制**:默认不限制内存;`-m MEM` 可选传入(注意限制可能干扰特权 systemd 容器内 + 服务启动)。`--cpus` 已移除(会破坏 systemd 容器内服务状态机)。 + +### 3.3 安装 / 卸载判定 +- 安装成功以包管理器(`yum`/`dnf` 自动探测)`install` 返回 0 且 `rpm -q` 二次校验通过为准。 +- 卸载使用 `rpm -e --nodeps <主包名>` 精确删除目标主包,避免 yum 连带移除受保护依赖 + (如 `python3-dnf`)造成误判 FAIL;隔离容器内操作不影响宿主。 +- 主包名提取优先级:用户精确名 > `<名>-enhanced` > `<名>-core` > 模糊匹配首个非依赖子包, + 避免把 `cronie-anacron` / `vim-data` 等依赖子包误当主包。 + +### 3.4 命令测试 +- 安装成功且**不在桌面白名单**时,收集包内 ELF 可执行文件(参考 `ts_common.sh` 的 + `get_elf_executable` 思路,容器内执行),排除 `.so`、`/nde-` 等。 +- **使用已安装的真实主包名**(`pkg_bin`,如 `vim-enhanced`)列文件,而非原始输入名 + (如 `vim`),避免原始名不是已安装 rpm 名时漏测命令。 +- ELF 判定用 **ELF magic 字节**(`head -c4 | grep -q $'\x7fELF'`),不依赖容器内 + `file` 命令是否存在(最小化镜像可能缺 `file`)。 +- 对每个二进制尝试 `--version` / `--help` / `version`,任一成功即视为命令可用; + **仅在命令不存在(127)、无法执行/无权限(126)或崩溃(139 段错误 / 134 abort)时记命令测试 FAIL**。 + 其余非 0 退出码(如交互式/需参数的命令 `crontab` 不带参数返回 1)视为命令可正常执行, + 不误报 FAIL——包级功能测试以「命令存在且能执行」为准,而非逐项功能正确性。 + +### 3.5 服务测试(真实 start/stop) +- **容器启动**:每个测试容器以 `--privileged --cgroupns=host -v /sys/fs/cgroup:/sys/fs/cgroup:rw + --tmpfs /run --tmpfs /tmp` 启动 `/sbin/init`,使 **systemd 作为 PID1** 在容器内运行 + (已实测 `is-system-running` 返回 `running`,可正常执行 `systemctl`)。 +- **识别**:安装后通过 `rpm -ql | grep -E '^/(usr/)?lib/systemd/system/.*\.(service|socket|target)$'`(兼容 OpenCloudOS 9 usrmerge:`/lib` 为 `/usr/lib` 软链,`rpm -ql` 报 `/lib/systemd/system/...`) + 识别随包提供的 systemd unit,并复用 `ts_common.sh` 的过滤规则排除内置 service + (`systemd-*.service`、`initrd*`、`man-db-cache-update.service`、`run-*.service` 等)。 + **优先用已安装主包名 `pkg_bin` 列文件,缺失时回退原始包名**,提升 unit 识别覆盖率。 +- **EnvironmentFile 注入**:部分 RHEL 系服务(如 `cronie` 的 `crond.service`)的 `ExecStart` + 引用 `$VAR`(如 `$CRONDARGS`)但未在 unit 内声明 `EnvironmentFile`,systemd 不会展开 + 未声明变量而将其作为字面量传入,导致启动失败。脚本在 `start` 前通过 **drop-in** + (`/etc/systemd/system/.d/10-env.conf`,内容 `EnvironmentFile=-/etc/sysconfig/` + 或 `/etc/default/`)注入常见环境文件,使变量正确展开后服务可真实启动。 +- **命令测试范围**:命令测试仅对 `/usr/bin`、`/bin` 下的 ELF 可执行文件执行 + `--version/--help/version` 探测,**排除 `/sbin`、`/usr/sbin` 下的守护进程二进制** + (如 `crond`),避免把 daemon 当普通命令误启动、残留冲突而干扰后续服务测试。 +- **测试流程**(对每个 unit):`systemctl daemon-reload` → `enable` → `start` → + `is-active` → `stop`,各步输出写入 `logs/<包名>/service__start.log` / + `service__stop.log`,即 issue 要求的**原始 start/status/stop 报告**。 +- **判定**: + - 命中**服务包列表**(参数3)的包 → `SKIPPED`(原因:需特殊配置,issue 明确跳过)。 + - 容器内 systemd 未就绪(环境限制)→ `SKIPPED`(原因:systemd 未就绪)。 + - 真实测试 `start` 成功 + `is-active=active` + `stop` 成功 → `PASS`。 + - systemd 已就绪但 `start`/`is-active`/`stop` 任意一步失败 → **判定为真实服务缺陷 `FAIL`**, + 并附 `start/active/stop` 退出码原因(使真实服务缺陷能被检出,而非被掩盖)。 +- **局限**:仅当容器内 systemd 因环境限制(网络命名空间/资源)无法就绪时才保守记 `SKIPPED`; + 服务自身缺陷一律记 `FAIL`。 + +--- + +## 4. 与现有用例的兼容性 +- 脚本为**独立新文件** `command-execute-docker.sh`,不修改、不破坏现有 QEMU 版 + `command-execute-whitelist.sh` 及任何已有用例。 +- 结果格式与现有 `package-command-result-*.txt` 保持一致(新增 `service` 列),便于 CI 对接。 + +--- + +## 5. 环境要求 +- WSL2 + Docker(已安装)。 +- 测试镜像 `ocos-test-image`(由 `Dockerfile.test` 构建,基于 `opencloudos/opencloudos:9`, + 含 `dnf-utils`、`yum` 等工具)。 +- `tst_lib/ts_common.sh` 存在(脚本会 source 以复用底层判定函数)。 diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000000000000000000000000000000000000..8e81ed3b61c2d158523d822f562c49a203e812d7 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,31 @@ +# Dockerfile.test +# 基于 OpenCloudOS 9 构建测试镜像 + +FROM opencloudos/opencloudos:9 + +# 设置时区和语言 +ENV LANG=C.UTF-8 + +# 安装测试必需工具 +RUN yum install -y \ + dnf-utils \ + rpm-build \ + file \ + which \ + coreutils \ + findutils \ + grep \ + sed \ + gawk \ + procps-ng \ + systemd \ + && yum clean all + +# 创建工作目录 +RUN mkdir -p /test/logs /test/results + +# 设置工作目录 +WORKDIR /test + +# 默认命令 +CMD ["/bin/bash"] diff --git a/command-execute-docker.sh b/command-execute-docker.sh new file mode 100644 index 0000000000000000000000000000000000000000..cfbb3476307f71521473f5945342f923d6293fd8 --- /dev/null +++ b/command-execute-docker.sh @@ -0,0 +1,584 @@ +#!/bin/bash +# ============================================================================ +# command-execute-docker.sh +# 基于 Docker 对软件包进行并发功能测试(安装/卸载、命令、服务) +# +# 对应 issue: 【2026犀牛鸟issue】基于docker对软件包进行并发测试 +# 复用 tst_lib/ts_common.sh 中的底层判定逻辑与结果格式约定。 +# +# 参数: +# $1 待测软件包列表文件 (每行一个包名,如 vim / curl.x86_64) +# $2 桌面白名单文件(可选) (命中则跳过该包的命令测试) +# $3 含服务的软件包列表(可选) (命中则服务启动标记为 skipped) +# 可选参数: +# -j N 并发容器数,默认 4 +# -t SEC 容器拉起阶段超时秒数,默认 600 +# (各步 docker exec 命令另有独立 timeout 保护) +# -i SEC yum install 超时秒数,默认 300 +# -r SEC yum remove 超时秒数,默认 120 +# -m MEM 单容器内存上限 (可选, 默认不限制) +# --image NAME 测试镜像,默认 ocos-test-image +# +# 结果输出: +# result/package-command-result-docker-<时间戳>.txt 逐包 TSV 明细 +# result/docker-test-summary-<时间戳>.txt 分类汇总 + 对账 +# logs/<包名>/ 按包分目录日志 +# +# 说明(issue 要求的方案): +# 本脚本采用「每包独立容器(特权 + systemd)」策略。每个容器以 +# --privileged --cgroupns=host 启动 /sbin/init,使 systemd 作为 PID1 运行, +# 从而可在容器内真实执行 systemctl start / is-active / stop 服务测试。 +# 命中服务列表(参数3)的包按 issue 视为「需特殊配置」标记 SKIPPED; +# 其余含 systemd unit 的包做真实 start/stop 测试,成功记 PASS; +# 若容器内 systemd 未就绪(环境限制)则保守记 SKIPPED 并附原因; +# 若 systemd 已就绪但 start/is-active/stop 仍失败,则判定为真实服务缺陷记 FAIL。 +# ============================================================================ + +set -o pipefail + +# ----------------------------- 路径与默认参数 ----------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# ts_common.sh 通过 TST_TS_TOPDIR 定位同级 other_common.sh,必须导出 +export TST_TS_TOPDIR="$SCRIPT_DIR" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/tst_lib/ts_common.sh" 2>/dev/null || true + +PKG_LIST_FILE="" +WHITELIST_FILE="" +SERVICE_LIST_FILE="" +MAX_JOBS=4 +PKG_TIMEOUT=600 +INSTALL_TIMEOUT=300 +REMOVE_TIMEOUT=120 +CONTAINER_MEM="" # 默认不限制单容器内存(限制会干扰特权 systemd 容器内服务启动) +DOCKER_IMAGE="ocos-test-image" + +RESULT_DIR="${SCRIPT_DIR}/result" +LOG_BASE="${SCRIPT_DIR}/logs" +TIMESTAMP="$(date '+%Y%m%d-%H%M%S')" +DETAIL_FILE="${RESULT_DIR}/package-command-result-docker-${TIMESTAMP}.txt" +SUMMARY_FILE="${RESULT_DIR}/docker-test-summary-${TIMESTAMP}.txt" +# 原因明细(独立于 6 列明细,供汇总报告输出真实失败/跳过原因) +REASON_FILE="${RESULT_DIR}/package-command-reason-docker-${TIMESTAMP}.txt" +# 并发输出串行锁文件(避免多容器 echo 交错) +OUT_LOCK="${RESULT_DIR}/.out.lock" + +# ----------------------------- usage ----------------------------- +usage() { + cat < [桌面白名单文件] [服务包列表文件] [-j 并发数] + + $1 待测软件包列表(每行一个包名) + $2 桌面白名单(命中跳过命令测试,可省略) + $3 服务包列表(命中服务启动标记 skipped,可省略) + +可选: + -j N 并发容器数 (默认 4) + -t SEC 容器拉起阶段超时 (默认 600, 各步命令另有独立 timeout) + -i SEC install 超时 (默认 300) + -r SEC remove 超时 (默认 120) + -m MEM 单容器内存上限 (可选, 默认不限制; 注意限制可能干扰特权 systemd 容器内服务启动) + --image NAME 测试镜像 (默认 ocos-test-image) +EOF + exit 1 +} + +# ----------------------------- 参数解析 ----------------------------- +while [ $# -gt 0 ]; do + case "$1" in + -j) MAX_JOBS="$2"; shift 2 ;; + -t) PKG_TIMEOUT="$2"; shift 2 ;; + -i) INSTALL_TIMEOUT="$2"; shift 2 ;; + -r) REMOVE_TIMEOUT="$2"; shift 2 ;; + -m) CONTAINER_MEM="$2"; shift 2 ;; + --image) DOCKER_IMAGE="$2"; shift 2 ;; + -*) echo "未知选项: $1"; usage ;; + *) + if [ -z "$PKG_LIST_FILE" ]; then PKG_LIST_FILE="$1" + elif [ -z "$WHITELIST_FILE" ]; then WHITELIST_FILE="$1" + elif [ -z "$SERVICE_LIST_FILE" ]; then SERVICE_LIST_FILE="$1" + else echo "警告: 忽略多余参数: $1"; fi + shift ;; + esac +done + +# 数值参数合法性校验(必须为正整数,避免 xargs -P 0 / timeout 非法值) +for _v in MAX_JOBS PKG_TIMEOUT INSTALL_TIMEOUT REMOVE_TIMEOUT; do + _val="${!_v}" + [[ "$_val" =~ ^[1-9][0-9]*$ ]] || { echo "错误: -${_v,,} 必须为正整数,当前: '$_val'"; usage; } +done + +[ -z "$PKG_LIST_FILE" ] && { echo "错误: 必须指定包列表文件"; usage; } +[ -f "$PKG_LIST_FILE" ] || { echo "错误: 包列表文件不存在: $PKG_LIST_FILE"; exit 1; } + +if [ -z "$WHITELIST_FILE" ] || [ ! -f "$WHITELIST_FILE" ]; then + WHITELIST_FILE="/dev/null" +fi +if [ -z "$SERVICE_LIST_FILE" ] || [ ! -f "$SERVICE_LIST_FILE" ]; then + SERVICE_LIST_FILE="/dev/null" +fi + +# ----------------------------- 校验 docker ----------------------------- +command -v docker >/dev/null 2>&1 || { echo "错误: 未找到 docker 命令"; exit 1; } + +# ----------------------------- 准备目录 ----------------------------- +# Ctrl+C/终止时清理可能残留的测试容器 +cleanup_containers() { + docker ps -a -q -f 'name=^pkg_' 2>/dev/null | xargs -r docker rm -f >/dev/null 2>&1 || true +} +trap cleanup_containers INT TERM +# 每次运行先清理上一轮的结果与日志(删除历史),确保结果只反映本轮运行 +rm -rf "$RESULT_DIR" "$LOG_BASE" +mkdir -p "$RESULT_DIR" "$LOG_BASE" +# 初始化输出文件(截断,非追加) +echo -e "# source-package\tbinary_package\tinstall\tcommand\tservice\tremove" >"$DETAIL_FILE" +: >"$REASON_FILE" +: >"$OUT_LOCK" +# 主流程(非并发部分)也打开输出锁 fd 8,使 emit 在主流程可用且安全 +# 必须在首个 emit 调用(镜像不存在时的报错)之前打开,否则 flock -x 8 报 Bad file descriptor +exec 8>>"$OUT_LOCK" + +docker image inspect "$DOCKER_IMAGE" >/dev/null 2>&1 || { + emit "错误: 测试镜像不存在: $DOCKER_IMAGE (请先按指南 4.3/4.4 构建)"; exit 1; } + +# 读取包列表(跳过空行与注释行;兼容 Windows CRLF:后续对每个包名 tr -d '\r') +mapfile -t PACKAGES < <(grep -v '^\s*$' "$PKG_LIST_FILE" | grep -v '^\s*#') +TOTAL_PKG=${#PACKAGES[@]} +[ "$TOTAL_PKG" -eq 0 ] && { echo "错误: 包列表为空"; exit 1; } + +echo "待测包总数: $TOTAL_PKG" +echo "并发数: $MAX_JOBS | 单包超时: ${PKG_TIMEOUT}s | 镜像: $DOCKER_IMAGE" + +# ----------------------------- 工具函数 ----------------------------- +is_in_whitelist() { + local pkg=$1 + grep -qxF "$pkg" "$WHITELIST_FILE" 2>/dev/null +} +is_in_service_list() { + local pkg=$1 + grep -qxF "$pkg" "$SERVICE_LIST_FILE" 2>/dev/null +} + +# 转义包名用于容器名 +safe_name() { echo "$1" | tr -c 'A-Za-z0-9' '_' | cut -c1-60; } + +# 把单个包的结果追加到明细文件(加 flock 防并发交错) +write_detail() { + local src=$1 bin=$2 inst=$3 cmd=$4 svc=$5 rm=$6 + ( + flock 9 + echo -e "${src}\t${bin}\t${inst}\t${cmd}\t${svc}\t${rm}" >>"$DETAIL_FILE" + ) 9>>"$DETAIL_FILE" +} + +# 把单个包的真实失败/跳过原因追加到原因明细文件(供汇总报告输出真实原因) +# 列: bin install_reason cmd_reason service_reason skip_reason remove_reason +write_reason() { + local bin=$1 ireason=$2 creason=$3 sreason=$4 skreason=$5 rmreason=$6 + ( + flock 9 + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$bin" "$ireason" "$creason" "$sreason" "$skreason" "$rmreason" >>"$REASON_FILE" + ) 9>>"$REASON_FILE" +} + +# 串行化并发输出(避免多容器 echo 交错):依赖调用方已打开 fd 8 +emit() { flock -x 8; printf '%s\n' "$*"; flock -u 8; } + +# ----------------------------- 核心:测试单个包 ----------------------------- +test_one_package() { + local pkg_name="$1" + local pkg_index="$2" + + pkg_name="$(echo "$pkg_name" | tr -d '\r' | xargs)" + [ -z "$pkg_name" ] && return 0 + + local cname="pkg_${pkg_index}_$(safe_name "$pkg_name")" + # 日志目录用 safe_name,避免原始包名含空格/特殊字符导致路径异常 + local pkg_log_dir="${LOG_BASE}/$(safe_name "$pkg_name")" + mkdir -p "$pkg_log_dir" + + # 结果初值 + local install_result="PASS" remove_result="PASS" + local cmd_result="PASS" service_result="SKIPPED" + local skip_reason="" install_reason="" remove_reason="" + local cmd_reason="" service_reason="" + local pkg_bin="$pkg_name" pkg_src="" + + emit "[${pkg_index}/${TOTAL_PKG}] 开始: $pkg_name" + + # ---- 步骤0:拉起容器(特权 + systemd 作为 PID1,整体超时包裹) ---- + docker rm -f "$cname" >/dev/null 2>&1 + # -m 传了才限制内存(限制可能干扰特权 systemd 容器内服务启动,故默认不限制) + local mem_opt="" + [ -n "$CONTAINER_MEM" ] && mem_opt="--memory='${CONTAINER_MEM}'" + timeout "$PKG_TIMEOUT" bash -c " + docker run -d --privileged --cgroupns=host \ + --name '$cname' \ + $mem_opt \ + -v /sys/fs/cgroup:/sys/fs/cgroup:rw \ + --tmpfs /run --tmpfs /tmp \ + '$DOCKER_IMAGE' /sbin/init >/dev/null 2>&1 + " + if ! docker ps -q -f "name=^${cname}$" | grep -q .; then + install_result="FAIL" + install_reason="容器启动失败(超时或资源不足)" + write_detail "$pkg_src" "$pkg_bin" "FAIL" "SKIP" "SKIP" "SKIP" + write_reason "$pkg_bin" "$install_reason" "" "" "" "" + emit " [结果] 容器启动失败: $pkg_name" + docker rm -f "$cname" >/dev/null 2>&1 + return 1 + fi + # 等待容器就绪(docker exec 可用) + local ready=0 + for _ in $(seq 1 20); do + if docker exec "$cname" true >/dev/null 2>&1; then ready=1; break; fi + sleep 1 + done + if [ "$ready" -ne 1 ]; then + install_result="FAIL" + install_reason="容器未就绪" + write_detail "$pkg_src" "$pkg_bin" "FAIL" "SKIP" "SKIP" "SKIP" + write_reason "$pkg_bin" "$install_reason" "" "" "" "" + emit " [结果] 容器未就绪: $pkg_name" + docker rm -f "$cname" >/dev/null 2>&1 + return 1 + fi + + # 等待 systemd 作为 PID1 完全启动(避免后续服务测试时 startup job 未完成, + # 导致 start 后 unit 仍处于 activating 状态) + local sys_ready=0 + for _ in $(seq 1 30); do + if [ "$(docker exec "$cname" timeout 20 systemctl is-system-running 2>/dev/null)" = "running" ]; then + sys_ready=1; break + fi + sleep 1 + done + if [ "$sys_ready" -ne 1 ]; then + emit " [警告] 容器内 systemd 未在 30s 内完全就绪,服务测试可能受限" + fi + + # ---- 步骤1:安装 ---- + # 探测容器内包管理器(兼容纯 dnf 环境,避免硬编码 yum) + local pm="yum" + if docker exec "$cname" command -v dnf >/dev/null 2>&1; then pm="dnf"; fi + emit " [安装] $pkg_name (包管理器: $pm)" + docker exec "$cname" bash -c "timeout ${INSTALL_TIMEOUT} ${pm} install -y ${pkg_name}" \ + >"${pkg_log_dir}/install.log" 2>&1 + local install_exit=$? + + if [ $install_exit -ne 0 ]; then + # 网络/仓库临时故障重试一次,降低误报 FAIL + if grep -qiE 'timeout|Temporary failure|Cannot retrieve|Could not retrieve|Connection (timed out|refused)|HTTP (error|50[0-9])|Failed to (download|connect)' "${pkg_log_dir}/install.log"; then + emit " [安装] 检测到网络/仓库临时故障,重试一次" + docker exec "$cname" bash -c "timeout ${INSTALL_TIMEOUT} ${pm} install -y ${pkg_name}" \ + >"${pkg_log_dir}/install.log" 2>&1 + install_exit=$? + fi + fi + if [ $install_exit -ne 0 ]; then + install_result="FAIL" + install_reason="${pm} install 返回码=${install_exit}" + if grep -qi "conflicts" "${pkg_log_dir}/install.log"; then + install_reason="包冲突: $(grep -i 'conflicts' "${pkg_log_dir}/install.log" | head -1)" + elif grep -qiE "Nothing to do|No match|not found|No package" "${pkg_log_dir}/install.log"; then + install_reason="包不存在或无可用版本" + fi + fi + # 二次校验:yum 成功后提取真实包名做 rpm -q 验证 + if [ "$install_result" = "PASS" ]; then + # 候选主包名优先级:精确名 > 名-enhanced > 名-core > 名(模糊首个非依赖包) + # 避免把 cronie-anacron / vim-data 等依赖子包误当主包 + pkg_bin="$(docker exec "$cname" bash -c " + q() { rpm -qa --qf '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}' \"\$1\" 2>/dev/null | head -1; } + if [ -n \"\$(q ${pkg_name})\" ]; then + q ${pkg_name} + elif [ -n \"\$(q ${pkg_name}-enhanced)\" ]; then + q ${pkg_name}-enhanced + elif [ -n \"\$(q ${pkg_name}-core)\" ]; then + q ${pkg_name}-core + else + rpm -qa --qf '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}\n' \"${pkg_name}*\" 2>/dev/null \ + | grep -vE '-(libs|common|filesystem|data|devel|doc|minimal|help|aux|utils|anacron)-' \ + | head -1 + fi + ")" + if [ -z "$pkg_bin" ] || ! docker exec "$cname" bash -c "rpm -q ${pkg_bin} >/dev/null 2>&1"; then + install_result="FAIL" + install_reason="安装后 rpm -q 验证失败" + else + pkg_src="$(docker exec "$cname" bash -c "rpm -qi ${pkg_bin} 2>/dev/null | grep -i 'Source RPM' | head -1 | awk '{print \$NF}'")" + fi + fi + + # ---- 步骤2:命令测试(安装成功且不在桌面白名单) ---- + if [ "$install_result" = "PASS" ]; then + if is_in_whitelist "$pkg_name"; then + cmd_result="SKIP" + skip_reason="桌面白名单, 跳过命令测试" + emit " [命令] $pkg_name 命中白名单,跳过" + else + emit " [命令测试] $pkg_name" + # 收集 ELF 可执行文件:用 ELF magic 判断,避免依赖容器内 file 命令是否存在。 + # 用已安装的真实主包 pkg_bin 列文件(原始包名未必是已安装 rpm 名) + local binaries + binaries="$(docker exec "$cname" bash -c " + rpm -ql ${pkg_bin} 2>/dev/null \ + | grep -E '^/(usr/bin|bin)/' \ + | grep -vE '\.so(\.[0-9]+)?\$|/nde-' \ + | while read -r f; do + [ -x \"\$f\" ] && [ -f \"\$f\" ] && head -c4 \"\$f\" 2>/dev/null | LC_ALL=C grep -q \$'\\x7fELF' && echo \"\$f\" + done + " 2>/dev/null)" + + if [ -z "$binaries" ]; then + emit " 包内无 ELF 命令,跳过命令测试" + else + local cmd_fail_found=false + while IFS= read -r bin; do + [ -z "$bin" ] && continue + # 任一探测(--version/--help/version)成功即视为命令可用 + docker exec "$cname" bash -c \ + "timeout 10 ${bin} --version >/dev/null 2>&1 || timeout 10 ${bin} --help >/dev/null 2>&1 || timeout 10 ${bin} version >/dev/null 2>&1" + local code=$? + # 命令不存在(127)、不可执行/无权限(126)、崩溃(139 段错误/134 abort) 视为命令损坏 → FAIL; + # 其余非0(如交互/需参数命令 crontab 不带参数返回1)视为命令可正常执行,避免误报 + if [ $code -eq 127 ]; then + cmd_fail_found=true + cmd_reason="${cmd_reason}${bin}: 命令不存在(127); " + elif [ $code -eq 126 ]; then + cmd_fail_found=true + cmd_reason="${cmd_reason}${bin}: 无法执行/无权限(126); " + elif [ $code -eq 139 ] || [ $code -eq 134 ]; then + cmd_fail_found=true + cmd_reason="${cmd_reason}${bin}: 异常退出(${code}); " + fi + done <<< "$binaries" + if [ "$cmd_fail_found" = true ]; then + cmd_result="FAIL" + fi + fi + fi + fi + + # ---- 步骤3:服务测试(systemd 容器内真实 start/stop/status) ---- + if [ "$install_result" = "PASS" ]; then + if is_in_service_list "$pkg_name"; then + service_result="SKIPPED" + service_reason="命中服务列表(需特殊配置), 跳过服务启动" + if [ -n "$skip_reason" ]; then + skip_reason="${skip_reason}; ${service_reason}" + else + skip_reason="$service_reason" + fi + emit " [服务] $pkg_name 命中服务列表,标记 SKIPPED" + else + # 识别包内 systemd unit(参考 ts_common.sh 过滤规则排除内置 service)。 + # 优先用已安装主包 pkg_bin 列文件,缺失时回退原始包名,提升识别覆盖率 + local unit_filter="grep -E '^/(usr/)?lib/systemd/system/.*\.(service|socket|target)\$' | grep -vE '@|systemd-\.+\.service|initrd.*\.service|anaconda.*\.service|coreos.*\.service|run-[a-z0-9]{33}\.service|man-db-cache-update.service|rc-local.service|nvidia-persistenced.service|NetworkManager-wait-online.service|systemd-fsck.*\.service|selinux-autorelabel.service'" + local services="" + for _svc_pkg in "${pkg_bin}" "${pkg_name}"; do + services="$(docker exec "$cname" bash -c "rpm -ql ${_svc_pkg} 2>/dev/null | ${unit_filter}" 2>/dev/null)" + [ -n "$services" ] && break + done + if [ -z "$services" ]; then + service_result="PASS" + emit " [服务] $pkg_name 无 systemd unit" + else + # 确认容器内 systemd 已作为 PID1 就绪 + local sys_running + sys_running="$(docker exec "$cname" timeout 20 systemctl is-system-running 2>/dev/null)" + if [ "$sys_running" != "running" ]; then + service_result="SKIPPED" + service_reason="容器内 systemd 未就绪(${sys_running}), 服务启动 skipped" + if [ -n "$skip_reason" ]; then + skip_reason="${skip_reason}; ${service_reason}" + else + skip_reason="$service_reason" + fi + emit " [服务] $pkg_name 容器内 systemd 未就绪,标记 SKIPPED" + else + local svc_fail_found=false + while IFS= read -r unit_path; do + [ -z "$unit_path" ] && continue + local unit_name; unit_name="$(basename "$unit_path")" + emit " 测试服务: ${unit_name}" + docker exec "$cname" timeout 20 systemctl daemon-reload 2>/dev/null + docker exec "$cname" timeout 20 systemctl enable "$unit_name" >/dev/null 2>&1 + # 兼容 RHEL 系服务:部分 unit 的 ExecStart 引用 $VAR(如 crond 的 + # $CRONDARGS)但未在 unit 内声明 EnvironmentFile,systemd 不会展开 + # 未声明变量而将其作为字面量传入,导致启动失败。这里通过 drop-in + # 注入常见 sysconfig/default 环境文件,使变量正确展开后可真实启动 + local svc_base="${unit_name%.service}" + local drop_dir="/etc/systemd/system/${unit_name}.d" + for envf in "/etc/sysconfig/${svc_base}" "/etc/default/${svc_base}"; do + if docker exec "$cname" [ -f "$envf" ]; then + docker exec "$cname" bash -c "mkdir -p '${drop_dir}' && printf '[Service]\nEnvironmentFile=-${envf}\n' > '${drop_dir}/10-env.conf'" + docker exec "$cname" timeout 20 systemctl daemon-reload 2>/dev/null + fi + done + docker exec "$cname" timeout 30 systemctl start "$unit_name" \ + >"${pkg_log_dir}/service_${unit_name}_start.log" 2>&1 + local start_exit=$? + # 轮询等待服务进入 active(systemd 启动瞬间处于 activating, + # 最多等待 15 秒,避免误把可启动的服务判为 SKIPPED) + local is_active="inactive" + for _i in $(seq 1 15); do + is_active="$(docker exec "$cname" timeout 20 systemctl is-active "$unit_name" 2>/dev/null)" + [ "$is_active" = "active" ] && break + sleep 1 + done + docker exec "$cname" timeout 30 systemctl stop "$unit_name" \ + >"${pkg_log_dir}/service_${unit_name}_stop.log" 2>&1 + local stop_exit=$? + # systemd 已就绪(running)但 start/is-active/stop 任意一步失败, + # 视为真实服务缺陷 → 标记 FAIL 并附原因(不再保守降级为 SKIPPED) + if [ $start_exit -ne 0 ] || [ "$is_active" != "active" ] || [ $stop_exit -ne 0 ]; then + svc_fail_found=true + service_reason="${service_reason}${unit_name}: start=${start_exit}, active=${is_active}, stop=${stop_exit}; " + fi + done <<< "$services" + if [ "$svc_fail_found" = true ]; then + service_result="FAIL" + if [ -z "$skip_reason" ]; then + skip_reason="$service_reason" + else + skip_reason="${skip_reason}; ${service_reason}" + fi + emit " [服务] $pkg_name 服务 start/stop 失败,标记 FAIL" + else + service_result="PASS" + emit " [服务] $pkg_name 服务 start/stop 成功" + fi + fi + fi + fi + else + service_result="SKIP" + fi + + # ---- 步骤4:卸载(仅当安装成功) ---- + if [ "$install_result" = "PASS" ]; then + emit " [卸载] $pkg_name" + # 用 rpm -e --nodeps 精确卸载目标包本身(使用提取到的真实主包名 pkg_bin), + # 避免连带移除受保护依赖(如 python3-dnf),隔离容器内操作安全 + docker exec "$cname" bash -c "timeout ${REMOVE_TIMEOUT} rpm -e --nodeps ${pkg_bin}" \ + >"${pkg_log_dir}/remove.log" 2>&1 + local remove_exit=$? + if [ $remove_exit -ne 0 ]; then + remove_result="FAIL" + remove_reason="rpm -e --nodeps 返回码=${remove_exit}" + if grep -iqw "protected packages" "${pkg_log_dir}/remove.log"; then + remove_result="PASS" + remove_reason="" + fi + fi + if [ "$remove_result" = "PASS" ]; then + if docker exec "$cname" bash -c "rpm -q ${pkg_bin}" >/dev/null 2>&1; then + remove_result="FAIL" + remove_reason="${remove_reason} rpm -q 仍能找到包" + fi + fi + else + remove_result="SKIP" + fi + + # ---- 步骤5:清理容器 ---- + docker rm -f "$cname" >/dev/null 2>&1 + + # ---- 写入明细 ---- + if [ "$install_result" = "FAIL" ] || [ "$cmd_result" = "FAIL" ] || [ "$service_result" = "FAIL" ] || [ "$remove_result" = "FAIL" ]; then + emit " [结果] ❌ 失败 (安装:$install_result 命令:$cmd_result 服务:$service_result 卸载:$remove_result)" + elif [ -n "$skip_reason" ]; then + emit " [结果] ⏭ 跳过 ($skip_reason)" + else + emit " [结果] ✅ 通过" + fi + write_detail "$pkg_src" "$pkg_bin" "$install_result" "$cmd_result" "$service_result" "$remove_result" + write_reason "$pkg_bin" "$install_reason" "$cmd_reason" "$service_reason" "$skip_reason" "$remove_reason" +} + +# ----------------------------- 并发调度 ----------------------------- +export -f test_one_package is_in_whitelist is_in_service_list safe_name write_detail write_reason emit +export TOTAL_PKG WHITELIST_FILE SERVICE_LIST_FILE DETAIL_FILE LOG_BASE REASON_FILE OUT_LOCK \ + PKG_TIMEOUT INSTALL_TIMEOUT REMOVE_TIMEOUT CONTAINER_MEM DOCKER_IMAGE + +# 用 xargs 实现并发(天然限制并行数,单任务失败不影响其他) +printf '%s\n' "${PACKAGES[@]}" | nl -ba -v1 | while IFS=$'\t' read -r idx pkg; do + emit "$idx $pkg" +done | xargs -P "$MAX_JOBS" -I {} bash -c ' + exec 8>>"$OUT_LOCK" + idx="${1%% *}"; pkg="${1#* }" + test_one_package "$pkg" "$idx" +' _ {} + +# ----------------------------- 汇总报告 ----------------------------- +# 用 awk 一次性读取逐包明细并分类,避免子 shell 变量丢失问题 +# 同时从原因明细文件提取真实失败/跳过原因,使汇总含原因(满足验收标准) +awk -F'\t' -v maxjobs="$MAX_JOBS" -v total="$TOTAL_PKG" -v reasonfile="$REASON_FILE" ' +BEGIN { + PASS=0; FAIL=0; SKIP=0 + while ((getline line < reasonfile) > 0) { + n=split(line, f, "\t") + if (n < 6) continue + reason[f[1]] = f[2] "\t" f[3] "\t" f[4] "\t" f[5] "\t" f[6] + } + close(reasonfile) +} +/^#/ { next } +NF < 6 { next } +{ + src=$1; bin=$2; inst=$3; cmd=$4; svc=$5; rm=$6 + r_inst=""; r_cmd=""; r_svc=""; r_skip=""; r_rm="" + if (bin in reason) { + k=split(reason[bin], rf, "\t") + r_inst=rf[1]; r_cmd=rf[2]; r_svc=rf[3]; r_skip=rf[4]; r_rm=rf[5] + } + if (inst == "FAIL") { + FAIL++; fail_install[fail_install_n++] = bin " (原因: " r_inst ")" + } else if (rm == "FAIL") { + FAIL++; fail_remove[fail_remove_n++] = bin " (原因: " r_rm ")" + } else if (cmd == "FAIL") { + FAIL++; fail_cmd[fail_cmd_n++] = bin " (原因: " r_cmd ")" + } else if (svc == "FAIL") { + FAIL++; fail_svc[fail_svc_n++] = bin " (原因: " r_svc ")" + } else if (svc == "SKIPPED" || cmd == "SKIP") { + SKIP++; skip_items[skip_n++] = bin " (服务:" svc "; 命令:" cmd "; 跳过原因:" r_skip ")" + } else { + PASS++ + } +} +END { + printf("========== 测试失败汇总 (%s) ==========\n", strftime("%Y-%m-%d %H:%M:%S")) + printf("环境: docker | 并发度: %s | 待测包总数: %s | 通过: %d | 失败: %d | 跳过: %d\n", maxjobs, total, PASS, FAIL, SKIP) + print "" + printf("【安装失败】(%d)\n", fail_install_n) + for (i=0;i