diff --git a/testcase/README-command-execute-docker.md b/testcase/README-command-execute-docker.md new file mode 100644 index 0000000000000000000000000000000000000000..8b9fa33c13d674a3f6ae6477690ca49aadd1b479 --- /dev/null +++ b/testcase/README-command-execute-docker.md @@ -0,0 +1,82 @@ +# Docker 并发软件包测试 + +`command-execute-docker.sh` 在独立容器中对每个软件包执行“安装 → 命令验证 → +服务验证 → 卸载”,不改变现有的 `command-execute-whitelist.sh` 用例。 + +## 准备 OpenCloudOS 镜像 + +默认镜像是 `opencloudos/opencloudos9-init:latest`。建议从 OpenCloudOS 国内官方 +镜像站下载 init 镜像并校验后导入;例如 x86_64 的 9.6 镜像: + +```bash +base=https://mirrors.opencloudos.tech/opencloudos/9/images/docker/x86_64/20260514.2 +image=OpenCloudOS-Container-Init-9.6-20260514.2.x86_64.tar.xz +curl -fLO "$base/$image" +curl -fLO "$base/$image.sha256" +sha256sum -c "$image.sha256" +docker load -i "$image" +docker image tag opencloudos-container-init-9.6-20260514.2.x86_64:latest \ + opencloudos/opencloudos9-init:latest +``` + +镜像内启用的 DNF/YUM 仓库就是测试使用的仓库,因此镜像版本必须与被测系统一致。 +若 CI 使用定制仓库,可用 `--repo-dir /path/to/repos` 将被测系统的 `.repo` 文件目录 +只读挂载到容器,也可通过 `--image` 指定已包含这些配置的派生镜像。脚本不会自动拉取 +或构建镜像,避免在测试过程中引入不受控的软件源。 + +## 使用 + +三个位置参数依次为待测包列表、桌面白名单和需特殊配置的服务包列表。列表忽略空行 +和 `#` 注释,取每行第一列;白名单使用安装后的 RPM `NAME` 精确匹配。 + +```bash +sudo ./command-execute-docker.sh \ + -j 8 \ + --stage-timeout 300 \ + --package-timeout 1200 \ + --cpus 1 \ + --memory 1g \ + --repo-dir /etc/yum.repos.d \ + --output-dir ./docker-results \ + pkg.list desktop-white.pkg service-white.pkg +``` + +常用选项: + +- `-j N`:并发容器数; +- `--image IMAGE`:已经在本机导入的 OpenCloudOS 镜像; +- `--cpus`、`--memory`、`--pids-limit`:单容器资源上限; +- `--repo-dir`:只读挂载与被测系统一致的 YUM/DNF repo 配置; +- `--stage-timeout`:单个安装、服务操作或卸载操作超时; +- `--package-timeout`:覆盖容器创建、systemd 就绪等待、测试和清理的单包硬超时; +- `--systemd auto|always|never`:systemd 容器策略。 + +## 命令与服务判定 + +命令测试沿用 `tst_lib/ts_common.sh` 的判定语义:从 RPM 文件列表中筛选 +`/usr/bin` 和 `/usr/sbin` 下可执行的 ELF,依次尝试 `--help`、`--version`,再用 +`strace` 检查 `execve`;缺失动态库、断链、崩溃或无法执行均记为失败。桌面白名单 +中的包将命令阶段标记为 `SKIP` 并记录原因。 + +默认 init 镜像以 `/sbin/init` 作为 PID 1。脚本使用私有 cgroup namespace、临时 +`/run` 和 `--privileged`,对包提供的非模板 `.service`、`.socket`、`.target` 逐个记录 +`start`、`status/is-active` 和 `stop` 结论。`--privileged` 会扩大容器权限,只应在隔离 +的 CI 节点运行。特殊服务包列表中的服务统一记为“需特殊配置”的 `SKIP`。若 +`--systemd auto` 检测到镜像没有可用的 systemd,会退回普通容器,安装、命令和卸载 +仍继续执行,服务启动明确记为 `SKIP`,不会误报为失败。 + +## 结果与清理 + +总报告命名为 `package-command-result-docker-*.txt`,前半部分保持 +`source-package / binary_package / install / command / service / remove` 列格式,后半 +部分按安装失败、卸载失败、命令失败、服务失败和跳过项分类列出包名及原因。包级结果 +按“任一阶段失败优先,其次任一阶段跳过,否则通过”计数,脚本强制校验: + +```text +通过 + 失败 + 跳过 = 待测包总数 +``` + +每个容器只能写入自己的包级输出目录;两个白名单文件及 repo 配置均为只读挂载,不能 +修改其他包的输入或结果。每个包的安装、命令、服务、卸载和容器日志位于本次运行目录的 `packages/` 下。单包 +失败不会中断其他后台任务。容器使用 `--rm`,脚本退出陷阱还会按本次运行 label 强制 +清理;只使用 bind mount,不创建卷或中间镜像层。为便于排障,包级日志和总报告会保留。 diff --git a/testcase/command-execute-docker.sh b/testcase/command-execute-docker.sh new file mode 100755 index 0000000000000000000000000000000000000000..f21d17409093c86d112c12ef27d48c4381695d00 --- /dev/null +++ b/testcase/command-execute-docker.sh @@ -0,0 +1,694 @@ +#!/bin/bash +############################################################################### +# Concurrent package install/command/service/remove checks using Docker. +############################################################################### + +set -u +set -o pipefail + +g_script_dir="$(cd "$(dirname "$0")" && pwd)" +g_worker="$g_script_dir/lib/docker-package-worker.sh" +g_jobs=4 +g_image="opencloudos/opencloudos9-init:latest" +g_stage_timeout=300 +g_package_timeout=1200 +g_cpus="1" +g_memory="1g" +g_pids_limit=512 +g_output_root="$(pwd)" +g_systemd_mode="auto" +g_repo_dir="" +g_run_id="yum-ci-docker-$(date '+%Y%m%d%H%M%S')-$$" +g_run_dir="" +g_label="yum-ci-pkg-test.run=$g_run_id" +g_systemd_capable=0 +g_docker=() +g_docker_bin="" +g_docker_sudo=0 +g_repo_args=() +g_current_container="" +g_pids=() +g_started_pid="" +g_tracking_critical=0 +g_pending_exit=0 + +usage() { + cat <<'EOF' +Usage: + command-execute-docker.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 containers (default: 4) + --image IMAGE OpenCloudOS base/init image + --stage-timeout SEC Timeout for each install/service/remove operation (default: 300) + --package-timeout SEC Hard timeout for one package container (default: 1200) + --cpus N CPU limit per container (default: 1) + --memory SIZE Memory limit per container (default: 1g) + --pids-limit N PID limit per container (default: 512) + --systemd MODE auto, always, or never (default: auto) + --repo-dir DIR Mount tested-system .repo files read-only + --output-dir DIR Report and per-package log parent (default: current directory) + -h, --help Show this help + +The default init image is run with a private cgroup namespace and --privileged +so systemd units can be checked. In auto mode, an image without a usable init +falls back to a one-shot container and service startup is recorded as SKIP. +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" +} + +select_docker_command() { + local docker_bin="${YUM_CI_DOCKER_BIN:-docker}" + + command -v "$docker_bin" >/dev/null 2>&1 || die "$docker_bin is not installed" + if "$docker_bin" info >/dev/null 2>&1; then + g_docker=("$docker_bin") + g_docker_bin="$docker_bin" + g_docker_sudo=0 + elif command -v sudo >/dev/null 2>&1 && sudo -n "$docker_bin" info >/dev/null 2>&1; then + g_docker=(sudo -n "$docker_bin") + g_docker_bin="$docker_bin" + g_docker_sudo=1 + else + die "cannot access the Docker daemon as the current user or via passwordless sudo" + fi +} + +cleanup_containers() { + local ids + [ "${#g_docker[@]}" -gt 0 ] || return 0 + ids="$("${g_docker[@]}" ps -aq --filter "label=$g_label" 2>/dev/null || true)" + if [ -n "$ids" ]; then + # shellcheck disable=SC2086 + "${g_docker[@]}" rm -f $ids >/dev/null 2>&1 || true + fi +} + +cleanup_current_container() { + [ -n "$g_current_container" ] || return 0 + "${g_docker[@]}" rm -f "$g_current_container" >/dev/null 2>&1 || true + g_current_container="" +} + +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_containers + 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" +} + +run_one_shot_container() { + local container_name="$1" + local package_spec="$2" + local package_dir="$3" + local runtime_log="$4" + local rc + + g_current_container="$container_name" + "${g_docker[@]}" run --rm --name "$container_name" \ + --label "$g_label" --cpus "$g_cpus" --memory "$g_memory" \ + --pids-limit "$g_pids_limit" --security-opt no-new-privileges \ + "${g_repo_args[@]}" \ + -v "$g_worker:/opt/yum-ci-pkg-test/docker-package-worker.sh:ro" \ + -v "$package_dir:/test-output" \ + -v "$g_run_dir/input/desktop.list:/test-input/desktop.list:ro" \ + -v "$g_run_dir/input/service.list:/test-input/service.list:ro" \ + --entrypoint /bin/bash "$g_image" \ + /opt/yum-ci-pkg-test/docker-package-worker.sh \ + "$package_spec" /test-input/desktop.list \ + /test-input/service.list /test-output \ + "$g_stage_timeout" >>"$runtime_log" 2>&1 + rc=$? + cleanup_current_container + return "$rc" +} + +run_systemd_container() { + local container_name="$1" + local package_spec="$2" + local package_dir="$3" + local runtime_log="$4" + local container_id="" + local state="" + local rc=1 + + g_current_container="$container_name" + container_id="$("${g_docker[@]}" run -d --rm --name "$container_name" \ + --label "$g_label" --hostname "$container_name" \ + --cpus "$g_cpus" --memory "$g_memory" --pids-limit "$g_pids_limit" \ + --privileged --cgroupns=private --stop-timeout 10 \ + --tmpfs /run --tmpfs /run/lock \ + "${g_repo_args[@]}" \ + -v "$g_worker:/opt/yum-ci-pkg-test/docker-package-worker.sh:ro" \ + -v "$package_dir:/test-output" \ + -v "$g_run_dir/input/desktop.list:/test-input/desktop.list:ro" \ + -v "$g_run_dir/input/service.list:/test-input/service.list:ro" \ + "$g_image" /sbin/init 2>>"$runtime_log")" || { + cleanup_current_container + return 1 + } + + for _ in $(seq 1 30); do + state="$("${g_docker[@]}" inspect -f '{{.State.Status}}' "$container_id" 2>/dev/null || true)" + [ "$state" = "running" ] || break + state="$("${g_docker[@]}" exec "$container_id" systemctl is-system-running 2>/dev/null || true)" + case "$state" in + running | degraded) + break + ;; + esac + sleep 1 + done + + case "$state" in + running | degraded) + "${g_docker[@]}" exec "$container_id" /bin/bash \ + /opt/yum-ci-pkg-test/docker-package-worker.sh \ + "$package_spec" /test-input/desktop.list \ + /test-input/service.list /test-output \ + "$g_stage_timeout" >>"$runtime_log" 2>&1 + rc=$? + ;; + *) + echo "systemd container did not become ready (state: ${state:-unknown})" >>"$runtime_log" + rc=125 + ;; + esac + + "${g_docker[@]}" logs "$container_id" >>"$runtime_log" 2>&1 || true + cleanup_current_container + return "$rc" +} + +run_package_controller() { + local index="$1" + local package_spec="$2" + local slug + local relative_dir + local package_dir + local runtime_log + local container_name + local rc=0 + + relative_dir="$(package_relative_dir "$index" "$package_spec")" + package_dir="$g_run_dir/packages/$relative_dir" + runtime_log="$package_dir/container.log" + container_name="${g_run_id}-${index}" + mkdir -p "$package_dir" + printf '%s\n' "$package_spec" >"$package_dir/requested-package.txt" + : >"$runtime_log" + + if [ "$g_systemd_capable" -eq 1 ]; then + run_systemd_container "$container_name" "$package_spec" "$package_dir" "$runtime_log" + rc=$? + if [ "$rc" -ne 0 ] && [ "$g_systemd_mode" = "auto" ] && [ ! -s "$package_dir/result.tsv" ]; then + echo "falling back to a one-shot container; service tests will be skipped" >>"$runtime_log" + run_one_shot_container "$container_name-fallback" "$package_spec" "$package_dir" "$runtime_log" + rc=$? + fi + else + run_one_shot_container "$container_name" "$package_spec" "$package_dir" "$runtime_log" + rc=$? + fi + + if [ ! -s "$package_dir/result.tsv" ]; then + if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then + write_synthetic_result "$package_spec" "$package_dir" "container-level timeout after ${g_package_timeout}s" + else + write_synthetic_result "$package_spec" "$package_dir" "container worker exited with status $rc; see container.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 [ ! -s "$package_dir/result.tsv" ]; then + if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ] || [ "$rc" -eq 143 ]; then + write_synthetic_result "$package_spec" "$package_dir" "container-level timeout after ${g_package_timeout}s" + else + write_synthetic_result "$package_spec" "$package_dir" "container controller exited with status $rc; see container.log" + fi + 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_LABEL="$g_label" \ + YUM_CI_INTERNAL_IMAGE="$g_image" \ + YUM_CI_INTERNAL_STAGE_TIMEOUT="$g_stage_timeout" \ + YUM_CI_INTERNAL_PACKAGE_TIMEOUT="$g_package_timeout" \ + YUM_CI_INTERNAL_CPUS="$g_cpus" \ + YUM_CI_INTERNAL_MEMORY="$g_memory" \ + YUM_CI_INTERNAL_PIDS_LIMIT="$g_pids_limit" \ + YUM_CI_INTERNAL_SYSTEMD_CAPABLE="$g_systemd_capable" \ + YUM_CI_INTERNAL_SYSTEMD_MODE="$g_systemd_mode" \ + YUM_CI_INTERNAL_REPO_DIR="$g_repo_dir" \ + YUM_CI_INTERNAL_DOCKER_BIN="$g_docker_bin" \ + YUM_CI_INTERNAL_DOCKER_SUDO="$g_docker_sudo" \ + 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 +} + +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 '环境: docker | 并发度: %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_label="${YUM_CI_INTERNAL_LABEL:?}" + g_image="${YUM_CI_INTERNAL_IMAGE:?}" + g_stage_timeout="${YUM_CI_INTERNAL_STAGE_TIMEOUT:?}" + g_package_timeout="${YUM_CI_INTERNAL_PACKAGE_TIMEOUT:?}" + g_cpus="${YUM_CI_INTERNAL_CPUS:?}" + g_memory="${YUM_CI_INTERNAL_MEMORY:?}" + g_pids_limit="${YUM_CI_INTERNAL_PIDS_LIMIT:?}" + g_systemd_capable="${YUM_CI_INTERNAL_SYSTEMD_CAPABLE:?}" + g_systemd_mode="${YUM_CI_INTERNAL_SYSTEMD_MODE:?}" + g_repo_dir="${YUM_CI_INTERNAL_REPO_DIR:-}" + g_docker_bin="${YUM_CI_INTERNAL_DOCKER_BIN:?}" + g_docker_sudo="${YUM_CI_INTERNAL_DOCKER_SUDO:?}" + if [ "$g_docker_sudo" -eq 1 ]; then + g_docker=(sudo -n "$g_docker_bin") + else + g_docker=("$g_docker_bin") + fi + if [ -n "$g_repo_dir" ]; then + g_repo_args=(-v "$g_repo_dir:/etc/yum.repos.d:ro") + fi +} + +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_container 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 + ;; + --image) + [ "$#" -ge 2 ] || die "$1 requires a value" + g_image="$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 + ;; + --cpus) + [ "$#" -ge 2 ] || die "$1 requires a value" + g_cpus="$2" + shift 2 + ;; + --memory) + [ "$#" -ge 2 ] || die "$1 requires a value" + g_memory="$2" + shift 2 + ;; + --pids-limit) + [ "$#" -ge 2 ] || die "$1 requires a value" + g_pids_limit="$2" + shift 2 + ;; + --systemd) + [ "$#" -ge 2 ] || die "$1 requires a value" + g_systemd_mode="$2" + shift 2 + ;; + --repo-dir) + [ "$#" -ge 2 ] || die "$1 requires a value" + g_repo_dir="$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_pids_limit" || die "pids limit must be a positive integer" +if ! [[ "$g_cpus" =~ ^[0-9]+([.][0-9]+)?$ ]] || + ! awk -v cpus="$g_cpus" 'BEGIN { exit !(cpus > 0) }'; then + die "cpus must be a positive number" +fi +[[ "$g_memory" =~ ^[1-9][0-9]*[bkmgBKMG]?$ ]] || die "memory must be a Docker size such as 512m or 2g" +case "$g_systemd_mode" in + auto | always | never) ;; + *) die "systemd mode must be auto, always, or never" ;; +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 +if [ -n "$g_repo_dir" ]; then + [ -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" + g_repo_args=(-v "$g_repo_dir:/etc/yum.repos.d:ro") +fi + +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" +mkdir -p "$g_run_dir/input" "$g_run_dir/packages" +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" +select_docker_command +trap cleanup_containers EXIT +trap 'handle_parent_signal 130' INT +trap 'handle_parent_signal 143' TERM HUP +"${g_docker[@]}" image inspect "$g_image" >/dev/null 2>&1 || + die "image is not local: $g_image (load it from the OpenCloudOS domestic mirror or choose --image)" +"${g_docker[@]}" run --rm --entrypoint /bin/bash "$g_image" -c 'exit 0' >/dev/null 2>&1 || + die "image does not provide a working /bin/bash: $g_image" + +if [ "$g_systemd_mode" != "never" ] && + "${g_docker[@]}" run --rm --entrypoint /bin/bash "$g_image" -c 'test -x /sbin/init && command -v systemctl >/dev/null' >/dev/null 2>&1; then + g_systemd_capable=1 +elif [ "$g_systemd_mode" = "always" ]; then + die "--systemd always requested, but the image has no /sbin/init and systemctl" +fi + +echo "Docker package test: image=$g_image jobs=$g_jobs total=$g_total systemd_mode=$g_systemd_mode" +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 "${g_pids[0]}" || true + g_pids=("${g_pids[@]:1}") + 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-docker-$(date '+%Y%m%d-%H%M%S').txt" +aggregate_results "$g_report" diff --git a/testcase/lib/docker-package-worker.sh b/testcase/lib/docker-package-worker.sh new file mode 100755 index 0000000000000000000000000000000000000000..47dc2a56b04b7af9cf164a6480d0c6f9ee5743bd --- /dev/null +++ b/testcase/lib/docker-package-worker.sh @@ -0,0 +1,361 @@ +#!/bin/bash +# Run one package's install/command/service/remove checks inside a container. + +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="container 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 failures=0 + local checked=0 + local first_failure="" + local system_state="" + + 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/ '$NF !~ /@/ { print $NF }' | sort -u >"$units_list" || true + + if [ ! -s "$units_list" ]; then + g_service="PASS" + g_service_reason="package provides no non-template systemd 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" + return 0 + fi + if ! command -v systemctl >/dev/null 2>&1 || [ ! -d /run/systemd/system ]; then + g_service="SKIP" + g_service_reason="container has no running systemd PID 1" + return 0 + fi + system_state="$(systemctl is-system-running 2>/dev/null || true)" + case "$system_state" in + running | degraded) ;; + *) + g_service="SKIP" + g_service_reason="container systemd is not usable (state: ${system_state:-unknown})" + return 0 + ;; + esac + + : >"$service_log" + if ! run_timed systemctl daemon-reload >>"$service_log" 2>&1; then + g_service="FAIL" + g_service_reason="systemctl daemon-reload failed before unit testing" + return 1 + fi + while IFS= read -r unit; do + local start_ok=0 + local active_ok=0 + local stop_ok=0 + checked=$((checked + 1)) + + echo "===== $unit =====" >>"$service_log" + if run_timed systemctl --no-pager start "$unit" >>"$service_log" 2>&1; then + start_ok=1 + fi + systemctl --no-pager --full status "$unit" >>"$service_log" 2>&1 || true + if run_timed systemctl is-active --quiet "$unit" >>"$service_log" 2>&1; then + active_ok=1 + fi + if run_timed systemctl --no-pager stop "$unit" >>"$service_log" 2>&1; then + stop_ok=1 + fi + printf 'conclusion: start=%s status/is-active=%s stop=%s\n' \ + "$([ "$start_ok" -eq 1 ] && echo PASS || echo FAIL)" \ + "$([ "$active_ok" -eq 1 ] && echo PASS || echo FAIL)" \ + "$([ "$stop_ok" -eq 1 ] && echo PASS || echo FAIL)" >>"$service_log" + + if [ "$start_ok" -ne 1 ] || [ "$active_ok" -ne 1 ] || [ "$stop_ok" -ne 1 ]; then + failures=$((failures + 1)) + [ -n "$first_failure" ] || first_failure="$unit (start=$start_ok active=$active_ok stop=$stop_ok)" + fi + done <"$units_list" + + if [ "$failures" -eq 0 ]; then + g_service="PASS" + g_service_reason="all ${checked} unit(s) passed start/status/stop" + return 0 + fi + g_service="FAIL" + g_service_reason="${failures}/${checked} unit(s) failed; $first_failure" + return 1 +} + +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-docker.sh b/testcase/tests/test-command-execute-docker.sh new file mode 100755 index 0000000000000000000000000000000000000000..ca3daff097c44aae9a12a47c93a65722320db3ee --- /dev/null +++ b/testcase/tests/test-command-execute-docker.sh @@ -0,0 +1,440 @@ +#!/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_fake_docker() { + local fake="$g_test_dir/fake-docker" + cat >"$fake" <<'EOF' +#!/bin/bash +set -u + +case "${1:-}" in + info) + exit 0 + ;; + image) + [ "${2:-}" = "inspect" ] && exit 0 + ;; + ps) + exit 0 + ;; + inspect) + echo "${FAKE_DOCKER_INSPECT_STATE:-running}" + exit 0 + ;; + exec) + if [[ " $* " == *" systemctl is-system-running "* ]]; then + echo degraded + exit 0 + fi + exit 1 + ;; + logs) + echo "${FAKE_DOCKER_LOG_TEXT:-fake init log}" + exit 0 + ;; + rm) + exit 0 + ;; + run) + args=("$@") + joined=" $* " + [ -z "${FAKE_DOCKER_EVENTS_LOG:-}" ] || printf 'run %s\n' "$*" >>"$FAKE_DOCKER_EVENTS_LOG" + if [[ "$joined" == *" -d "* ]]; then + [ -z "${FAKE_DOCKER_SYSTEMD_MARKER:-}" ] || printf 'started\n' >>"$FAKE_DOCKER_SYSTEMD_MARKER" + if [ -n "${FAKE_DOCKER_SYSTEMD_DELAY:-}" ]; then + sleep "$FAKE_DOCKER_SYSTEMD_DELAY" + fi + echo fake-container-id + exit 0 + fi + if [[ "$joined" != *" /opt/yum-ci-pkg-test/docker-package-worker.sh "* ]]; then + exit 0 + fi + + host_output="" + for ((i = 0; i < ${#args[@]}; i++)); do + if [ "${args[$i]}" = "-v" ]; then + i=$((i + 1)) + [ -z "${FAKE_MOUNTS_LOG:-}" ] || printf '%s\n' "${args[$i]}" >>"$FAKE_MOUNTS_LOG" + case "${args[$i]}" in + *:/test-output) host_output="${args[$i]%:/test-output}" ;; + esac + fi + done + count="${#args[@]}" + package="${args[$((count - 5))]}" + [ -n "$host_output" ] || exit 98 + 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) + sleep 3 + printf 'slow\tslow.src.rpm\tslow-1-1.x86_64\tPASS\tPASS\tPASS\tPASS\t-\t-\t-\t-\tPASS\n' + ;; + esac >"$host_output/result.tsv" + exit 0 + ;; +esac +exit 1 +EOF + chmod +x "$fake" + echo "$fake" +} + +test_container_timeout() { + local case_dir="$g_test_dir/timeout" + local fake_docker + local report + mkdir -p "$case_dir" + printf 'slow\n' >"$case_dir/packages.list" + : >"$case_dir/desktop.list" + : >"$case_dir/service.list" + fake_docker="$(make_fake_docker)" + + SECONDS=0 + if FAKE_DOCKER_SYSTEMD_DELAY=3 YUM_CI_DOCKER_BIN="$fake_docker" \ + "$g_topdir/testcase/command-execute-docker.sh" --systemd always \ + --package-timeout 1 --image fake:latest --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 container-level timeout returned success" + fi + [ "$SECONDS" -lt 3 ] || fail "systemd startup escaped the package timeout (${SECONDS}s elapsed)" + + report="$(find "$case_dir/output" -maxdepth 1 -name 'package-command-result-docker-*.txt' -print -quit)" + [ -n "$report" ] || fail "timeout report was not created" + assert_contains "$report" '待测包总数: 1 | 通过: 0 | 失败: 1 | 跳过: 0' + assert_contains "$report" 'container-level timeout after 1s' +} + +test_help_and_validation() { + "$g_topdir/testcase/command-execute-docker.sh" --help >/dev/null + if "$g_topdir/testcase/command-execute-docker.sh" --jobs 0 a b c >/dev/null 2>&1; then + fail "--jobs 0 was accepted" + fi + if "$g_topdir/testcase/command-execute-docker.sh" --cpus 0.0 a b c >/dev/null 2>&1; then + fail "--cpus 0.0 was accepted" + fi +} + +test_auto_fallback_log_preserved() { + local case_dir="$g_test_dir/fallback" + local fake_docker + local container_log + mkdir -p "$case_dir" + printf 'good\n' >"$case_dir/packages.list" + : >"$case_dir/desktop.list" + : >"$case_dir/service.list" + fake_docker="$(make_fake_docker)" + + FAKE_DOCKER_INSPECT_STATE=exited FAKE_DOCKER_LOG_TEXT=init-diagnostic \ + YUM_CI_DOCKER_BIN="$fake_docker" \ + "$g_topdir/testcase/command-execute-docker.sh" --systemd auto \ + --image fake:latest --output-dir "$case_dir/output" \ + "$case_dir/packages.list" "$case_dir/desktop.list" "$case_dir/service.list" \ + >"$case_dir/run.log" 2>&1 || fail "auto fallback did not complete" + + container_log="$(find "$case_dir/output" -name container.log -print -quit)" + [ -n "$container_log" ] || fail "fallback container log was not created" + assert_contains "$container_log" 'systemd container did not become ready' + assert_contains "$container_log" 'init-diagnostic' + assert_contains "$container_log" 'falling back to a one-shot container' +} + +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_systemd_output="$case_dir/no-systemd-output" + mkdir -p "$fakebin" + + cat >"$fakebin/dnf" <<'EOF' +#!/bin/bash +exit 0 +EOF + cat >"$fakebin/file" <<'EOF' +#!/bin/bash +exit 0 +EOF + cat >"$fakebin/strace" <<'EOF' +#!/bin/bash +exit 0 +EOF + cat >"$fakebin/systemctl" <<'EOF' +#!/bin/bash +if [ "${1:-}" = "is-system-running" ]; then + echo offline +fi +exit 1 +EOF + cat >"$fakebin/rpm" <<'EOF' +#!/bin/bash +if [ "${1:-}" = "-ql" ]; then + printf '%s\n' \ + /usr/share/doc/fakepkg/documentation.service \ + /usr/lib/systemd/system/fake.service + 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/docker-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/systemd-units.list" 'fake.service' + if grep -Fq documentation.service "$special_output/systemd-units.list"; then + fail "a non-systemd documentation path was treated as a unit" + fi + + PATH="$fakebin:$PATH" "$g_topdir/testcase/lib/docker-package-worker.sh" \ + fakepkg "$case_dir/empty.list" "$case_dir/empty.list" "$no_systemd_output" 5 + assert_contains "$no_systemd_output/result.tsv" $'PASS\tPASS\tSKIP\tPASS' + if ! grep -Eq 'container has no running systemd PID 1|container systemd is not usable' \ + "$no_systemd_output/result.tsv"; then + fail "no-systemd service branch did not record an explicit SKIP reason" + fi +} + +test_main_signal_terminates_package_session() { + local case_dir="$g_test_dir/main-signal" + local fake_docker + local main_pid + local main_rc + local marker="$case_dir/systemd-started" + local events="$case_dir/docker-events.log" + mkdir -p "$case_dir" + printf 'slow\n' >"$case_dir/packages.list" + : >"$case_dir/desktop.list" + : >"$case_dir/service.list" + fake_docker="$(make_fake_docker)" + + FAKE_DOCKER_SYSTEMD_DELAY=3 FAKE_DOCKER_SYSTEMD_MARKER="$marker" \ + FAKE_DOCKER_EVENTS_LOG="$events" YUM_CI_DOCKER_BIN="$fake_docker" \ + "$g_topdir/testcase/command-execute-docker.sh" --systemd auto \ + --package-timeout 30 --image fake:latest --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 "systemd package session 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 after SIGTERM" + + for _ in $(seq 1 35); do + if pgrep -f -- "$fake_docker" >/dev/null 2>&1; then + fail "a package descendant survived main-process SIGTERM" + fi + sleep 0.1 + done + if grep -Fq -- '-fallback' "$events"; then + fail "an auto-fallback container was created after main-process SIGTERM" + fi +} + +test_spawn_tracking_signal_race() { + local case_dir="$g_test_dir/spawn-race" + local fake_docker + local main_pid + local main_rc + local debug_marker="$case_dir/debug-signal-fired" + local events="$case_dir/docker-events.log" + local bash_env="$case_dir/bash-env" + mkdir -p "$case_dir" + printf 'slow\n' >"$case_dir/packages.list" + : >"$case_dir/desktop.list" + : >"$case_dir/service.list" + fake_docker="$(make_fake_docker)" + + cat >"$bash_env" <<'EOF' +set -T +trap 'case "$BASH_COMMAND" in g_pids+=*) trap - DEBUG; : >"$YUM_CI_DEBUG_SIGNAL_MARKER"; kill -TERM "$$" ;; esac' DEBUG +EOF + + BASH_ENV="$bash_env" YUM_CI_DEBUG_SIGNAL_MARKER="$debug_marker" \ + FAKE_DOCKER_SYSTEMD_DELAY=3 FAKE_DOCKER_EVENTS_LOG="$events" \ + YUM_CI_DOCKER_BIN="$fake_docker" \ + /bin/bash "$g_topdir/testcase/command-execute-docker.sh" --systemd auto \ + --package-timeout 30 --image fake:latest --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 + [ -e "$debug_marker" ] || fail "DEBUG trap did not hit the pre-tracking spawn window" + [ "$main_rc" -eq 143 ] || fail "spawn-race main process returned $main_rc instead of 143" + + for _ in $(seq 1 35); do + if pgrep -f -- "$fake_docker" >/dev/null 2>&1; then + fail "spawn-race package descendant survived main-process SIGTERM" + fi + sleep 0.1 + done + if grep -Fq -- '-fallback' "$events"; then + fail "spawn-race signal allowed a fallback container to start" + fi +} + +test_double_signal_during_cleanup() { + local case_dir="$g_test_dir/double-signal" + local fake_docker + local main_pid + local main_rc + local phases="$case_dir/signal-phases" + local events="$case_dir/docker-events.log" + local bash_env="$case_dir/bash-env" + mkdir -p "$case_dir" + printf 'slow\n' >"$case_dir/packages.list" + : >"$case_dir/desktop.list" + : >"$case_dir/service.list" + fake_docker="$(make_fake_docker)" + + cat >"$bash_env" <<'EOF' +set -T +trap 'case "$BASH_COMMAND" in g_pids+=*) if [ ! -e "$YUM_CI_DEBUG_PHASES" ]; then echo first >"$YUM_CI_DEBUG_PHASES"; kill -TERM "$$"; fi ;; "pkill -TERM -s "*) if [ "$(tail -n 1 "$YUM_CI_DEBUG_PHASES" 2>/dev/null)" = first ]; then echo second >>"$YUM_CI_DEBUG_PHASES"; kill -TERM "$$"; fi ;; esac' DEBUG +EOF + + BASH_ENV="$bash_env" YUM_CI_DEBUG_PHASES="$phases" \ + FAKE_DOCKER_SYSTEMD_DELAY=3 FAKE_DOCKER_EVENTS_LOG="$events" \ + YUM_CI_DOCKER_BIN="$fake_docker" \ + /bin/bash "$g_topdir/testcase/command-execute-docker.sh" --systemd auto \ + --package-timeout 30 --image fake:latest --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 + [ "$(tr '\n' ',' <"$phases")" = 'first,second,' ] || + fail "double-signal DEBUG trap did not hit both cleanup windows" + [ "$main_rc" -eq 143 ] || fail "double-signal main process returned $main_rc instead of 143" + + for _ in $(seq 1 35); do + if pgrep -f -- "$fake_docker" >/dev/null 2>&1; then + fail "double-signal package descendant survived cleanup" + fi + sleep 0.1 + done + if grep -Fq -- '-fallback' "$events"; then + fail "double signal allowed a fallback container to start" + fi +} + +test_aggregation_and_reconciliation() { + local case_dir="$g_test_dir/aggregate" + local fake_docker + local report + local mounts_log="$case_dir/mounts.log" + mkdir -p "$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" + fake_docker="$(make_fake_docker)" + + if FAKE_MOUNTS_LOG="$mounts_log" YUM_CI_DOCKER_BIN="$fake_docker" \ + "$g_topdir/testcase/command-execute-docker.sh" --systemd never -j 2 \ + --image fake:latest --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 installfail returned success" + fi + + report="$(find "$case_dir/output" -maxdepth 1 -name 'package-command-result-docker-*.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" '环境: docker | 并发度: 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 -Ec '/packages/[^:]+:/test-output$' "$mounts_log")" -eq 7 ] || + fail "each package must mount only its own output directory" + [ "$(grep -Fc ':/test-input/desktop.list:ro' "$mounts_log")" -eq 7 ] || + fail "desktop list was not mounted read-only for each package" + [ "$(grep -Fc ':/test-input/service.list:ro' "$mounts_log")" -eq 7 ] || + fail "service list was not mounted read-only for each package" + if grep ':/test-output$' "$mounts_log" | grep -vq '/packages/[^:]*:/test-output$'; then + fail "a container can write outside its package output directory" + fi +} + +test_help_and_validation +test_aggregation_and_reconciliation +test_container_timeout +test_auto_fallback_log_preserved +test_worker_skip_branches_and_unit_filter +test_main_signal_terminates_package_session +test_spawn_tracking_signal_race +test_double_signal_during_cleanup +echo "PASS: command-execute-docker scheduler/report tests"