diff --git a/testcase/README-command-execute-chroot.md b/testcase/README-command-execute-chroot.md new file mode 100644 index 0000000000000000000000000000000000000000..b2e689619622dae3386d6739cfc80523863a230a --- /dev/null +++ b/testcase/README-command-execute-chroot.md @@ -0,0 +1,101 @@ +# chroot 并发软件包测试 + +`command-execute-chroot.sh` 为每个软件包创建独立 chroot,执行“安装 → 命令验证 → +服务识别 → 卸载”,不修改现有 `command-execute-whitelist.sh` 用例。脚本需要 root 权限, +并用 `-j` 控制同时运行的 chroot 数量。 + +## 根文件系统与国内软件源 + +推荐在与被测系统相同版本的 OpenCloudOS rootfs 上运行,并通过 `--repo-dir` 指定该 +版本的 `.repo` 文件。测试过程只使用这些 repo,不会自动切换或混用其他软件源。 +国内 CI 可将 repo 的 `baseurl` 配置为腾讯镜像,例如: + +```ini +[BaseOS] +name=OpenCloudOS BaseOS +baseurl=https://mirrors.tencent.com/opencloudos/9/BaseOS/$basearch/os/ +enabled=1 +gpgcheck=1 +gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-OpenCloudOS-9 +``` + +若不指定 `--base-rootfs`,脚本使用宿主机 `dnf --installroot` 创建一次基础 rootfs, +所以宿主机须有与 repo 配套的 RPM GPG key。也可以先从 OpenCloudOS 国内官方镜像站 +获取匹配的容器/rootfs 并解压到目录,然后使用 `--base-rootfs /path/to/rootfs`。外部 +base 始终作为只读下层使用,不会被包测试修改。 + +## 使用 + +三个位置参数依次为待测软件包列表、桌面白名单、需特殊配置的服务包列表。列表忽略 +空行和 `#` 注释,取每行第一列;白名单按安装后的 RPM `NAME` 精确匹配。 + +```bash +sudo ./command-execute-chroot.sh \ + -j 8 \ + --base-rootfs /srv/rootfs/opencloudos-9.6 \ + --repo-dir /srv/repos/opencloudos-9.6 \ + --stage-timeout 300 \ + --package-timeout 1200 \ + --output-dir ./chroot-results \ + pkg.list desktop-white.pkg service-white.pkg +``` + +常用选项: + +- `-j N`:并发 chroot 数; +- `--base-rootfs DIR`:与被测系统匹配的基础 rootfs;省略时用 DNF 创建; +- `--repo-dir DIR`:写入每个测试 root 的 YUM/DNF repo 配置; +- `--releasever VERSION`:准备基础 rootfs 时使用的 releasever; +- `--root-mode auto|overlay|copy`:优先使用 overlay,失败时自动回退 reflink/copy; +- `--stage-timeout`:单次安装、命令依赖安装或卸载的超时; +- `--package-timeout`:覆盖 root 创建、挂载、测试、卸载和清理的单包硬超时; +- `--bootstrap-timeout`:DNF 准备基础 rootfs 的超时; +- `--work-dir`:临时 chroot 所在目录,默认 `/var/tmp`;为确保 `findmnt` 校验无歧义, + 规范化后的路径仅允许字母、数字、`_`、`.`、`/`、`-`; +- `--output-dir`:报告目录,不得与本次临时 chroot 目录相同或互相嵌套。 + +## 隔离与挂载 + +每个包拥有独立的 overlay upper/work/merged;宿主不支持 overlay 时使用 +`cp --reflink=auto`,再回退普通复制。各实例只读绑定 worker、桌面/服务列表和 +`/etc/resolv.conf`,只可写自己的包级日志目录。`/dev`、`/proc`、`/sys` 以 rbind +方式挂载并设为 slave,避免卸载传播到宿主。正常退出、超时、INT/TERM/HUP 都会先终止 +包级进程 session,再递归卸载;只有 `findmnt` 确认没有残留挂载后才删除临时 root。 +卸载或确认失败时保留目录并将该包标为失败,避免跨挂载点删除宿主内容。单包失败不会停止 +其他任务。 + +## 命令与服务结论 + +命令测试复用 `tst_lib/ts_common.sh` 的判定语义:筛选 `/usr/bin`、`/usr/sbin` 下的 +ELF,依次尝试 `--help`、`--version`,最后用 `strace` 检查 `execve`。桌面白名单包 +的命令阶段记录为 `SKIP`。 + +worker 会从 RPM 文件列表识别 `/usr/lib/systemd/system`、`/lib/systemd/system`、 +`/etc/systemd/system` 下的 `.service`、`.socket`、`.target`。普通 chroot 没有 +独立 systemd/PID 1,调用 `systemctl start` 可能连接不到服务管理器或产生假成功, +因此本实现不冒充可测环境:对检测到的每个 unit 明确记录 +`start=SKIP status/is-active=SKIP stop=SKIP`,原因为“chroot 无独立 systemd PID 1”。 +template unit 另以“需要实例名和配置”为原因记录 `SKIP`。参数 3 中的包记录为“需特殊 +配置”的 `SKIP`。不含 unit 的包服务阶段为 `PASS`。 + +该限制意味着本脚本验证安装、命令、unit 识别和卸载,但不验证真实服务进程启动; +需要 start/status/stop 实测时应使用带 systemd PID 1 的虚拟机或 init 容器用例。 + +## 结果 + +总报告为 `package-command-result-chroot-*.txt`,包含: + +```text +source-package / binary_package / install / command / service / remove +``` + +报告按安装失败、卸载失败、命令失败、服务失败、跳过项分类,并记录包名和原因。包级 +计数按“任一阶段失败优先,其次任一阶段跳过,否则通过”,且强制对账: + +```text +通过 + 失败 + 跳过 = 待测包总数 +``` + +每个包的 `install.log`、命令日志、`systemd-units.list`、`service.log`、`remove.log` +和 `chroot.log` 保存在本次输出目录的 `packages/` 下;正常情况下临时 root 不保留,挂载 +无法安全卸载时会保留并报告路径以供人工处理。 diff --git a/testcase/command-execute-chroot.sh b/testcase/command-execute-chroot.sh new file mode 100755 index 0000000000000000000000000000000000000000..cb53ebcc5d754b52f9e098f8cecad688bcab3a55 --- /dev/null +++ b/testcase/command-execute-chroot.sh @@ -0,0 +1,810 @@ +#!/bin/bash +############################################################################### +# Concurrent package install/command/service/remove checks using isolated chroots. +############################################################################### + +set -u +set -o pipefail + +g_script_dir="$(cd "$(dirname "$0")" && pwd)" +g_worker="$g_script_dir/lib/chroot-package-worker.sh" +g_jobs=4 +g_stage_timeout=300 +g_package_timeout=1200 +g_bootstrap_timeout=1200 +g_output_root="$(pwd)" +g_repo_dir="/etc/yum.repos.d" +g_releasever="9" +g_base_root_input="" +g_base_root="" +g_root_mode="auto" +g_work_parent="/var/tmp" +g_work_root="" +g_run_id="yum-ci-chroot-$(date '+%Y%m%d%H%M%S')-$$" +g_run_dir="" +g_current_package_root="" +g_dnf_bin="${YUM_CI_DNF_BIN:-dnf}" +g_chroot_bin="${YUM_CI_CHROOT_BIN:-chroot}" +g_mount_bin="${YUM_CI_MOUNT_BIN:-mount}" +g_umount_bin="${YUM_CI_UMOUNT_BIN:-umount}" +g_findmnt_bin="${YUM_CI_FINDMNT_BIN:-findmnt}" +g_pids=() +g_started_pid="" +g_tracking_critical=0 +g_pending_exit=0 + +usage() { + cat <<'EOF' +Usage: + command-execute-chroot.sh [options] + +Required arguments: + package-list Packages to test, one package spec per line + desktop-whitelist Package names whose binary command test is skipped + special-service-list Package names whose service startup is skipped + +Options: + -j, --jobs N Concurrent chroots (default: 4) + --stage-timeout SEC Timeout for each install/service/remove operation (default: 300) + --package-timeout SEC Full-lifecycle timeout for one chroot (default: 1200) + --bootstrap-timeout SEC Timeout for preparing a base rootfs (default: 1200) + --base-rootfs DIR Reuse an existing matching rootfs as read-only base + --repo-dir DIR Tested-system .repo files (default: /etc/yum.repos.d) + --releasever VERSION DNF releasever when preparing a base (default: 9) + --root-mode MODE auto, overlay, or copy (default: auto) + --work-dir DIR Temporary chroot parent (default: /var/tmp) + --output-dir DIR Report and per-package log parent (default: current directory) + -h, --help Show this help + +Root privileges are required for chroot and bind mounts. Each package gets an +independent overlay or copied root. Because a chroot has no dedicated systemd +PID 1, detected units record start/status/stop as explicit SKIP results. +EOF +} + +die() { + echo "ERROR: $*" >&2 + exit 2 +} + +is_positive_integer() { + [[ "$1" =~ ^[1-9][0-9]*$ ]] +} + +normalize_list() { + local source_file="$1" + local target_file="$2" + local deduplicate="$3" + + if [ "$deduplicate" = "yes" ]; then + sed 's/\r$//; s/[[:space:]]*#.*$//' "$source_file" | + awk 'NF && !seen[$1]++ { print $1 }' >"$target_file" + else + sed 's/\r$//; s/[[:space:]]*#.*$//' "$source_file" | + awk 'NF { print $1 }' >"$target_file" + fi +} + +safe_slug() { + printf '%s' "$1" | sed 's/[^A-Za-z0-9_.-]/_/g' | cut -c1-48 +} + +write_synthetic_result() { + local package_spec="$1" + local package_dir="$2" + local reason="$3" + reason="$(printf '%s' "$reason" | tr '\t\r\n' ' ' | sed 's/[[:space:]][[:space:]]*/ /g' | cut -c1-500)" + printf '%s\tUNKNOWN\t%s\tFAIL\tSKIP\tSKIP\tSKIP\t%s\tinstallation did not complete\tinstallation did not complete\tinstallation did not complete\tFAIL\n' \ + "$package_spec" "$package_spec" "$reason" >"$package_dir/result.tsv" +} + +validate_base_root() { + local root="$1" + + [ -x "$root/bin/bash" ] || die "base rootfs has no executable /bin/bash: $root" + [ -x "$root/usr/bin/rpm" ] || [ -x "$root/bin/rpm" ] || + die "base rootfs has no rpm: $root" + [ -x "$root/usr/bin/dnf" ] || [ -x "$root/usr/bin/yum" ] || + die "base rootfs has neither dnf nor yum: $root" +} + +path_has_mounts() { + local path="$1" + local targets + local target + + if ! targets="$("$g_findmnt_bin" -rn -o TARGET 2>/dev/null)"; then + echo "ERROR: cannot verify mount state below $path" >&2 + return 0 + fi + while IFS= read -r target; do + case "$target" in + "$path" | "$path"/*) return 0 ;; + esac + done <<<"$targets" + return 1 +} + +install_repo_configuration() { + local root="$1" + + mkdir -p "$root/etc/yum.repos.d" + rm -f "$root/etc/yum.repos.d/"*.repo + cp -a "$g_repo_dir/"*.repo "$root/etc/yum.repos.d/" +} + +prepare_base_root() { + if [ -n "$g_base_root_input" ]; then + g_base_root="$(realpath "$g_base_root_input")" + validate_base_root "$g_base_root" + return 0 + fi + + g_base_root="$g_work_root/base-rootfs" + mkdir -p "$g_base_root/etc/yum.repos.d" + if [ -d /etc/pki/rpm-gpg ]; then + mkdir -p "$g_base_root/etc/pki" + cp -a /etc/pki/rpm-gpg "$g_base_root/etc/pki/" + fi + + echo "Preparing OpenCloudOS base rootfs with releasever=$g_releasever" + timeout --signal=TERM --kill-after=10 "${g_bootstrap_timeout}s" \ + "$g_dnf_bin" -y --installroot="$g_base_root" --releasever="$g_releasever" \ + --setopt="reposdir=$g_repo_dir" --setopt=install_weak_deps=False \ + --setopt=keepcache=False install \ + bash coreutils dnf rpm file strace grep sed gawk findutils util-linux \ + >"$g_run_dir/base-rootfs-bootstrap.log" 2>&1 || + die "base rootfs preparation failed; see $g_run_dir/base-rootfs-bootstrap.log" + install_repo_configuration "$g_base_root" + validate_base_root "$g_base_root" +} + +detect_root_mode() { + local requested="$g_root_mode" + local probe="$g_work_root/overlay-probe" + local overlay_failed=0 + + [ "$requested" != "copy" ] || return 0 + mkdir -p "$probe/lower" "$probe/upper" "$probe/work" "$probe/merged" + : >"$probe/lower/probe" + if "$g_mount_bin" -t overlay overlay \ + -o "lowerdir=$probe/lower,upperdir=$probe/upper,workdir=$probe/work" \ + "$probe/merged" >/dev/null 2>&1; then + "$g_umount_bin" "$probe/merged" >/dev/null 2>&1 || true + g_root_mode="overlay" + elif [ "$requested" = "overlay" ]; then + overlay_failed=1 + else + g_root_mode="copy" + fi + if path_has_mounts "$probe"; then + die "overlay probe could not be safely unmounted; retained under $probe" + fi + rm -rf -- "$probe" + [ "$overlay_failed" -eq 0 ] || die "overlay root mode was requested but an overlay mount failed" +} + +unmount_package_tree() { + local merged="$1" + local target + + # A copied root is not itself a mountpoint, so recursively unmount each + # known top-level bind before trying the overlay/copy root as a whole. + for target in \ + "$merged/test-output" \ + "$merged/test-input/service.list" \ + "$merged/test-input/desktop.list" \ + "$merged/test-input/chroot-package-worker.sh" \ + "$merged/etc/resolv.conf" \ + "$merged/sys" \ + "$merged/proc" \ + "$merged/dev" \ + "$merged"; do + "$g_umount_bin" -R -l "$target" >/dev/null 2>&1 || true + done +} + +cleanup_package_root() { + local root_dir="$1" + + [ -n "$root_dir" ] || return 0 + case "$root_dir" in + "$g_work_root"/packages/*) ;; + *) return 1 ;; + esac + unmount_package_tree "$root_dir/merged" + if path_has_mounts "$root_dir"; then + echo "ERROR: chroot mounts remain; refusing to remove $root_dir" >&2 + return 1 + fi + rm -rf -- "$root_dir" +} + +cleanup_current_package_root() { + local root_dir + + [ -n "$g_current_package_root" ] || return 0 + root_dir="$g_current_package_root" + g_current_package_root="" + cleanup_package_root "$root_dir" +} + +cleanup_all_roots() { + local root_dir + local cleanup_failed=0 + + [ -n "$g_work_root" ] || return 0 + if [ -d "$g_work_root/packages" ]; then + while IFS= read -r root_dir; do + cleanup_package_root "$root_dir" || cleanup_failed=1 + done < <(find "$g_work_root/packages" -mindepth 1 -maxdepth 1 -type d -print) + fi + if path_has_mounts "$g_work_root"; then + echo "ERROR: mounts remain under $g_work_root; temporary root was retained" >&2 + return 1 + fi + if [ "$cleanup_failed" -ne 0 ]; then + echo "ERROR: one or more chroot roots could not be cleaned under $g_work_root" >&2 + return 1 + fi + rm -rf -- "$g_work_root" +} + +terminate_package_sessions() { + local exit_code="$1" + local pid + local still_running + + trap '' INT TERM HUP + for pid in "${g_pids[@]-}"; do + pkill -TERM -s "$pid" >/dev/null 2>&1 || kill -TERM "$pid" >/dev/null 2>&1 || true + done + for pid in "${g_pids[@]-}"; do + wait "$pid" 2>/dev/null || true + done + + for _ in $(seq 1 50); do + still_running=0 + for pid in "${g_pids[@]-}"; do + if pgrep -s "$pid" >/dev/null 2>&1; then + still_running=1 + break + fi + done + [ "$still_running" -eq 1 ] || break + sleep 0.1 + done + + for pid in "${g_pids[@]-}"; do + pkill -KILL -s "$pid" >/dev/null 2>&1 || kill -KILL "$pid" >/dev/null 2>&1 || true + done + cleanup_all_roots + exit "$exit_code" +} + +handle_parent_signal() { + g_pending_exit="$1" + if [ "$g_tracking_critical" -eq 0 ]; then + terminate_package_sessions "$g_pending_exit" + fi +} + +package_relative_dir() { + local index="$1" + local package_spec="$2" + local slug + + slug="$(safe_slug "$package_spec")" + [ -n "$slug" ] || slug="package" + printf '%05d-%s' "$index" "$slug" +} + +bind_read_only() { + local source_path="$1" + local target_path="$2" + + "$g_mount_bin" --bind "$source_path" "$target_path" || return 1 + "$g_mount_bin" -o remount,bind,ro "$target_path" || return 1 +} + +setup_package_root() { + local root_dir="$1" + local package_dir="$2" + local root="$root_dir/merged" + + mkdir -p "$root_dir/upper" "$root_dir/work" "$root" + if [ "$g_root_mode" = "overlay" ]; then + "$g_mount_bin" -t overlay overlay \ + -o "lowerdir=$g_base_root,upperdir=$root_dir/upper,workdir=$root_dir/work" \ + "$root" || return 1 + else + cp -a --reflink=auto "$g_base_root/." "$root/" || return 1 + fi + + install_repo_configuration "$root" || return 1 + mkdir -p "$root/dev" "$root/proc" "$root/sys" "$root/test-input" \ + "$root/test-output" "$root/etc" + rm -f "$root/etc/resolv.conf" + : >"$root/etc/resolv.conf" + : >"$root/test-input/chroot-package-worker.sh" + : >"$root/test-input/desktop.list" + : >"$root/test-input/service.list" + + "$g_mount_bin" --rbind /dev "$root/dev" || return 1 + "$g_mount_bin" --make-rslave "$root/dev" || return 1 + "$g_mount_bin" --rbind /proc "$root/proc" || return 1 + "$g_mount_bin" --make-rslave "$root/proc" || return 1 + "$g_mount_bin" --rbind /sys "$root/sys" || return 1 + "$g_mount_bin" --make-rslave "$root/sys" || return 1 + bind_read_only /etc/resolv.conf "$root/etc/resolv.conf" || return 1 + bind_read_only "$g_worker" "$root/test-input/chroot-package-worker.sh" || return 1 + bind_read_only "$g_run_dir/input/desktop.list" "$root/test-input/desktop.list" || return 1 + bind_read_only "$g_run_dir/input/service.list" "$root/test-input/service.list" || return 1 + "$g_mount_bin" --bind "$package_dir" "$root/test-output" || return 1 +} + +run_package_controller() { + local index="$1" + local package_spec="$2" + local relative_dir + local package_dir + local runtime_log + local root_dir + local rc=0 + + relative_dir="$(package_relative_dir "$index" "$package_spec")" + package_dir="$g_run_dir/packages/$relative_dir" + runtime_log="$package_dir/chroot.log" + root_dir="$g_work_root/packages/$relative_dir" + mkdir -p "$package_dir" + printf '%s\n' "$package_spec" >"$package_dir/requested-package.txt" + : >"$runtime_log" + + g_current_package_root="$root_dir" + if setup_package_root "$root_dir" "$package_dir" >>"$runtime_log" 2>&1; then + "$g_chroot_bin" "$root_dir/merged" /bin/bash \ + /test-input/chroot-package-worker.sh \ + "$package_spec" /test-input/desktop.list \ + /test-input/service.list /test-output \ + "$g_stage_timeout" >>"$runtime_log" 2>&1 + rc=$? + else + rc=125 + fi + if ! cleanup_current_package_root >>"$runtime_log" 2>&1; then + write_synthetic_result "$package_spec" "$package_dir" \ + "chroot cleanup failed; mounts or temporary root were retained; see chroot.log" + return 0 + fi + + if [ ! -s "$package_dir/result.tsv" ]; then + if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then + write_synthetic_result "$package_spec" "$package_dir" "chroot worker timed out after ${g_package_timeout}s" + else + write_synthetic_result "$package_spec" "$package_dir" "chroot worker exited with status $rc; see chroot.log" + fi + fi + return 0 +} + +supervise_package() { + local index="$1" + local package_spec="$2" + local relative_dir + local package_dir + local rc + + relative_dir="$(package_relative_dir "$index" "$package_spec")" + package_dir="$g_run_dir/packages/$relative_dir" + mkdir -p "$package_dir" + printf '%s\n' "$package_spec" >"$package_dir/requested-package.txt" + + timeout --signal=TERM --kill-after=5 "${g_package_timeout}s" \ + /bin/bash "$0" --internal-run-package "$index" "$package_spec" + rc=$? + + if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ] || [ "$rc" -eq 143 ]; then + write_synthetic_result "$package_spec" "$package_dir" "chroot lifecycle timeout after ${g_package_timeout}s" + elif [ ! -s "$package_dir/result.tsv" ]; then + write_synthetic_result "$package_spec" "$package_dir" "chroot controller exited with status $rc; see chroot.log" + fi +} + +track_started_package() { + g_pids+=("$g_started_pid") +} + +start_package() { + local index="$1" + local package_spec="$2" + + g_tracking_critical=1 + env \ + YUM_CI_INTERNAL_RUN_DIR="$g_run_dir" \ + YUM_CI_INTERNAL_RUN_ID="$g_run_id" \ + YUM_CI_INTERNAL_STAGE_TIMEOUT="$g_stage_timeout" \ + YUM_CI_INTERNAL_PACKAGE_TIMEOUT="$g_package_timeout" \ + YUM_CI_INTERNAL_REPO_DIR="$g_repo_dir" \ + YUM_CI_INTERNAL_BASE_ROOT="$g_base_root" \ + YUM_CI_INTERNAL_ROOT_MODE="$g_root_mode" \ + YUM_CI_INTERNAL_WORK_ROOT="$g_work_root" \ + YUM_CI_INTERNAL_CHROOT_BIN="$g_chroot_bin" \ + YUM_CI_INTERNAL_MOUNT_BIN="$g_mount_bin" \ + YUM_CI_INTERNAL_UMOUNT_BIN="$g_umount_bin" \ + YUM_CI_INTERNAL_FINDMNT_BIN="$g_findmnt_bin" \ + setsid /bin/bash "$0" --internal-supervise-package "$index" "$package_spec" & + g_started_pid=$! + track_started_package + g_tracking_critical=0 + if [ "$g_pending_exit" -ne 0 ]; then + terminate_package_sessions "$g_pending_exit" + fi +} + +remove_tracked_pid() { + local completed_pid="$1" + local pid + local remaining=() + + for pid in "${g_pids[@]}"; do + [ "$pid" = "$completed_pid" ] || remaining+=("$pid") + done + g_pids=("${remaining[@]}") +} + +wait_for_finished_package() { + local completed_pid="" + local pid + + if [ "${BASH_VERSINFO[0]}" -gt 5 ] || + { [ "${BASH_VERSINFO[0]}" -eq 5 ] && [ "${BASH_VERSINFO[1]}" -ge 1 ]; }; then + wait -n -p completed_pid "${g_pids[@]}" || true + if [ -n "$completed_pid" ]; then + remove_tracked_pid "$completed_pid" + return 0 + fi + fi + + while :; do + for pid in "${g_pids[@]}"; do + if ! kill -0 "$pid" 2>/dev/null; then + wait "$pid" 2>/dev/null || true + remove_tracked_pid "$pid" + return 0 + fi + done + sleep 0.05 + done +} + +status_is_valid() { + case "$1" in + PASS | FAIL | SKIP) return 0 ;; + *) return 1 ;; + esac +} + +append_category() { + local file="$1" + local package_name="$2" + local reason="$3" + printf ' - %-35s 原因: %s\n' "$package_name" "$reason" >>"$file" +} + +aggregate_results() { + local report_file="$1" + local rows_file="$g_run_dir/report.rows" + local install_fail="$g_run_dir/install-fail.list" + local remove_fail="$g_run_dir/remove-fail.list" + local command_fail="$g_run_dir/command-fail.list" + local service_fail="$g_run_dir/service-fail.list" + local skipped="$g_run_dir/skipped.list" + local pass_count=0 + local fail_count=0 + local skip_count=0 + local index=0 + local package_spec + + : >"$rows_file" + : >"$install_fail" + : >"$remove_fail" + : >"$command_fail" + : >"$service_fail" + : >"$skipped" + + while IFS= read -r package_spec; do + local slug relative_dir result_file fields + local requested source binary install command service remove + local install_reason command_reason service_reason remove_reason _worker_overall overall display_name + local skip_reason="" + index=$((index + 1)) + slug="$(safe_slug "$package_spec")" + [ -n "$slug" ] || slug="package" + relative_dir="$(printf '%05d-%s' "$index" "$slug")" + result_file="$g_run_dir/packages/$relative_dir/result.tsv" + fields="$(awk -F '\t' 'NR == 1 { print NF }' "$result_file" 2>/dev/null || true)" + if [ "$fields" != "12" ]; then + write_synthetic_result "$package_spec" "$g_run_dir/packages/$relative_dir" "malformed or missing worker result" + fi + IFS=$'\t' read -r requested source binary install command service remove \ + install_reason command_reason service_reason remove_reason _worker_overall <"$result_file" + + if ! status_is_valid "$install" || ! status_is_valid "$command" || + ! status_is_valid "$service" || ! status_is_valid "$remove"; then + requested="$package_spec" + source="UNKNOWN" + binary="$package_spec" + install="FAIL" + command="SKIP" + service="SKIP" + remove="SKIP" + install_reason="worker returned an invalid stage status" + command_reason="installation did not complete" + service_reason="installation did not complete" + remove_reason="installation did not complete" + fi + + if [ "$install" = "FAIL" ] || [ "$command" = "FAIL" ] || + [ "$service" = "FAIL" ] || [ "$remove" = "FAIL" ]; then + overall="FAIL" + fail_count=$((fail_count + 1)) + elif [ "$install" = "SKIP" ] || [ "$command" = "SKIP" ] || + [ "$service" = "SKIP" ] || [ "$remove" = "SKIP" ]; then + overall="SKIP" + skip_count=$((skip_count + 1)) + else + overall="PASS" + pass_count=$((pass_count + 1)) + fi + + display_name="$binary" + [ "$display_name" != "UNKNOWN" ] || display_name="$requested" + printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$source" "$binary" "$install" "$command" "$service" "$remove" >>"$rows_file" + + [ "$install" != "FAIL" ] || append_category "$install_fail" "$display_name" "$install_reason" + [ "$remove" != "FAIL" ] || append_category "$remove_fail" "$display_name" "$remove_reason" + [ "$command" != "FAIL" ] || append_category "$command_fail" "$display_name" "$command_reason" + [ "$service" != "FAIL" ] || append_category "$service_fail" "$display_name" "$service_reason" + if [ "$overall" = "SKIP" ]; then + [ "$install" != "SKIP" ] || skip_reason="install: $install_reason" + [ "$command" != "SKIP" ] || skip_reason="${skip_reason:+$skip_reason; }command: $command_reason" + [ "$service" != "SKIP" ] || skip_reason="${skip_reason:+$skip_reason; }service: $service_reason" + [ "$remove" != "SKIP" ] || skip_reason="${skip_reason:+$skip_reason; }remove: $remove_reason" + append_category "$skipped" "$display_name" "$skip_reason" + fi + done <"$g_run_dir/input/packages.list" + + { + printf '# source-package\tbinary_package\tinstall\tcommand\tservice\tremove\n' + cat "$rows_file" + echo + printf '========== 测试失败汇总 (%s) ==========\n' "$(date '+%Y-%m-%d %H:%M:%S')" + printf '环境: chroot | 并发度: %d | 待测包总数: %d | 通过: %d | 失败: %d | 跳过: %d\n\n' \ + "$g_jobs" "$g_total" "$pass_count" "$fail_count" "$skip_count" + print_category "安装失败" "$install_fail" + print_category "卸载失败" "$remove_fail" + print_category "命令测试失败" "$command_fail" + print_category "服务测试失败" "$service_fail" + print_category "跳过项" "$skipped" + echo "=======================================================" + } >"$report_file" + + if [ $((pass_count + fail_count + skip_count)) -ne "$g_total" ]; then + echo "ERROR: reconciliation failed: PASS + FAIL + SKIP != total" >&2 + return 2 + fi + cat "$report_file" + echo "Report: $report_file" + [ "$fail_count" -eq 0 ] +} + +print_category() { + local title="$1" + local file="$2" + local count + count="$(wc -l <"$file")" + printf '【%s】(%d)\n' "$title" "$count" + if [ "$count" -eq 0 ]; then + echo " (无)" + else + cat "$file" + fi + echo +} + +load_internal_environment() { + g_run_dir="${YUM_CI_INTERNAL_RUN_DIR:?}" + g_run_id="${YUM_CI_INTERNAL_RUN_ID:?}" + g_stage_timeout="${YUM_CI_INTERNAL_STAGE_TIMEOUT:?}" + g_package_timeout="${YUM_CI_INTERNAL_PACKAGE_TIMEOUT:?}" + g_repo_dir="${YUM_CI_INTERNAL_REPO_DIR:?}" + g_base_root="${YUM_CI_INTERNAL_BASE_ROOT:?}" + g_root_mode="${YUM_CI_INTERNAL_ROOT_MODE:?}" + g_work_root="${YUM_CI_INTERNAL_WORK_ROOT:?}" + g_chroot_bin="${YUM_CI_INTERNAL_CHROOT_BIN:?}" + g_mount_bin="${YUM_CI_INTERNAL_MOUNT_BIN:?}" + g_umount_bin="${YUM_CI_INTERNAL_UMOUNT_BIN:?}" + g_findmnt_bin="${YUM_CI_INTERNAL_FINDMNT_BIN:?}" +} + +if [ "${1:-}" = "--internal-supervise-package" ]; then + [ "$#" -eq 3 ] || die "invalid internal supervisor invocation" + load_internal_environment + supervise_package "$2" "$3" + exit 0 +fi + +if [ "${1:-}" = "--internal-run-package" ]; then + [ "$#" -eq 3 ] || die "invalid internal package invocation" + load_internal_environment + trap cleanup_current_package_root EXIT + trap 'exit 130' INT + trap 'exit 143' TERM HUP + run_package_controller "$2" "$3" + exit 0 +fi + +while [ "$#" -gt 0 ]; do + case "$1" in + -j | --jobs) + [ "$#" -ge 2 ] || die "$1 requires a value" + g_jobs="$2" + shift 2 + ;; + --stage-timeout) + [ "$#" -ge 2 ] || die "$1 requires a value" + g_stage_timeout="$2" + shift 2 + ;; + --package-timeout) + [ "$#" -ge 2 ] || die "$1 requires a value" + g_package_timeout="$2" + shift 2 + ;; + --bootstrap-timeout) + [ "$#" -ge 2 ] || die "$1 requires a value" + g_bootstrap_timeout="$2" + shift 2 + ;; + --base-rootfs) + [ "$#" -ge 2 ] || die "$1 requires a value" + g_base_root_input="$2" + shift 2 + ;; + --releasever) + [ "$#" -ge 2 ] || die "$1 requires a value" + g_releasever="$2" + shift 2 + ;; + --root-mode) + [ "$#" -ge 2 ] || die "$1 requires a value" + g_root_mode="$2" + shift 2 + ;; + --repo-dir) + [ "$#" -ge 2 ] || die "$1 requires a value" + g_repo_dir="$2" + shift 2 + ;; + --work-dir) + [ "$#" -ge 2 ] || die "$1 requires a value" + g_work_parent="$2" + shift 2 + ;; + --output-dir) + [ "$#" -ge 2 ] || die "$1 requires a value" + g_output_root="$2" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + --) + shift + break + ;; + -*) die "unknown option: $1" ;; + *) break ;; + esac +done + +[ "$#" -eq 3 ] || { + usage >&2 + exit 2 +} + +g_package_file="$1" +g_desktop_file="$2" +g_service_file="$3" + +is_positive_integer "$g_jobs" || die "jobs must be a positive integer" +is_positive_integer "$g_stage_timeout" || die "stage timeout must be a positive integer" +is_positive_integer "$g_package_timeout" || die "package timeout must be a positive integer" +is_positive_integer "$g_bootstrap_timeout" || die "bootstrap timeout must be a positive integer" +[[ "$g_releasever" =~ ^[A-Za-z0-9._-]+$ ]] || die "releasever contains unsupported characters" +case "$g_root_mode" in + auto | overlay | copy) ;; + *) die "root mode must be auto, overlay, or copy" ;; +esac + +[ -x "$g_worker" ] || [ -f "$g_worker" ] || die "worker not found: $g_worker" +for input_file in "$g_package_file" "$g_desktop_file" "$g_service_file"; do + [ -f "$input_file" ] || die "input file not found: $input_file" +done +[ -d "$g_repo_dir" ] || die "repo directory not found: $g_repo_dir" +g_repo_dir="$(realpath "$g_repo_dir")" +find "$g_repo_dir" -maxdepth 1 -type f -name '*.repo' -print -quit | grep -q . || + die "repo directory contains no .repo files: $g_repo_dir" +[ -z "$g_base_root_input" ] || [ -d "$g_base_root_input" ] || + die "base rootfs directory not found: $g_base_root_input" +[ -d "$g_work_parent" ] || mkdir -p "$g_work_parent" || + die "cannot create work parent: $g_work_parent" +g_work_parent="$(realpath "$g_work_parent")" +case "$g_work_parent" in + *[!A-Za-z0-9_./-]*) + die "work directory path may contain only letters, digits, '_', '.', '/', and '-' so mount cleanup can be verified safely" + ;; +esac + +mkdir -p "$g_output_root" || die "cannot create output directory: $g_output_root" +g_output_root="$(realpath "$g_output_root")" +g_run_dir="$g_output_root/$g_run_id" +g_work_root="$g_work_parent/$g_run_id" +case "$g_run_dir/" in + "$g_work_root/"*) die "output and temporary chroot run directories must not overlap" ;; +esac +case "$g_work_root/" in + "$g_run_dir/"*) die "output and temporary chroot run directories must not overlap" ;; +esac +mkdir -m 0700 "$g_work_root" || die "cannot create private chroot work root: $g_work_root" +trap cleanup_all_roots EXIT +mkdir "$g_work_root/packages" || die "cannot create chroot package work directory" +mkdir -m 0755 "$g_run_dir" || die "cannot create new output run directory: $g_run_dir" +mkdir "$g_run_dir/input" "$g_run_dir/packages" || die "cannot create output subdirectories" +normalize_list "$g_package_file" "$g_run_dir/input/packages.list" no +normalize_list "$g_desktop_file" "$g_run_dir/input/desktop.list" yes +normalize_list "$g_service_file" "$g_run_dir/input/service.list" yes + +g_total="$(wc -l <"$g_run_dir/input/packages.list")" +[ "$g_total" -gt 0 ] || die "package list contains no testable entries" +while IFS= read -r package_spec; do + [[ "$package_spec" =~ ^[A-Za-z0-9][A-Za-z0-9._+~:@%-]*$ ]] || + die "unsafe or unsupported package spec: $package_spec" +done <"$g_run_dir/input/packages.list" + +command -v timeout >/dev/null 2>&1 || die "GNU timeout is required" +command -v setsid >/dev/null 2>&1 || die "util-linux setsid is required" +command -v pgrep >/dev/null 2>&1 || die "procps-ng pgrep is required" +command -v pkill >/dev/null 2>&1 || die "procps-ng pkill is required" +[ "$(id -u)" -eq 0 ] || [ "${YUM_CI_CHROOT_TEST_MODE:-0}" = "1" ] || + die "root privileges are required; run with sudo" +for required_command in "$g_chroot_bin" "$g_mount_bin" "$g_umount_bin" "$g_findmnt_bin"; do + command -v "$required_command" >/dev/null 2>&1 || die "required command not found: $required_command" +done +if [ -z "$g_base_root_input" ]; then + command -v "$g_dnf_bin" >/dev/null 2>&1 || die "dnf is required to prepare a base rootfs" +fi +trap 'handle_parent_signal 130' INT +trap 'handle_parent_signal 143' TERM HUP +prepare_base_root +detect_root_mode + +echo "Chroot package test: jobs=$g_jobs total=$g_total root_mode=$g_root_mode base=$g_base_root" +echo "Logs: $g_run_dir/packages" + +g_index=0 +while IFS= read -r package_spec; do + g_index=$((g_index + 1)) + start_package "$g_index" "$package_spec" + if [ "${#g_pids[@]}" -ge "$g_jobs" ]; then + wait_for_finished_package + fi +done <"$g_run_dir/input/packages.list" +for pid in "${g_pids[@]}"; do + wait "$pid" || true +done + +g_report="$g_output_root/package-command-result-chroot-$(date '+%Y%m%d-%H%M%S').txt" +aggregate_results "$g_report" diff --git a/testcase/lib/chroot-package-worker.sh b/testcase/lib/chroot-package-worker.sh new file mode 100755 index 0000000000000000000000000000000000000000..c40f5569d84136575e498760fd81b671bc169edf --- /dev/null +++ b/testcase/lib/chroot-package-worker.sh @@ -0,0 +1,329 @@ +#!/bin/bash +# Run one package's install/command/service/remove checks inside a chroot. + +set -u +set -o pipefail + +usage() { + echo "Usage: $0 " >&2 +} + +[ "$#" -eq 5 ] || { + usage + exit 2 +} + +g_requested="$1" +g_desktop_list="$2" +g_service_list="$3" +g_output_dir="$4" +g_stage_timeout="$5" + +mkdir -p "$g_output_dir" + +g_source_package="UNKNOWN" +g_binary_package="$g_requested" +g_rpm_name="$g_requested" +g_install="FAIL" +g_command="SKIP" +g_service="SKIP" +g_remove="SKIP" +g_install_reason="worker did not finish installation" +g_command_reason="installation did not complete" +g_service_reason="installation did not complete" +g_remove_reason="installation did not complete" + +clean_text() { + printf '%s' "$*" | tr '\t\r\n' ' ' | sed 's/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//' | cut -c1-500 +} + +reason_from_log() { + local log_file="$1" + local fallback="$2" + local reason="" + + if [ -s "$log_file" ]; then + reason="$(grep -Ei 'error:|failed|failure|conflict|nothing provides|no match|not found|timed out|timeout|missing' "$log_file" | tail -n 1 || true)" + [ -n "$reason" ] || reason="$(grep -v '^[[:space:]]*$' "$log_file" | tail -n 1 || true)" + fi + [ -n "$reason" ] || reason="$fallback" + clean_text "$reason" +} + +in_list() { + local list_file="$1" + local package_name="$2" + + [ -s "$list_file" ] && grep -Fxq -- "$package_name" "$list_file" +} + +# Called indirectly by the EXIT trap. +# shellcheck disable=SC2317 +overall_status() { + if [ "$g_install" = "FAIL" ] || [ "$g_command" = "FAIL" ] || + [ "$g_service" = "FAIL" ] || [ "$g_remove" = "FAIL" ]; then + echo "FAIL" + elif [ "$g_install" = "SKIP" ] || [ "$g_command" = "SKIP" ] || + [ "$g_service" = "SKIP" ] || [ "$g_remove" = "SKIP" ]; then + echo "SKIP" + else + echo "PASS" + fi +} + +# shellcheck disable=SC2317 +write_result() { + local overall + overall="$(overall_status)" + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$(clean_text "$g_requested")" \ + "$(clean_text "$g_source_package")" \ + "$(clean_text "$g_binary_package")" \ + "$g_install" "$g_command" "$g_service" "$g_remove" \ + "$(clean_text "$g_install_reason")" \ + "$(clean_text "$g_command_reason")" \ + "$(clean_text "$g_service_reason")" \ + "$(clean_text "$g_remove_reason")" \ + "$overall" >"$g_output_dir/result.tsv" +} + +trap write_result EXIT + +if command -v dnf >/dev/null 2>&1; then + g_pm="dnf" +elif command -v yum >/dev/null 2>&1; then + g_pm="yum" +else + g_install_reason="chroot has neither dnf nor yum" + exit 1 +fi + +run_timed() { + timeout --signal=TERM --kill-after=10 "${g_stage_timeout}s" "$@" +} + +install_package() { + local rc + local install_log="$g_output_dir/install.log" + + run_timed "$g_pm" install -y -- "$g_requested" >"$install_log" 2>&1 + rc=$? + if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then + g_install_reason="installation timed out after ${g_stage_timeout}s" + return 1 + fi + if [ "$rc" -ne 0 ]; then + g_install_reason="$(reason_from_log "$install_log" "$g_pm install returned $rc")" + return 1 + fi + if grep -Eiq 'scriptlet failed|No such file or directory' "$install_log"; then + g_install_reason="$(reason_from_log "$install_log" "installation reported a scriptlet/file error")" + return 1 + fi + + g_rpm_name="$(rpm -q --qf '%{NAME}\n' -- "$g_requested" 2>>"$install_log" | head -n 1)" + if [ -z "$g_rpm_name" ]; then + g_install_reason="rpm query failed after successful package-manager install" + return 1 + fi + g_source_package="$(rpm -q --qf '%{SOURCERPM}\n' -- "$g_rpm_name" 2>/dev/null | head -n 1)" + g_binary_package="$(rpm -q --qf '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}\n' -- "$g_rpm_name" 2>/dev/null | head -n 1)" + [ -n "$g_source_package" ] || g_source_package="UNKNOWN" + [ -n "$g_binary_package" ] || g_binary_package="$g_requested" + + g_install="PASS" + g_install_reason="-" + return 0 +} + +probe_command() { + local command_path="$1" + local command_id="$2" + local command_log="$g_output_dir/commands/${command_id}.log" + local trace_log="$g_output_dir/commands/${command_id}.strace" + + : >"$command_log" + if timeout --signal=KILL 2 "$command_path" --help >"$command_log" 2>&1; then + return 0 + fi + if timeout --signal=KILL 2 "$command_path" --version >>"$command_log" 2>&1; then + return 0 + fi + if grep -Eiq 'error while loading shared libraries|segmentation fault|core dumped' "$command_log"; then + return 1 + fi + if ! realpath "$command_path" >/dev/null 2>&1; then + echo "broken executable path: $command_path" >>"$command_log" + return 1 + fi + + timeout --signal=KILL 2 strace -f -qq -e trace=execve -o "$trace_log" \ + "$command_path" --help >>"$command_log" 2>&1 || true + if grep -Eq 'SIG(SEGV|ABRT)|core dumped' "$trace_log" "$command_log" 2>/dev/null; then + return 1 + fi + if grep -Eq 'execve\(.*\)[[:space:]]+=[[:space:]]+0' "$trace_log" 2>/dev/null; then + return 0 + fi + if grep -Eiq 'invalid|argument|unknown|option|usage' "$command_log"; then + return 0 + fi + return 1 +} + +test_commands() { + local prereq_log="$g_output_dir/command-prerequisites.log" + local files_list="$g_output_dir/command-files.list" + local commands_list="$g_output_dir/commands.list" + local command_path + local command_id=0 + local failures=0 + local first_failure="" + + if in_list "$g_desktop_list" "$g_rpm_name" || in_list "$g_desktop_list" "$g_requested"; then + g_command="SKIP" + g_command_reason="desktop whitelist: binary command test is not applicable" + return 0 + fi + + if ! command -v file >/dev/null 2>&1 || ! command -v strace >/dev/null 2>&1; then + run_timed "$g_pm" install -y file strace >"$prereq_log" 2>&1 || true + fi + if ! command -v file >/dev/null 2>&1 || ! command -v strace >/dev/null 2>&1; then + g_command="FAIL" + g_command_reason="$(reason_from_log "$prereq_log" "file/strace command-test prerequisites are unavailable")" + return 1 + fi + + rpm -ql -- "$g_rpm_name" 2>"$g_output_dir/rpm-ql-command.log" | + awk '/^\/usr\/(s?bin)\// && $0 !~ /\.so([.]|$)/ && $0 !~ /^\/usr\/bin\/nde-/ { print }' \ + >"$files_list" + : >"$commands_list" + while IFS= read -r command_path; do + [ -x "$command_path" ] || continue + if file -L -- "$command_path" 2>/dev/null | grep -qw 'ELF'; then + echo "$command_path" >>"$commands_list" + fi + done <"$files_list" + + if [ ! -s "$commands_list" ]; then + g_command="PASS" + g_command_reason="no ELF executable under /usr/bin or /usr/sbin" + return 0 + fi + + mkdir -p "$g_output_dir/commands" + while IFS= read -r command_path; do + command_id=$((command_id + 1)) + if ! probe_command "$command_path" "$command_id"; then + failures=$((failures + 1)) + [ -n "$first_failure" ] || first_failure="$command_path: $(reason_from_log "$g_output_dir/commands/${command_id}.log" "execve probe failed")" + fi + done <"$commands_list" + + if [ "$failures" -eq 0 ]; then + g_command="PASS" + g_command_reason="all ${command_id} ELF executable(s) passed" + return 0 + fi + g_command="FAIL" + g_command_reason="${failures}/${command_id} executable(s) failed; $first_failure" + return 1 +} + +test_services() { + local units_list="$g_output_dir/systemd-units.list" + local service_log="$g_output_dir/service.log" + local unit + local concrete_units=0 + local template_units=0 + + rpm -ql -- "$g_rpm_name" 2>"$g_output_dir/rpm-ql-service.log" | + grep -E '^/(usr/lib|lib|etc)/systemd/system/.*\.(service|socket|target)$' | + awk -F/ '{ print $NF }' | sort -u >"$units_list" || true + + if [ ! -s "$units_list" ]; then + g_service="PASS" + g_service_reason="package provides no systemd service/socket/target unit" + return 0 + fi + if in_list "$g_service_list" "$g_rpm_name" || in_list "$g_service_list" "$g_requested"; then + g_service="SKIP" + g_service_reason="special-service list: service requires configuration before startup" + while IFS= read -r unit; do + printf '===== %s =====\nconclusion: start=SKIP status/is-active=SKIP stop=SKIP; reason=special configuration required\n' \ + "$unit" >>"$service_log" + done <"$units_list" + return 0 + fi + while IFS= read -r unit; do + case "$unit" in + *@.service | *@.socket | *@.target) + template_units=$((template_units + 1)) + printf '===== %s =====\nconclusion: start=SKIP status/is-active=SKIP stop=SKIP; reason=template unit requires an instance name and configuration\n' \ + "$unit" >>"$service_log" + ;; + *) + concrete_units=$((concrete_units + 1)) + printf '===== %s =====\nconclusion: start=SKIP status/is-active=SKIP stop=SKIP; reason=chroot has no dedicated systemd PID 1\n' \ + "$unit" >>"$service_log" + ;; + esac + done <"$units_list" + g_service="SKIP" + if [ "$concrete_units" -eq 0 ]; then + g_service_reason="package provides only template systemd unit(s); instance name/configuration required" + elif [ "$template_units" -gt 0 ]; then + g_service_reason="chroot has no dedicated systemd PID 1; template unit(s) also require an instance; start/status/is-active/stop were not executed" + else + g_service_reason="chroot has no dedicated systemd PID 1; start/status/is-active/stop were not executed" + fi + return 0 +} + +remove_package() { + local rc + local remove_log="$g_output_dir/remove.log" + + run_timed "$g_pm" remove -y --setopt=clean_requirements_on_remove=False -- "$g_rpm_name" >"$remove_log" 2>&1 + rc=$? + if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then + g_remove="FAIL" + g_remove_reason="removal timed out after ${g_stage_timeout}s" + return 1 + fi + if [ "$rc" -ne 0 ]; then + g_remove="FAIL" + g_remove_reason="$(reason_from_log "$remove_log" "$g_pm remove returned $rc")" + return 1 + fi + if grep -Eiq 'scriptlet failed' "$remove_log"; then + g_remove="FAIL" + g_remove_reason="$(reason_from_log "$remove_log" "removal reported a scriptlet error")" + return 1 + fi + if rpm -q -- "$g_rpm_name" >/dev/null 2>&1; then + g_remove="FAIL" + g_remove_reason="package remains installed after successful package-manager return" + return 1 + fi + + g_remove="PASS" + g_remove_reason="-" + return 0 +} + +if install_package; then + test_commands || true + test_services || true + remove_package || true +else + g_command="SKIP" + g_command_reason="installation failed" + g_service="SKIP" + g_service_reason="installation failed" + g_remove="SKIP" + g_remove_reason="installation failed" +fi + +exit 0 diff --git a/testcase/tests/test-command-execute-chroot.sh b/testcase/tests/test-command-execute-chroot.sh new file mode 100755 index 0000000000000000000000000000000000000000..34d09d08dea570f08d37afcf1d6667c19e251151 --- /dev/null +++ b/testcase/tests/test-command-execute-chroot.sh @@ -0,0 +1,476 @@ +#!/bin/bash + +set -euo pipefail + +g_test_dir="$(mktemp -d)" +g_topdir="$(cd "$(dirname "$0")/../.." && pwd)" +trap 'rm -rf "$g_test_dir"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_contains() { + local file="$1" + local expected="$2" + grep -Fq -- "$expected" "$file" || fail "$file does not contain: $expected" +} + +make_fixture() { + local case_dir="$1" + local path + + mkdir -p "$case_dir/bin" "$case_dir/base/bin" "$case_dir/base/usr/bin" \ + "$case_dir/base/etc/yum.repos.d" "$case_dir/repos" "$case_dir/work" + for path in bin/bash usr/bin/rpm usr/bin/dnf; do + printf '#!/bin/bash\nexit 0\n' >"$case_dir/base/$path" + chmod +x "$case_dir/base/$path" + done + printf '[fake]\nname=fake\nbaseurl=https://mirrors.tencent.com/opencloudos/9/BaseOS/x86_64/os/\nenabled=1\ngpgcheck=0\n' \ + >"$case_dir/repos/fake.repo" + : >"$case_dir/mount.log" + : >"$case_dir/bind.map" + : >"$case_dir/chroot.events" + : >"$case_dir/chroot.completions" + + cat >"$case_dir/bin/fake-mount" <<'EOF' +#!/bin/bash +set -u +printf '%s\n' "$*" >>"$FAKE_MOUNT_LOG" +if [ "${1:-}" = "--bind" ]; then + printf '%s|%s\n' "$2" "$3" >>"$FAKE_BIND_MAP" +fi +exit 0 +EOF +cat >"$case_dir/bin/fake-umount" <<'EOF' +#!/bin/bash +set -u +printf '%s\n' "$*" >>"$FAKE_UMOUNT_LOG" +[ "${FAKE_UMOUNT_FAIL:-0}" != "1" ] || exit 1 +exit 0 +EOF + cat >"$case_dir/bin/fake-findmnt" <<'EOF' +#!/bin/bash +[ "${FAKE_FINDMNT_MOUNTED:-0}" != "1" ] || awk -F '|' '{ print $2 }' "$FAKE_BIND_MAP" +exit 0 +EOF + cat >"$case_dir/bin/fake-chroot" <<'EOF' +#!/bin/bash +set -u +root="$1" +package="$4" +host_output="$(awk -F '|' -v target="$root/test-output" '$2 == target { source=$1 } END { print source }' "$FAKE_BIND_MAP")" +[ -n "$host_output" ] || exit 98 +printf 'start:%s\n' "$package" >>"$FAKE_CHROOT_EVENTS" +[ -z "${FAKE_CHROOT_MARKER:-}" ] || printf 'started\n' >>"$FAKE_CHROOT_MARKER" +if [ "$package" = "partialtimeout" ]; then + mkdir -p "$host_output" + printf 'partialtimeout\tpartial.src.rpm\tpartial-1-1.x86_64\tPASS\tSKIP\tSKIP\tSKIP\t-\tcommand pending\tservice pending\tremove pending\tSKIP\n' \ + >"$host_output/result.tsv" +fi +if [ "$package" = "slow" ] || [ "$package" = "partialtimeout" ]; then + sleep "${FAKE_CHROOT_DELAY:-3}" +fi +mkdir -p "$host_output" +case "$package" in + good) + printf 'good\tgood.src.rpm\tgood-1-1.x86_64\tPASS\tPASS\tPASS\tPASS\t-\t-\t-\t-\tPASS\n' + ;; + installfail) + printf 'installfail\tUNKNOWN\tinstallfail\tFAIL\tSKIP\tSKIP\tSKIP\tmissing dependency\tinstallation failed\tinstallation failed\tinstallation failed\tFAIL\n' + ;; + desktop) + printf 'desktop\tdesktop.src.rpm\tdesktop-1-1.x86_64\tPASS\tSKIP\tPASS\tPASS\t-\tdesktop whitelist\t-\t-\tSKIP\n' + ;; + service) + printf 'service\tservice.src.rpm\tservice-1-1.x86_64\tPASS\tPASS\tSKIP\tPASS\t-\t-\trequires configuration\t-\tSKIP\n' + ;; + commandfail) + printf 'commandfail\tcommandfail.src.rpm\tcommandfail-1-1.x86_64\tPASS\tFAIL\tPASS\tPASS\t-\texecve returned 127\t-\t-\tFAIL\n' + ;; + servicefail) + printf 'servicefail\tservicefail.src.rpm\tservicefail-1-1.x86_64\tPASS\tPASS\tFAIL\tPASS\t-\t-\tstart failed\t-\tFAIL\n' + ;; + removefail) + printf 'removefail\tremovefail.src.rpm\tremovefail-1-1.x86_64\tPASS\tPASS\tPASS\tFAIL\t-\t-\t-\tpackage remains installed\tFAIL\n' + ;; + slow) + printf 'slow\tslow.src.rpm\tslow-1-1.x86_64\tPASS\tPASS\tPASS\tPASS\t-\t-\t-\t-\tPASS\n' + ;; + partialtimeout) + printf 'partialtimeout\tpartial.src.rpm\tpartial-1-1.x86_64\tPASS\tPASS\tPASS\tPASS\t-\t-\t-\t-\tPASS\n' + ;; +esac >"$host_output/result.tsv" +printf 'complete:%s\n' "$package" >>"$FAKE_CHROOT_COMPLETIONS" +EOF + chmod +x "$case_dir/bin/"* +} + +run_case() { + local case_dir="$1" + shift + env \ + YUM_CI_CHROOT_TEST_MODE=1 \ + YUM_CI_CHROOT_BIN="$case_dir/bin/fake-chroot" \ + YUM_CI_MOUNT_BIN="$case_dir/bin/fake-mount" \ + YUM_CI_UMOUNT_BIN="$case_dir/bin/fake-umount" \ + YUM_CI_FINDMNT_BIN="$case_dir/bin/fake-findmnt" \ + FAKE_MOUNT_LOG="$case_dir/mount.log" \ + FAKE_UMOUNT_LOG="$case_dir/umount.log" \ + FAKE_BIND_MAP="$case_dir/bind.map" \ + FAKE_CHROOT_EVENTS="$case_dir/chroot.events" \ + FAKE_CHROOT_COMPLETIONS="$case_dir/chroot.completions" \ + FAKE_CHROOT_DELAY="${FAKE_CHROOT_DELAY:-3}" \ + FAKE_UMOUNT_FAIL="${FAKE_UMOUNT_FAIL:-0}" \ + FAKE_FINDMNT_MOUNTED="${FAKE_FINDMNT_MOUNTED:-0}" \ + "$g_topdir/testcase/command-execute-chroot.sh" \ + --base-rootfs "$case_dir/base" --repo-dir "$case_dir/repos" \ + --root-mode copy --work-dir "$case_dir/work" "$@" +} + +start_case() { + local case_dir="$1" + shift + exec env \ + YUM_CI_CHROOT_TEST_MODE=1 \ + YUM_CI_CHROOT_BIN="$case_dir/bin/fake-chroot" \ + YUM_CI_MOUNT_BIN="$case_dir/bin/fake-mount" \ + YUM_CI_UMOUNT_BIN="$case_dir/bin/fake-umount" \ + YUM_CI_FINDMNT_BIN="$case_dir/bin/fake-findmnt" \ + FAKE_MOUNT_LOG="$case_dir/mount.log" \ + FAKE_UMOUNT_LOG="$case_dir/umount.log" \ + FAKE_BIND_MAP="$case_dir/bind.map" \ + FAKE_CHROOT_EVENTS="$case_dir/chroot.events" \ + FAKE_CHROOT_COMPLETIONS="$case_dir/chroot.completions" \ + FAKE_CHROOT_MARKER="${FAKE_CHROOT_MARKER:-}" \ + FAKE_CHROOT_DELAY="${FAKE_CHROOT_DELAY:-3}" \ + FAKE_UMOUNT_FAIL="${FAKE_UMOUNT_FAIL:-0}" \ + FAKE_FINDMNT_MOUNTED="${FAKE_FINDMNT_MOUNTED:-0}" \ + "$g_topdir/testcase/command-execute-chroot.sh" \ + --base-rootfs "$case_dir/base" --repo-dir "$case_dir/repos" \ + --root-mode copy --work-dir "$case_dir/work" "$@" +} + +assert_no_work_roots() { + local case_dir="$1" + if find "$case_dir/work" -mindepth 1 -print -quit | grep -q .; then + fail "temporary chroot roots remain under $case_dir/work" + fi +} + +test_help_and_validation() { + local case_dir="$g_test_dir/path-validation" + local shared="$case_dir/shared" + local unsafe="$case_dir/unsafe work" + + "$g_topdir/testcase/command-execute-chroot.sh" --help >/dev/null + if "$g_topdir/testcase/command-execute-chroot.sh" --jobs 0 a b c >/dev/null 2>&1; then + fail "--jobs 0 was accepted" + fi + if "$g_topdir/testcase/command-execute-chroot.sh" --root-mode invalid a b c >/dev/null 2>&1; then + fail "an invalid root mode was accepted" + fi + + make_fixture "$case_dir" + printf 'good\n' >"$case_dir/packages.list" + : >"$case_dir/desktop.list" + : >"$case_dir/service.list" + if run_case "$case_dir" --work-dir "$unsafe" --output-dir "$case_dir/output" \ + "$case_dir/packages.list" "$case_dir/desktop.list" "$case_dir/service.list" \ + >"$case_dir/unsafe.log" 2>&1; then + fail "a work directory that findmnt would escape was accepted" + fi + assert_contains "$case_dir/unsafe.log" 'work directory path may contain only' + + mkdir -p "$shared" + if run_case "$case_dir" --work-dir "$shared" --output-dir "$shared" \ + "$case_dir/packages.list" "$case_dir/desktop.list" "$case_dir/service.list" \ + >"$case_dir/overlap.log" 2>&1; then + fail "overlapping work and output run directories were accepted" + fi + assert_contains "$case_dir/overlap.log" 'output and temporary chroot run directories must not overlap' + if find "$shared" -mindepth 1 -print -quit | grep -q .; then + fail "overlap validation created a partial run directory" + fi +} + +test_aggregation_mounts_and_cleanup() { + local case_dir="$g_test_dir/aggregate" + local report + make_fixture "$case_dir" + printf 'good\ninstallfail\ndesktop\nservice\ncommandfail\nservicefail\nremovefail\n' >"$case_dir/packages.list" + printf 'desktop\n' >"$case_dir/desktop.list" + printf 'service\n' >"$case_dir/service.list" + + if run_case "$case_dir" -j 2 --output-dir "$case_dir/output" \ + "$case_dir/packages.list" "$case_dir/desktop.list" "$case_dir/service.list" \ + >"$case_dir/run.log" 2>&1; then + fail "an aggregate containing failures returned success" + fi + + report="$(find "$case_dir/output" -maxdepth 1 -name 'package-command-result-chroot-*.txt' -print -quit)" + [ -n "$report" ] || fail "aggregate report was not created" + assert_contains "$report" $'# source-package\tbinary_package\tinstall\tcommand\tservice\tremove' + assert_contains "$report" '环境: chroot | 并发度: 2 | 待测包总数: 7 | 通过: 1 | 失败: 4 | 跳过: 2' + assert_contains "$report" '【安装失败】(1)' + assert_contains "$report" 'missing dependency' + assert_contains "$report" '【卸载失败】(1)' + assert_contains "$report" 'package remains installed' + assert_contains "$report" '【命令测试失败】(1)' + assert_contains "$report" 'execve returned 127' + assert_contains "$report" '【服务测试失败】(1)' + assert_contains "$report" 'start failed' + assert_contains "$report" '【跳过项】(2)' + assert_contains "$report" 'desktop whitelist' + assert_contains "$report" 'requires configuration' + + [ "$(grep -c '^--rbind /dev ' "$case_dir/mount.log")" -eq 7 ] || fail "/dev was not bound per chroot" + [ "$(grep -c '^--rbind /proc ' "$case_dir/mount.log")" -eq 7 ] || fail "/proc was not bound per chroot" + [ "$(grep -c '^--rbind /sys ' "$case_dir/mount.log")" -eq 7 ] || fail "/sys was not bound per chroot" + [ "$(grep -c '/packages/.*|.*/test-output$' "$case_dir/bind.map")" -eq 7 ] || + fail "each chroot must bind only its own package output directory" + [ "$(grep -c 'desktop.list|.*/test-input/desktop.list$' "$case_dir/bind.map")" -eq 7 ] || + fail "desktop list was not bound for every chroot" + [ "$(grep -c 'service.list|.*/test-input/service.list$' "$case_dir/bind.map")" -eq 7 ] || + fail "service list was not bound for every chroot" + [ "$(grep -c '^-o remount,bind,ro ' "$case_dir/mount.log")" -eq 28 ] || + fail "worker, resolver, and input lists were not remounted read-only" + assert_no_work_roots "$case_dir" +} + +test_lifecycle_timeout() { + local case_dir="$g_test_dir/timeout" + local report + make_fixture "$case_dir" + printf 'slow\n' >"$case_dir/packages.list" + : >"$case_dir/desktop.list" + : >"$case_dir/service.list" + + SECONDS=0 + if run_case "$case_dir" --package-timeout 1 --output-dir "$case_dir/output" \ + "$case_dir/packages.list" "$case_dir/desktop.list" "$case_dir/service.list" \ + >"$case_dir/run.log" 2>&1; then + fail "a chroot lifecycle timeout returned success" + fi + [ "$SECONDS" -lt 3 ] || fail "chroot work escaped the package timeout (${SECONDS}s elapsed)" + report="$(find "$case_dir/output" -maxdepth 1 -name 'package-command-result-chroot-*.txt' -print -quit)" + assert_contains "$report" '待测包总数: 1 | 通过: 0 | 失败: 1 | 跳过: 0' + assert_contains "$report" 'chroot lifecycle timeout after 1s' + [ ! -s "$case_dir/chroot.completions" ] || fail "timed-out chroot completed after timeout" + assert_no_work_roots "$case_dir" +} + +test_partial_result_lifecycle_timeout() { + local case_dir="$g_test_dir/partial-timeout" + local report + make_fixture "$case_dir" + printf 'partialtimeout\n' >"$case_dir/packages.list" + : >"$case_dir/desktop.list" + : >"$case_dir/service.list" + + if FAKE_CHROOT_DELAY=3 run_case "$case_dir" --package-timeout 1 \ + --output-dir "$case_dir/output" "$case_dir/packages.list" \ + "$case_dir/desktop.list" "$case_dir/service.list" >"$case_dir/run.log" 2>&1; then + fail "a lifecycle timeout with a partial result returned success" + fi + report="$(find "$case_dir/output" -maxdepth 1 -name 'package-command-result-chroot-*.txt' -print -quit)" + assert_contains "$report" '待测包总数: 1 | 通过: 0 | 失败: 1 | 跳过: 0' + assert_contains "$report" 'chroot lifecycle timeout after 1s' + if grep -Fq 'command pending' "$report"; then + fail "a partial worker result survived lifecycle-timeout synthesis" + fi + assert_no_work_roots "$case_dir" +} + +test_work_conserving_scheduler() { + local case_dir="$g_test_dir/work-conserving" + make_fixture "$case_dir" + printf 'slow\ngood\nslow\ngood\n' >"$case_dir/packages.list" + : >"$case_dir/desktop.list" + : >"$case_dir/service.list" + + SECONDS=0 + FAKE_CHROOT_DELAY=3 run_case "$case_dir" -j 2 --package-timeout 10 \ + --output-dir "$case_dir/output" "$case_dir/packages.list" \ + "$case_dir/desktop.list" "$case_dir/service.list" >"$case_dir/run.log" 2>&1 || + fail "work-conserving scheduler fixture failed" + [ "$SECONDS" -lt 5 ] || fail "finished jobs did not refill a free concurrency slot (${SECONDS}s elapsed)" + [ "$(grep -c '^complete:' "$case_dir/chroot.completions")" -eq 4 ] || + fail "work-conserving scheduler did not complete all packages" + assert_no_work_roots "$case_dir" +} + +test_umount_failure_is_safe() { + local case_dir="$g_test_dir/umount-failure" + local retained_root + local report + make_fixture "$case_dir" + printf 'good\n' >"$case_dir/packages.list" + : >"$case_dir/desktop.list" + : >"$case_dir/service.list" + + if FAKE_UMOUNT_FAIL=1 FAKE_FINDMNT_MOUNTED=1 run_case "$case_dir" \ + --output-dir "$case_dir/output" "$case_dir/packages.list" \ + "$case_dir/desktop.list" "$case_dir/service.list" >"$case_dir/run.log" 2>&1; then + fail "a chroot cleanup failure returned success" + fi + retained_root="$(find "$case_dir/work" -mindepth 3 -maxdepth 3 -type d -name '00001-good' -print -quit)" + [ -n "$retained_root" ] || fail "unsafe cleanup removed a root with reported residual mounts" + [ -e "$case_dir/base/bin/bash" ] || fail "unsafe cleanup removed content from the source root" + assert_contains "$case_dir/run.log" 'chroot mounts remain; refusing to remove' + assert_contains "$case_dir/run.log" 'mounts remain under' + report="$(find "$case_dir/output" -maxdepth 1 -name 'package-command-result-chroot-*.txt' -print -quit)" + assert_contains "$report" 'chroot cleanup failed; mounts or temporary root were retained' +} + +test_worker_skip_branches_and_unit_filter() { + local case_dir="$g_test_dir/worker" + local fakebin="$case_dir/fakebin" + local special_output="$case_dir/special-output" + local no_pid1_output="$case_dir/no-pid1-output" + local template_output="$case_dir/template-output" + mkdir -p "$fakebin" + + for command_name in dnf file strace; do + printf '#!/bin/bash\nexit 0\n' >"$fakebin/$command_name" + done + cat >"$fakebin/rpm" <<'EOF' +#!/bin/bash +if [ "${1:-}" = "-ql" ]; then + if [ "${FAKE_RPM_UNIT_MODE:-concrete}" = "template" ]; then + printf '%s\n' /usr/lib/systemd/system/fake@.service + else + printf '%s\n' /usr/share/doc/fakepkg/documentation.service /usr/lib/systemd/system/fake.service + fi + exit 0 +fi +if [ "${1:-}" = "-q" ] && [ "${2:-}" = "--qf" ]; then + case "${3:-}" in + *SOURCERPM*) echo fakepkg-1-1.src.rpm ;; + *VERSION*) echo fakepkg-1-1.x86_64 ;; + *) echo fakepkg ;; + esac + exit 0 +fi +exit 1 +EOF + chmod +x "$fakebin"/* + printf 'fakepkg\n' >"$case_dir/desktop.list" + printf 'fakepkg\n' >"$case_dir/service.list" + : >"$case_dir/empty.list" + + PATH="$fakebin:$PATH" "$g_topdir/testcase/lib/chroot-package-worker.sh" \ + fakepkg "$case_dir/desktop.list" "$case_dir/service.list" "$special_output" 5 + assert_contains "$special_output/result.tsv" $'PASS\tSKIP\tSKIP\tPASS' + assert_contains "$special_output/result.tsv" 'desktop whitelist' + assert_contains "$special_output/result.tsv" 'special-service list' + assert_contains "$special_output/service.log" 'start=SKIP status/is-active=SKIP stop=SKIP' + assert_contains "$special_output/systemd-units.list" 'fake.service' + if grep -Fq documentation.service "$special_output/systemd-units.list"; then + fail "a documentation path was treated as a systemd unit" + fi + + PATH="$fakebin:$PATH" "$g_topdir/testcase/lib/chroot-package-worker.sh" \ + fakepkg "$case_dir/empty.list" "$case_dir/empty.list" "$no_pid1_output" 5 + assert_contains "$no_pid1_output/result.tsv" $'PASS\tPASS\tSKIP\tPASS' + assert_contains "$no_pid1_output/result.tsv" 'chroot has no dedicated systemd PID 1' + assert_contains "$no_pid1_output/service.log" 'start=SKIP status/is-active=SKIP stop=SKIP' + + FAKE_RPM_UNIT_MODE=template PATH="$fakebin:$PATH" \ + "$g_topdir/testcase/lib/chroot-package-worker.sh" \ + fakepkg "$case_dir/empty.list" "$case_dir/empty.list" "$template_output" 5 + assert_contains "$template_output/result.tsv" $'PASS\tPASS\tSKIP\tPASS' + assert_contains "$template_output/result.tsv" 'only template systemd unit(s); instance name/configuration required' + assert_contains "$template_output/systemd-units.list" 'fake@.service' + assert_contains "$template_output/service.log" 'template unit requires an instance name and configuration' +} + +test_main_signal_cleanup() { + local case_dir="$g_test_dir/main-signal" + local marker="$case_dir/chroot-started" + local main_pid + local main_rc + make_fixture "$case_dir" + printf 'slow\n' >"$case_dir/packages.list" + : >"$case_dir/desktop.list" + : >"$case_dir/service.list" + + FAKE_CHROOT_MARKER="$marker" FAKE_CHROOT_DELAY=10 \ + start_case "$case_dir" --package-timeout 30 --output-dir "$case_dir/output" \ + "$case_dir/packages.list" "$case_dir/desktop.list" "$case_dir/service.list" \ + >"$case_dir/run.log" 2>&1 & + main_pid=$! + for _ in $(seq 1 50); do [ -s "$marker" ] && break; sleep 0.1; done + [ -s "$marker" ] || fail "fake chroot did not start" + kill -TERM "$main_pid" + set +e + wait "$main_pid" + main_rc=$? + set -e + [ "$main_rc" -eq 143 ] || fail "main process returned $main_rc instead of 143" + for _ in $(seq 1 20); do + pgrep -f -- "$case_dir/bin/fake-chroot" >/dev/null 2>&1 && fail "chroot descendant survived SIGTERM" + sleep 0.1 + done + [ ! -s "$case_dir/chroot.completions" ] || fail "chroot completed after main-process SIGTERM" + assert_no_work_roots "$case_dir" +} + +test_spawn_and_double_signal_races() { + local mode + for mode in spawn double; do + local case_dir="$g_test_dir/$mode-signal" + local bash_env="$case_dir/bash-env" + local main_pid main_rc + local marker="$case_dir/debug-marker" + make_fixture "$case_dir" + printf 'slow\n' >"$case_dir/packages.list" + : >"$case_dir/desktop.list" + : >"$case_dir/service.list" + if [ "$mode" = "spawn" ]; then + cat >"$bash_env" <<'EOF' +set -T +trap 'case "$BASH_COMMAND" in g_pids+=*) trap - DEBUG; : >"$YUM_CI_DEBUG_MARKER"; kill -TERM "$$" ;; esac' DEBUG +EOF + else + cat >"$bash_env" <<'EOF' +set -T +trap 'case "$BASH_COMMAND" in g_pids+=*) if [ ! -e "$YUM_CI_DEBUG_MARKER" ]; then echo first >"$YUM_CI_DEBUG_MARKER"; kill -TERM "$$"; fi ;; "pkill -TERM -s "*) if [ "$(tail -n 1 "$YUM_CI_DEBUG_MARKER" 2>/dev/null)" = first ]; then echo second >>"$YUM_CI_DEBUG_MARKER"; kill -TERM "$$"; fi ;; esac' DEBUG +EOF + fi + + BASH_ENV="$bash_env" YUM_CI_DEBUG_MARKER="$marker" FAKE_CHROOT_DELAY=10 \ + start_case "$case_dir" --package-timeout 30 --output-dir "$case_dir/output" \ + "$case_dir/packages.list" "$case_dir/desktop.list" "$case_dir/service.list" \ + >"$case_dir/run.log" 2>&1 & + main_pid=$! + set +e + wait "$main_pid" + main_rc=$? + set -e + [ "$main_rc" -eq 143 ] || fail "$mode signal returned $main_rc instead of 143" + if [ "$mode" = "spawn" ]; then + [ -e "$marker" ] || fail "spawn tracking window was not injected" + else + [ "$(tr '\n' ',' <"$marker")" = 'first,second,' ] || fail "double signal windows were not injected" + fi + for _ in $(seq 1 20); do + pgrep -f -- "$case_dir/bin/fake-chroot" >/dev/null 2>&1 && fail "$mode signal left a chroot descendant" + sleep 0.1 + done + [ ! -s "$case_dir/chroot.completions" ] || fail "$mode signal allowed chroot completion" + assert_no_work_roots "$case_dir" + done +} + +test_help_and_validation +test_aggregation_mounts_and_cleanup +test_lifecycle_timeout +test_partial_result_lifecycle_timeout +test_work_conserving_scheduler +test_umount_failure_is_safe +test_worker_skip_branches_and_unit_filter +test_main_signal_cleanup +test_spawn_and_double_signal_races +echo "PASS: command-execute-chroot scheduler/report tests"