diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 978a92cd..4976e11a 100755 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -119,12 +119,12 @@ reload_pm2 if [[ $AutoStartBot == true ]]; then log_with_style "INFO" "🤖 3. 启动 bot..." - nohup ql bot >$dir_log/bot.log 2>&1 & + nohup ql bot >"$dir_log/bot.log" 2>&1 & fi if [[ $EnableExtraShell == true ]]; then log_with_style "INFO" "🛠️ 4. 执行自定义脚本..." - nohup ql extra >$dir_log/extra.log 2>&1 & + nohup ql extra >"$dir_log/extra.log" 2>&1 & fi log_with_style "SUCCESS" "🎉 容器启动成功!" diff --git a/shell/api.sh b/shell/api.sh index ecacf56a..03926e99 100755 --- a/shell/api.sh +++ b/shell/api.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash create_token() { - local token_command="ts-node-transpile-only ${dir_root}/back/token.ts" local token_file="${dir_root}/static/build/token.js" if [[ -f $token_file ]]; then - token_command="node ${token_file}" + __ql_token__=$(node "$token_file") + else + __ql_token__=$(ts-node-transpile-only "${dir_root}/back/token.ts") fi - __ql_token__=$(eval "$token_command") } get_token() { diff --git a/shell/otask.sh b/shell/otask.sh index f826bfa5..20e1b70f 100755 --- a/shell/otask.sh +++ b/shell/otask.sh @@ -39,16 +39,16 @@ random_delay() { gen_array_scripts() { local dir_current=$(pwd) local i="-1" - cd $dir_scripts - for file in $(ls); do + cd "$dir_scripts" || return $? + for file in *; do if [[ -f $file ]] && [[ $file == *.js && $file != sendNotify.js ]]; then let i++ - array_scripts[i]=$(echo "$file" | perl -pe "s|$dir_scripts/||g") - array_scripts_name[i]=$(grep "new Env" $file | awk -F "\(" '{print $2}' | awk -F "\)" '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1) + array_scripts[i]=$file + array_scripts_name[i]=$(grep -- "new Env" "$file" | awk -F "\(" '{print $2}' | awk -F "\)" '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1) [[ -z ${array_scripts_name[i]} ]] && array_scripts_name[i]="<未识别出活动名称>" fi done - cd $dir_current + cd "$dir_current" || return $? } ## 使用说明 @@ -72,11 +72,11 @@ usage() { ## run nohup,$1:文件名,不含路径,带后缀 run_nohup() { local file_name=$1 - nohup node $file_name &>$log_path & + nohup node "$file_name" &>"$log_path" & } env_str_to_array() { - . $file_env + . "$file_env" local IFS="&" read -ra array <<<"${!env_param}" array_length=${#array[@]} @@ -122,7 +122,7 @@ enter_script_workdir() { _target_dir="${dir_scripts}/${work_dir}" fi if [[ -d "${_target_dir}" ]]; then - cd "${_target_dir}" + cd "${_target_dir}" || return $? if [[ ${file_param} =~ "/" ]]; then local script_name="${file_param##*/}" if [[ "${use_dot_prefix}" == "true" ]]; then @@ -136,13 +136,13 @@ enter_script_workdir() { t '警告:工作目录不存在 %s' "${_target_dir}" fi - cd $dir_scripts + cd "$dir_scripts" || return $? if [[ ${file_param} =~ "/" ]]; then local script_dir="${file_param%/*}" local script_name="${file_param##*/}" if [[ -d ${script_dir} ]]; then - cd ${script_dir} + cd "$script_dir" || return $? if [[ "${use_dot_prefix}" == "true" ]]; then file_param="./${script_name}" else @@ -159,12 +159,12 @@ run_normal() { random_delay "$file_param" fi - enter_script_workdir + enter_script_workdir || return $? if [[ $isJsOrPythonFile == 'false' ]]; then clear_non_sh_env fi - run_task_command $which_program $file_param "${script_params[@]}" + run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}" } handle_env_split() { @@ -175,7 +175,7 @@ handle_env_split() { env_str_to_array local tempArr=$(echo $num_param | sed "s/-max/-${array_length}/g" | sed "s/max-/${array_length}-/g" | perl -pe "s|(\d+)(-\|~\|_)(\d+)|{\1..\3}|g") local runArr=($(eval echo $tempArr)) - array_run=($(awk -v RS=' ' '!a[$1]++' <<<${runArr[@]})) + array_run=($(awk -v RS=' ' '!a[$1]++' <<<"${runArr[*]}")) } ## 并发执行时,设定的 RandomDelay 不会生效,即所有任务立即执行 @@ -192,10 +192,10 @@ run_concurrent() { time=$(date "+$mtime_format") single_log_time=$(format_log_time "$mtime_format" "$time") - enter_script_workdir + enter_script_workdir || return $? local j=0 - for i in ${array_run[@]}; do + for i in "${array_run[@]}"; do single_log_path="$dir_log/$log_dir/${single_log_time}_$((j + 1)).log" let j++ @@ -203,16 +203,16 @@ run_concurrent() { export "${env_param}=${array[$i - 1]}" clear_non_sh_env fi - eval envParam="${env_param}" numParam="${i}" run_task_command $which_program $file_param "${script_params[@]}" &>$single_log_path & + envParam="${env_param}" numParam="${i}" run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}" &>"$single_log_path" & done wait local k=0 - for i in ${array_run[@]}; do + for i in "${array_run[@]}"; do single_log_path="$dir_log/$log_dir/${single_log_time}_$((k + 1)).log" let k++ - cat $single_log_path - [[ -f $single_log_path ]] && rm -f $single_log_path + cat "$single_log_path" + [[ -f $single_log_path ]] && rm -f "$single_log_path" done } @@ -229,7 +229,7 @@ run_designated() { if [[ $isJsOrPythonFile == 'false' ]]; then local n=0 - for i in ${array_run[@]}; do + for i in "${array_run[@]}"; do array_str[n]=${array[$i - 1]} let n++ done @@ -241,9 +241,9 @@ run_designated() { clear_non_sh_env fi - enter_script_workdir + enter_script_workdir || return $? - envParam="${env_param}" numParam="${num_param}" run_task_command $which_program $file_param "${script_params[@]}" + envParam="${env_param}" numParam="${num_param}" run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}" } ## 运行其他命令 @@ -273,7 +273,7 @@ run_else() { if [[ -n "$script_for_dir" ]]; then local saved_file_param="$file_param" file_param="$script_for_dir" - enter_script_workdir true + enter_script_workdir true || return $? local adjusted_script="$file_param" file_param="$saved_file_param" @@ -289,17 +289,17 @@ run_else() { set -- "${new_args[@]}" else # 没有找到脚本参数,只 cd 到 scripts 目录 - enter_script_workdir true + enter_script_workdir true || return $? shift fi else # file_param 本身就是脚本,直接用 enter_script_workdir 处理 - enter_script_workdir true + enter_script_workdir true || return $? shift fi clear_non_sh_env - run_task_command $which_program $file_param "$@" + run_task_command ${which_program:+"$which_program"} "$file_param" "$@" } check_file() { @@ -313,12 +313,14 @@ check_file() { export PREV_NODE_OPTIONS="${NODE_OPTIONS:=}" export PREV_PYTHONPATH="${PYTHONPATH:=}" if [[ $1 == *.js ]] || [[ $1 == *.ts ]] || [[ $1 == *.mjs ]]; then - export NODE_OPTIONS="-r ${file_preload_js} ${NODE_OPTIONS}" + local preload_path=${file_preload_js//\\/\\\\} + preload_path=${preload_path//\"/\\\"} + export NODE_OPTIONS="-r \"${preload_path}\" ${NODE_OPTIONS}" else export PYTHONPATH="${dir_preload}:${dir_config}:${PYTHONPATH}" fi else - . $file_env + . "$file_env" fi fi } diff --git a/shell/pub.sh b/shell/pub.sh index 1d126bc6..3b045282 100755 --- a/shell/pub.sh +++ b/shell/pub.sh @@ -1,4 +1,12 @@ #!/usr/bin/env bash + +# BSD grep accepts \d as a digit; GNU grep treats it as a literal d. +string=$(awk '$1 == "version:" { print $2; exit }' version.yaml) || exit 1 +if [[ ! $string =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[[:alnum:].-]+)?(\+[[:alnum:].-]+)?$ ]]; then + printf 'Invalid release version: %s\n' "$string" >&2 + exit 1 +fi +version="v$string" echo -e "开始发布" echo -e "切换master分支" @@ -9,8 +17,6 @@ git push --set-upstream origin master -f echo -e "更新cdn文件" ts-node-transpile-only sample/tool.ts -string=$(cat version.yaml | grep "version" | egrep "[^ ]*" -o | egrep "\d\.*") -version="v$string" echo -e "当前版本$version" echo -e "删除已经存在的本地tag" diff --git a/shell/rmlog.sh b/shell/rmlog.sh index cc67ff24..30059d6b 100755 --- a/shell/rmlog.sh +++ b/shell/rmlog.sh @@ -3,42 +3,47 @@ days=$1 remove_js_log() { - local log_full_path_list=$(find $dir_log -name "*.log") - local diff_time - for log in $log_full_path_list; do - local log_date=$(echo $log | awk -F "/" '{print $NF}' | cut -c1-10) + local log log_date log_timestamp diff_time log_path encoded_path result + local now + now=$(date +%s) || return $? + while IFS= read -r -d '' log; do + log_date=${log##*/} + log_date=${log_date:0:10} if ! [[ $log_date =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then if [[ $is_macos -eq 1 ]]; then - log_date=$(stat -f %Sm -t "%Y-%m-%d" "$log") + log_date=$(stat -f %Sm -t "%Y-%m-%d" "$log") || continue else log_date=$(stat -c %y "$log" | cut -d ' ' -f 1) fi fi if [[ $is_macos -eq 1 ]]; then - diff_time=$(($(date +%s) - $(date -j -f "%Y-%m-%d" "$log_date" +%s))) + # BSD date otherwise fills the omitted time from the current clock. + log_timestamp=$(date -j -f "%Y-%m-%d %H:%M:%S" "$log_date 00:00:00" +%s) || continue else - diff_time=$(($(date +%s) - $(date +%s -d "$log_date"))) + log_timestamp=$(date +%s -d "$log_date") || continue fi + diff_time=$((now - log_timestamp)) if [[ $diff_time -gt $((${days} * 86400)) ]]; then - local log_path=$(echo "$log" | sed "s,${dir_log}/,,g") - local result=$(find_cron_api "log_path=$log_path") + log_path=${log#"$dir_log"/} + encoded_path=$(printf '%s' "$log_path" | jq -sRr @uri) || continue + result=$(find_cron_api "log_path=$encoded_path") t '查询文件 %s' "$log_path" if [[ -z $result ]]; then t '删除中~' - rm -vf $log + rm -vf -- "$log" else t '正在被 %s 使用,跳过~' "$result" fi fi - done + done < <(find "$dir_log" -type f -name "*.log" -print0) } remove_empty_dir() { - cd $dir_log - for dir in $(ls); do - if [[ -d $dir ]] && [[ -z $(ls $dir) ]]; then - rm -rf $dir - fi + local dir + for dir in "$dir_log"/*/ "$dir_log"/.[!.]*/ "$dir_log"/..?*/; do + [[ -d "$dir" ]] || continue + # rmdir also protects a directory populated between discovery and removal. + rmdir -- "$dir" 2>/dev/null || true done } diff --git a/shell/share.sh b/shell/share.sh index 575e8288..eb49502f 100755 --- a/shell/share.sh +++ b/shell/share.sh @@ -77,16 +77,19 @@ load_ql_envs() { } import_config() { - [[ -f $file_config_user ]] && . $file_config_user - [[ -f $dir_preload/lang_env.sh ]] && . $dir_preload/lang_env.sh + [[ -f $file_config_user ]] && . "$file_config_user" + [[ -f $dir_preload/lang_env.sh ]] && . "$dir_preload/lang_env.sh" - # 加载语言包(bash 4+ 支持 declare -A,不兼容时回退输出中文 key) + # Language packs need global associative arrays (Bash 4.2+). Probe the + # capability so Bash 3.2 and 4.0/4.1 retain the Chinese-key fallback. local lang=${QL_LANG:-zh} local lang_file="$dir_shell/lang/${lang}.sh" - if [[ ${BASH_VERSINFO[0]} -ge 4 ]] && [[ -f $lang_file ]]; then - . $lang_file - elif [[ ${BASH_VERSINFO[0]} -ge 4 ]]; then - . "$dir_shell/lang/zh.sh" + if (declare -gA __ql_lang_probe) 2>/dev/null; then + if [[ -f $lang_file ]]; then + . "$lang_file" + elif [[ -f "$dir_shell/lang/zh.sh" ]]; then + . "$dir_shell/lang/zh.sh" + fi fi load_ql_envs @@ -132,7 +135,7 @@ unset_proxy() { make_dir() { local dir=$1 if [[ ! -d $dir ]]; then - mkdir -p $dir + mkdir -p -- "$dir" fi } @@ -176,57 +179,57 @@ define_cmd() { } fix_config() { - make_dir $dir_tmp - make_dir $dir_static - make_dir $dir_data - make_dir $dir_config - make_dir $dir_log - make_dir $dir_db - make_dir $dir_scripts - make_dir $dir_list_tmp - make_dir $dir_repo - make_dir $dir_raw - make_dir $dir_update_log - make_dir $dir_dep + make_dir "$dir_tmp" + make_dir "$dir_static" + make_dir "$dir_data" + make_dir "$dir_config" + make_dir "$dir_log" + make_dir "$dir_db" + make_dir "$dir_scripts" + make_dir "$dir_list_tmp" + make_dir "$dir_repo" + make_dir "$dir_raw" + make_dir "$dir_update_log" + make_dir "$dir_dep" if [[ ! -s $file_config_user ]]; then - cp -f $file_config_sample $file_config_user + cp -f "$file_config_sample" "$file_config_user" fi if [[ ! -f $file_task_before ]]; then - cp -f $file_task_sample $file_task_before + cp -f "$file_task_sample" "$file_task_before" fi if [[ ! -f $file_task_after ]]; then - cp -f $file_task_sample $file_task_after + cp -f "$file_task_sample" "$file_task_after" fi if [[ ! -f $file_extra_shell ]]; then - cp -f $file_extra_sample $file_extra_shell + cp -f "$file_extra_sample" "$file_extra_shell" fi if [[ ! -s $file_notify_py ]]; then - cp -f $file_notify_py_sample $file_notify_py + cp -f "$file_notify_py_sample" "$file_notify_py" fi if [[ ! -s $file_notify_js ]]; then - cp -f $file_notify_js_sample $file_notify_js + cp -f "$file_notify_js_sample" "$file_notify_js" fi if [[ ! -s $file_test_js ]]; then - cp -f $file_test_js_sample $file_test_js + cp -f "$file_test_js_sample" "$file_test_js" fi if [[ ! -s $file_test_py ]]; then - cp -f $file_test_py_sample $file_test_py + cp -f "$file_test_py_sample" "$file_test_py" fi if [[ ! -s $dep_notify_js ]]; then - cp -f $file_notify_js_sample $dep_notify_js + cp -f "$file_notify_js_sample" "$dep_notify_js" fi if [[ ! -s $dep_notify_py ]]; then - cp -f $file_notify_py_sample $dep_notify_py + cp -f "$file_notify_py_sample" "$dep_notify_py" fi } @@ -246,17 +249,17 @@ npm_install_2() { local dir_current=$(pwd) local dir_work=$1 - cd $dir_work + cd "$dir_work" || return $? t '安装 %s 依赖包...\n' "$dir_work" npm_install_sub - cd $dir_current + cd "$dir_current" || return $? } diff_and_copy() { local copy_source=$1 local copy_to=$2 - if [[ ! -s $copy_to ]] || [[ $(diff $copy_source $copy_to) ]]; then - cp -f $copy_source $copy_to + if [[ ! -s $copy_to ]] || [[ $(diff "$copy_source" "$copy_to") ]]; then + cp -f "$copy_source" "$copy_to" fi } @@ -265,12 +268,13 @@ git_clone_scripts() { local dir="$2" local branch="$3" local proxy="$4" - [[ $branch ]] && local part_cmd="-b $branch " + local branch_args=() + [[ $branch ]] && branch_args=(-b "$branch") t '开始拉取仓库 %s 到 %s\n' "${uniq_path}" "$dir" set_proxy "$proxy" - git clone -q --depth=1 $part_cmd $url $dir + git clone -q --depth=1 "${branch_args[@]}" "$url" "$dir" exit_status=$? unset_proxy @@ -283,7 +287,7 @@ random_range() { } delete_pm2() { - cd $dir_root + cd "$dir_root" || return $? # Try to delete PM2 processes, but don't fail if PM2 is not available pm2 delete ecosystem.config.js 2>/dev/null || true # Also try to kill any directly spawned node processes @@ -291,7 +295,7 @@ delete_pm2() { } reload_pm2() { - cd $dir_root + cd "$dir_root" || return $? restore_env_vars # Try to start PM2, but handle failures gracefully @@ -310,7 +314,7 @@ reload_pm2() { if [[ "$QL_CONTAINER" == "true" ]]; then nohup node static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 & else - nohup node static/build/app.js >$dir_log/qinglong.log 2>&1 & + nohup node static/build/app.js >"$dir_log/qinglong.log" 2>&1 & fi local node_pid=$! @@ -398,7 +402,7 @@ handle_task_start() { } run_task_before() { - . $file_task_before "$@" + . "$file_task_before" "$@" if [[ ${task_before:=} ]]; then t '执行前置命令\n' @@ -408,7 +412,7 @@ run_task_before() { } run_task_after() { - . $file_task_after "$@" + . "$file_task_after" "$@" if [[ ${task_after:=} ]]; then t '\n执行后置命令\n' diff --git a/shell/start-simplify.sh b/shell/start-simplify.sh index f016ada5..36e4ceaf 100644 --- a/shell/start-simplify.sh +++ b/shell/start-simplify.sh @@ -63,12 +63,12 @@ reload_pm2 if [[ $command != "reload" ]]; then if [[ $AutoStartBot == true ]]; then log_with_style "INFO" "🤖 4. 启动 bot..." - nohup ql bot >$dir_log/bot.log 2>&1 & + nohup ql bot >"$dir_log/bot.log" 2>&1 & fi if [[ $EnableExtraShell == true ]]; then log_with_style "INFO" "🛠️ 5. 执行自定义脚本..." - nohup ql extra >$dir_log/extra.log 2>&1 & + nohup ql extra >"$dir_log/extra.log" 2>&1 & fi pm2 startup diff --git a/shell/start.sh b/shell/start.sh index 5553c359..3da0734d 100644 --- a/shell/start.sh +++ b/shell/start.sh @@ -4,31 +4,46 @@ set -e set -x +# Configuration and language helpers are not loaded during bootstrap errors. +startup_message() { + local format="$1" + shift + printf -- "$format\n" "$@" >&2 +} + if [[ ! $QL_DIR ]]; then - npm_dir=$(npm root -g) - pnpm_dir=$(pnpm root -g) - if [[ -d "$npm_dir/@whyour/qinglong" ]]; then + npm_dir=$(npm root -g 2>/dev/null) || npm_dir="" + pnpm_dir="" + if command -v pnpm >/dev/null 2>&1; then + pnpm_dir=$(pnpm root -g 2>/dev/null) || pnpm_dir="" + fi + if [[ -n "$npm_dir" && -d "$npm_dir/@whyour/qinglong" ]]; then QL_DIR="$npm_dir/@whyour/qinglong" - elif [[ -d "$pnpm_dir/@whyour/qinglong" ]]; then + elif [[ -n "$pnpm_dir" && -d "$pnpm_dir/@whyour/qinglong" ]]; then QL_DIR="$pnpm_dir/@whyour/qinglong" else - t '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装' + startup_message '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装' fi if [[ $QL_DIR ]]; then - t '请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务' "$QL_DIR" + startup_message '请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务' "$QL_DIR" fi exit 1 fi +if [[ $QL_DIR != /* ]]; then + startup_message 'QL_DIR 必须为绝对路径,例如 /ql' + exit 1 +fi + if [[ ! $QL_DATA_DIR ]]; then - t '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量' + startup_message '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量' exit 1 fi -if [[ $QL_DATA_DIR != */data ]]; then - t 'QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中' +if [[ $QL_DATA_DIR != /*/data ]]; then + startup_message 'QL_DATA_DIR 必须为以 /data 结尾的绝对路径,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中' exit 1 fi @@ -38,7 +53,11 @@ if [[ $command != "reload" ]]; then # 安装依赖 os_name="${QL_OS_TYPE:-}" if [ -z "$os_name" ]; then - os_name=$(source /etc/os-release && echo "$ID") + if [[ -r /etc/os-release ]]; then + os_name=$(source /etc/os-release && echo "$ID") + else + os_name=$(uname -s) + fi fi # 非 root 用户使用 sudo @@ -69,7 +88,7 @@ if [[ $command != "reload" ]]; then $SUDO apt-get install -y git curl wget tzdata perl openssl jq nginx procps netcat-openbsd openssh-client ;; *) - t '暂不支持此系统部署 %s' "$os_name" + startup_message '暂不支持此系统部署 %s' "$os_name" exit 1 ;; esac diff --git a/shell/task-timeout.sh b/shell/task-timeout.sh index 4710a9a4..a230d599 100644 --- a/shell/task-timeout.sh +++ b/shell/task-timeout.sh @@ -36,7 +36,10 @@ run_task_command() { ) & timeout_watcher=$! set +m - trap '{ + finish_task_timeout() { + local code="$1" + trap - EXIT + { # Bash can report a killed job between any two cleanup commands. # Keep redirection active until the watchdog is fully reaped. kill -KILL -- "-$timeout_watcher" || : @@ -45,14 +48,19 @@ run_task_command() { if kill -TERM -- "-$timeout_worker" 2>/dev/null; then sleep 0.1; fi kill -KILL -- "-$timeout_worker" 2>/dev/null || : wait "$timeout_worker" 2>/dev/null || : - rm -rf -- "$timeout_dir"' EXIT - trap 'exit 130' INT - trap 'exit 143' TERM - trap 'exit 129' HUP - trap 'exit 131' QUIT + rm -rf -- "$timeout_dir" + exit "$code" + } + # Older Bash can omit a subshell's EXIT trap in sourced pipelines. Call + # cleanup explicitly on completion/signals, retaining EXIT as a fallback. + trap 'finish_task_timeout "$?"' EXIT + trap 'finish_task_timeout 130' INT + trap 'finish_task_timeout 143' TERM + trap 'finish_task_timeout 129' HUP + trap 'finish_task_timeout 131' QUIT # Do not clean up a timer whose process group has not initialized yet. while [[ ! -f "$timeout_dir/ready" ]]; do - kill -0 "$timeout_watcher" 2>/dev/null || exit 125 + kill -0 "$timeout_watcher" 2>/dev/null || finish_task_timeout 125 sleep 0.001 done wait "$timeout_worker" @@ -60,8 +68,8 @@ run_task_command() { if [[ -f "$timeout_dir/expired" ]]; then # Let the timer finish escalation even if the task leader exited first. wait "$timeout_watcher" 2>/dev/null || : - exit 124 + finish_task_timeout 124 fi - exit "$timeout_code" + finish_task_timeout "$timeout_code" ) } diff --git a/shell/task.sh b/shell/task.sh index 92bd620f..d5ab6e7d 100755 --- a/shell/task.sh +++ b/shell/task.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash dir_shell=$QL_DIR/shell -. $dir_shell/share.sh -. $dir_shell/api.sh -. $dir_shell/task-timeout.sh +. "$dir_shell/share.sh" +. "$dir_shell/api.sh" +. "$dir_shell/task-timeout.sh" trap 'single_hanle SIGINT' INT trap 'single_hanle SIGTERM' TERM @@ -48,7 +48,7 @@ handle_log_path() { fi if [[ -z ${ID:=} ]]; then - ID=$(cat $list_crontab_user | grep -E "$cmd_task.* $file_param" | perl -pe "s|.*ID=(.*) $cmd_task.* $file_param\.*|\1|" | head -1 | awk -F " " '{print $1}') + ID=$(cat "$list_crontab_user" | grep -E "$cmd_task.* $file_param" | perl -pe "s|.*ID=(.*) $cmd_task.* $file_param\.*|\1|" | head -1 | awk -F " " '{print $1}') fi local suffix="" if [[ ! -z $ID ]]; then @@ -89,10 +89,10 @@ handle_log_path() { log_path="$real_log_path" fi - cmd="2>&1 | tee -a $dir_log/$log_path" + printf -v cmd '2>&1 | tee -a %q' "$dir_log/$log_path" make_dir "$dir_log/$log_dir" if [[ "${no_tee:=}" == "true" ]]; then - cmd=">> $dir_log/$log_path 2>&1" + printf -v cmd '>> %q 2>&1' "$dir_log/$log_path" fi if [[ "${real_time:=}" == "true" ]]; then @@ -162,5 +162,5 @@ define_program "${task_shell_params[@]}" handle_log_path "${task_shell_params[@]}" init_begin_time -eval . $dir_shell/otask.sh "$cmd" +eval '. "$dir_shell/otask.sh"' "$cmd" exit 0 diff --git a/shell/update.sh b/shell/update.sh index 2008cc9a..0324d1df 100755 --- a/shell/update.sh +++ b/shell/update.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash dir_shell=$QL_DIR/shell -. $dir_shell/share.sh -. $dir_shell/api.sh +. "$dir_shell/share.sh" +. "$dir_shell/api.sh" load_ql_envs -. $dir_shell/env.sh +. "$dir_shell/env.sh" send_mark=$dir_shell/send_mark @@ -165,17 +165,20 @@ update_raw() { t '开始下载:%s 保存路径:%s\n' "${raw_url}" "$dir_raw/${raw_file_name}" set_proxy "$proxy" - wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" ${raw_url} - exit_status=$? + if wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" "$raw_url"; then + exit_status=0 + else + exit_status=$? + fi unset_proxy - if [[ $? -eq 0 ]]; then - mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}" + if [[ $exit_status -eq 0 ]]; then + mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}" || return $? t '下载 %s 成功...\n' "${raw_file_name}" - cd $dir_raw + cd "$dir_raw" || return $? local filename="raw_${raw_file_name}" - local cron_id=$(cat $list_crontab_user | grep -E "$cmd_task.* $filename" | perl -pe "s|.*ID=(.*) $cmd_task.* $filename\.*|\1|" | head -1 | awk -F " " '{print $1}') - cp -f $raw_file_name $dir_scripts/${filename} + local cron_id=$(cat "$list_crontab_user" | grep -E "$cmd_task.* $filename" | perl -pe "s|.*ID=(.*) $cmd_task.* $filename\.*|\1|" | head -1 | awk -F " " '{print $1}') + cp -f "$raw_file_name" "$dir_scripts/${filename}" || return $? if [[ -z $cron_id ]] && [[ ${autoAddCron} == true ]]; then cron_line=$( perl -ne "{ @@ -202,6 +205,7 @@ update_raw() { else t '下载 %s 失败,保留之前正常下载的版本...\n' "${raw_file_name}" [[ -f "$dir_raw/${raw_file_name}.new" ]] && rm -f "$dir_raw/${raw_file_name}.new" + return "$exit_status" fi } @@ -209,7 +213,7 @@ update_raw() { ## 调用用户自定义的extra.sh run_extra_shell() { if [[ -f $file_extra_shell ]]; then - . $file_extra_shell + . "$file_extra_shell" else t '%s文件不存在,跳过执行...\n' "$file_extra_shell" fi @@ -266,7 +270,8 @@ show_service_logs() { # Winston writes the active runtime log by date. Follow the filename so # size-based rotations are handled without involving the PM2 log files. local current_system_log="$system_log_dir/$(date +%F).log" - tail -n 0 --retry -F "$current_system_log" + # GNU and BSD tail both support -F; --retry is GNU-only and implied by -F. + tail -n 0 -F "$current_system_log" } ## 脚本用法 @@ -484,9 +489,9 @@ main() { local log_path="${log_dir}/${log_time}.log" local file_path="$dir_log/$log_path" - cmd="2>&1 | tee -a $file_path" + printf -v cmd '2>&1 | tee -a %q' "$file_path" if [[ "$no_tee" == "true" ]]; then - cmd=">> $file_path 2>&1" + printf -v cmd '>> %q 2>&1' "$file_path" fi if [[ "$real_time" == "true" ]]; then cmd="" @@ -532,19 +537,20 @@ main() { get_uniq_path "$p2" if [[ -n $p2 ]]; then update_raw "$p2" "$p3" "$p4" "$p5" + command_status=$? else t '命令输入错误...\n' eval usage $cmd fi ;; rmlog) - eval . $dir_shell/rmlog.sh "$p2" $cmd + eval '. "$dir_shell/rmlog.sh" "$p2"' "$cmd" ;; bot) - eval . $dir_shell/bot.sh $cmd + eval '. "$dir_shell/bot.sh"' "$cmd" ;; check) - eval . $dir_shell/check.sh $cmd + eval '. "$dir_shell/check.sh"' "$cmd" ;; resetlet) eval update_auth_config "\\\"retries\\\":0" "重置登录错误次数" $cmd diff --git a/test/back/qlServiceLogCommand.test.cjs b/test/back/qlServiceLogCommand.test.cjs index 74fab549..fa28585d 100644 --- a/test/back/qlServiceLogCommand.test.cjs +++ b/test/back/qlServiceLogCommand.test.cjs @@ -101,7 +101,7 @@ test('ql log follows the current Winston system log without PM2', () => { ); assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, `tail <-n> <0> <--retry> <-F> <${currentLog}>\n`); + assert.equal(result.stdout, `tail <-n> <0> <-F> <${currentLog}>\n`); assert.equal(fs.existsSync(path.join(testRoot, 'data/log/log')), false); }); diff --git a/test/back/shell-compatibility.test.cjs b/test/back/shell-compatibility.test.cjs new file mode 100644 index 00000000..0af5b9fa --- /dev/null +++ b/test/back/shell-compatibility.test.cjs @@ -0,0 +1,558 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); + +const repo = path.resolve(__dirname, '../..'); +const bash = process.env.QL_TEST_BASH || '/bin/bash'; +const source = (file) => fs.readFileSync(path.join(repo, file), 'utf8'); +function extract(file, name) { + const text = source(file); + const start = text.indexOf(`${name}() {`); + assert.ok(start >= 0, name); + return text.slice(start, text.indexOf('\n}', start) + 2); +} +function fixture(t, prefix = `ql compatibility '" $literal `) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const write = (name, content, executable = false) => { + const file = path.join(root, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content, { mode: executable ? 0o755 : 0o644 }); + return file; + }; + const run = (code, env = {}, args = [], cwd = root) => + spawnSync(bash, ['-c', code, 'compatibility-test', ...args], { + cwd, + env: { ...process.env, ...env }, + encoding: 'utf8', + timeout: 15000, + }); + return { root, write, run }; +} + +for (const [label, args] of [ + ['normal', ['nested folder/probe [1] $name.sh', '--']], + ['explicit bash', ['bash', 'nested folder/probe [1] $name.sh']], + [ + 'designated', + ['nested folder/probe [1] $name.sh', 'desi', 'QA_PANEL', '1-2', '--'], + ], + [ + 'concurrent', + ['nested folder/probe [1] $name.sh', 'conc', 'QA_PANEL', '1-2', '--'], + ], + ['automatic Node', ['nested folder/probe [1] $name.js', '--']], + ['explicit Node', ['node', 'nested folder/probe [1] $name.js']], +]) { + for (const logging of ['tee', 'file']) { + test(`${label} task keeps literal paths and arguments with ${logging} logging`, (t) => { + const f = fixture(t); + for (const name of ['share.sh', 'task.sh', 'otask.sh', 'task-timeout.sh']) + f.write(`shell/${name}`, source(`shell/${name}`)); + f.write( + 'shell/api.sh', + 'update_cron(){ :; }; record_cron_stat(){ :; }\n', + ); + f.write('shell/preload/env.sh', 'export QA_PANEL="one&two"\n'); + f.write('shell/preload/sitecustomize.js', '// fixture preloader\n'); + f.write( + 'data/config/config.sh', + 'DefaultCronRule="0 0 * * *"\nCommandTimeoutTime="2s"\n', + ); + f.write('data/config/task_before.sh', 'printf "BEFORE\\n"\n'); + f.write('data/config/task_after.sh', 'printf "AFTER\\n"\n'); + f.write('bin/pnpm', '#!/bin/bash\nprintf /fixture/global\n', true); + const literal = `argument with 'quotes' $literal ; echo NOT_EXECUTED`; + f.write( + 'data/scripts/nested folder/probe [1] $name.sh', + 'printf "ARG=%s\\nACCOUNT=%s\\n" "$1" "$QA_PANEL"\n', + ); + f.write( + 'data/scripts/nested folder/probe [1] $name.js', + 'console.log("ARG=" + process.argv[2]);\n', + ); + const started = Date.now(); + const result = f.run( + 'exec "$QA_BASH" "$QL_DIR/shell/task.sh" "$@"', + { + QA_BASH: bash, + QL_DIR: f.root, + QL_DATA_DIR: path.join(f.root, 'data'), + ID: '42', + no_delay: 'true', + real_time: 'false', + no_tee: logging === 'file' ? 'true' : 'false', + real_log_path: 'literal [log] $name.log', + PATH: path.join(f.root, 'bin') + path.delimiter + process.env.PATH, + }, + [...args, literal], + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); + assert.ok( + Date.now() - started < 1800, + 'completion must not wait for the two-second watchdog', + ); + const log = fs.readFileSync( + path.join(f.root, 'data/log/literal [log] $name.log'), + 'utf8', + ); + assert.ok(log.includes(`ARG=${literal}\n`), log); + assert.match(log, /AFTER\n/); + assert.match(log, /完成 ✅/); + if (label === 'concurrent') { + assert.match(log, /ACCOUNT=one\n/); + assert.match(log, /ACCOUNT=two\n/); + } + }); + } +} + +test('shared initialization copies configuration and hooks under a directory containing spaces', (t) => { + const f = fixture(t); + f.write('shell/share.sh', source('shell/share.sh')); + for (const name of [ + 'config.sample.sh', + 'task.sample.sh', + 'extra.sample.sh', + 'notify.py', + 'notify.js', + 'ql_sample.js', + 'ql_sample.py', + ]) + f.write( + `sample/${name}`, + name === 'config.sample.sh' + ? 'DefaultCronRule="0 0 * * *"\n' + : '# fixture\n', + ); + const result = f.run( + '. "$QL_DIR/shell/share.sh"; fix_config; import_config; printf "%s" "$default_cron"', + { + QL_DIR: f.root, + QL_DATA_DIR: path.join(f.root, 'custom data'), + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); + assert.equal(result.stdout, '0 0 * * *'); + assert.ok( + fs.existsSync(path.join(f.root, 'custom data/config/task_after.sh')), + ); +}); + +test('token generation passes a compiled token filename literally', (t) => { + const f = fixture(t); + f.write('static/build/token.js', 'console.log("fixture-token");\n'); + const result = f.run( + extract('shell/api.sh', 'create_token') + + '\ncreate_token; printf "%s" "$__ql_token__"', + { dir_root: f.root }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, 'fixture-token'); +}); + +test('script discovery keeps filenames with spaces, glob characters and leading dashes', (t) => { + const f = fixture(t, 'ql-script-list | '); + for (const name of ['-dash name.js', 'probe [1].js', 'sendNotify.js']) + f.write(`scripts/${name}`, "new Env('fixture');\n"); + const result = f.run( + extract('shell/otask.sh', 'gen_array_scripts') + + '\ngen_array_scripts; printf "%s\\n" "${array_scripts[@]}"', + { + dir_scripts: path.join(f.root, 'scripts'), + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); + assert.deepEqual(result.stdout.trim().split('\n').sort(), [ + '-dash name.js', + 'probe [1].js', + ]); +}); + +test('token generation passes the TypeScript fallback filename literally', (t) => { + const f = fixture(t); + const result = f.run( + extract('shell/api.sh', 'create_token') + + ` + ts-node-transpile-only(){ [[ "$1" == "$dir_root/back/token.ts" ]] || return 99; printf fallback-token; } + create_token || exit $? + printf '%s' "$__ql_token__" + `, + { dir_root: f.root }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, 'fallback-token'); +}); + +test('repository cloning preserves a literal destination with and without a branch', (t) => { + const f = fixture(t); + const origin = path.join(f.root, 'origin repo'); + for (const args of [ + ['init', '-b', 'develop', origin], + ['-C', origin, 'config', 'user.name', 'Fixture'], + ['-C', origin, 'config', 'user.email', 'fixture@example.invalid'], + ]) { + const result = spawnSync('git', args, { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + } + fs.writeFileSync(path.join(origin, 'probe.txt'), 'fixture'); + for (const args of [ + ['-C', origin, 'add', '.'], + ['-C', origin, 'commit', '-m', 'fixture'], + ]) { + const result = spawnSync('git', args, { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + } + for (const branch of ['', 'develop']) { + const target = path.join(f.root, branch || 'default clone'); + const result = f.run( + [ + extract('shell/share.sh', 'set_proxy'), + extract('shell/share.sh', 'unset_proxy'), + extract('shell/share.sh', 'git_clone_scripts'), + 't(){ :; }; git_clone_scripts "$QA_ORIGIN" "$QA_TARGET" "$QA_BRANCH"; exit "$exit_status"', + ].join('\n'), + { QA_ORIGIN: origin, QA_TARGET: target, QA_BRANCH: branch }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal( + fs.readFileSync(path.join(target, 'probe.txt'), 'utf8'), + 'fixture', + ); + } +}); + +for (const condition of [ + 'missing data', + 'invalid data', + 'invalid root', + 'npm only', + 'missing module', + 'unsupported OS', +]) { + test(`native startup reports ${condition} without depending on translation initialization`, (t) => { + const f = fixture(t); + const npmRoot = path.join(f.root, 'npm global'); + fs.mkdirSync(path.join(npmRoot, '@whyour/qinglong'), { recursive: true }); + f.write('bin/npm', '#!/bin/bash\nprintf "%s" "$QA_NPM_ROOT"\n', true); + f.write('bin/id', '#!/bin/bash\nprintf 1000\n', true); + const env = { + PATH: path.join(f.root, 'bin'), + QL_DIR: f.root, + QL_DATA_DIR: path.join(f.root, 'data'), + QA_NPM_ROOT: npmRoot, + QL_OS_TYPE: 'unsupported-fixture', + }; + if (condition === 'missing data') env.QL_DATA_DIR = ''; + if (condition === 'invalid data') env.QL_DATA_DIR = 'relative/data'; + if (condition === 'invalid root') env.QL_DIR = 'relative'; + if (condition === 'npm only' || condition === 'missing module') + env.QL_DIR = ''; + if (condition === 'missing module') + fs.rmdirSync(path.join(npmRoot, '@whyour/qinglong')); + const result = f.run(source('shell/start.sh'), env); + assert.equal(result.status, 1, result.stderr); + assert.doesNotMatch( + result.stderr, + /command not found|No such file|integer expression/, + ); + assert.match( + result.stderr, + condition === 'npm only' + ? /export QL_DIR=/ + : condition === 'missing module' + ? /npm i -g @whyour\/qinglong/ + : condition === 'unsupported OS' + ? /暂不支持此系统/ + : condition === 'invalid root' + ? /QL_DIR/ + : /QL_DATA_DIR/, + ); + }); +} + +test('language loading falls back when global associative arrays are unavailable', (t) => { + const f = fixture(t, 'ql-locale-compat-'); + f.write( + 'shell/lang/en.sh', + 'printf loaded > "$QA_MARKER"\ndeclare -gA LANG_MESSAGES=([probe]=translated)\n', + ); + const result = f.run( + source('shell/share.sh') + + ` + declare(){ if [[ "$1" == -gA ]]; then return 2; fi; builtin declare "$@"; } + import_config + t probe + `, + { + QL_DIR: f.root, + QL_LANG: 'en', + QA_MARKER: path.join(f.root, 'loaded'), + DefaultCronRule: '0 0 * * *', + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, 'probe\n'); + assert.equal(fs.existsSync(path.join(f.root, 'loaded')), false); +}); + +test( + 'supported Bash keeps translated messages global after configuration loading', + { + skip: + spawnSync(bash, ['-c', 'declare -gA probe'], { stdio: 'ignore' }) + .status !== 0, + }, + (t) => { + const f = fixture(t); + f.write( + 'shell/lang/en.sh', + 'declare -gA LANG_MESSAGES=([probe]=translated)\n', + ); + const result = f.run( + source('shell/share.sh') + '\nimport_config; t probe', + { + QL_DIR: f.root, + QL_LANG: 'en', + DefaultCronRule: '0 0 * * *', + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, 'translated\n'); + }, +); + +for (const fail of [false, true]) { + test(`raw download ${ + fail + ? 'failure preserves the installed script' + : 'success installs the new script' + } and clears its proxy`, (t) => { + const f = fixture(t); + f.write('raw/probe.js', 'previous-good-version'); + f.write('scripts/raw_probe.js', 'previous-good-version'); + f.write('crontab.list', ''); + const result = f.run( + [ + extract('shell/share.sh', 'set_proxy'), + extract('shell/share.sh', 'unset_proxy'), + extract('shell/update.sh', 'update_raw'), + `t(){ :; } + wget(){ printf '%s' "$QA_CONTENT" > "$4"; return "$QA_STATUS"; } + update_raw 'https://fixture.invalid/probe.js' 'http://fixture.invalid:8080' false false + result=$? + if env | grep -qE '^https?_proxy='; then exit 99; fi + exit "$result"`, + ].join('\n'), + { + dir_raw: path.join(f.root, 'raw'), + dir_scripts: path.join(f.root, 'scripts'), + list_crontab_user: path.join(f.root, 'crontab.list'), + cmd_task: 'task', + uniq_path: 'probe', + QA_CONTENT: fail ? 'partial-download' : 'new-version', + QA_STATUS: fail ? '8' : '0', + }, + ); + assert.equal(result.status, fail ? 8 : 0, result.stderr); + const expected = fail ? 'previous-good-version' : 'new-version'; + assert.equal( + fs.readFileSync(path.join(f.root, 'raw/probe.js'), 'utf8'), + expected, + ); + assert.equal( + fs.readFileSync(path.join(f.root, 'scripts/raw_probe.js'), 'utf8'), + expected, + ); + assert.equal(fs.existsSync(path.join(f.root, 'raw/probe.js.new')), false); + }); +} + +test('ql raw propagates download failure without replacing either installed copy', (t) => { + const f = fixture(t); + for (const name of ['share.sh', 'update.sh', 'env.sh']) + f.write(`shell/${name}`, source(`shell/${name}`)); + f.write('shell/api.sh', ''); + f.write('data/config/config.sh', 'DefaultCronRule="0 0 * * *"\n'); + f.write('data/config/crontab.list', ''); + f.write('data/raw/invalid_probe.js', 'old-version'); + f.write('data/scripts/raw_invalid_probe.js', 'old-version'); + f.write('bin/wget', '#!/bin/bash\nprintf partial > "$4"\nexit 8\n', true); + const result = f.run( + 'exec "$QA_BASH" "$QL_DIR/shell/update.sh" raw https://fixture.invalid/probe.js "" false false', + { + QA_BASH: bash, + QL_DIR: f.root, + QL_DATA_DIR: path.join(f.root, 'data'), + ID: '', + PATH: path.join(f.root, 'bin') + path.delimiter + process.env.PATH, + }, + ); + assert.equal(result.status, 8, result.stderr); + assert.equal( + fs.readFileSync(path.join(f.root, 'data/raw/invalid_probe.js'), 'utf8'), + 'old-version', + ); + assert.equal( + fs.readFileSync( + path.join(f.root, 'data/scripts/raw_invalid_probe.js'), + 'utf8', + ), + 'old-version', + ); +}); + +const dateTools = [ + { + name: 'host', + command: 'date', + macos: process.platform === 'darwin' ? '1' : '0', + }, +]; +if ( + process.platform === 'darwin' && + spawnSync('gdate', ['--version']).status === 0 +) + dateTools.push({ name: 'GNU', command: 'gdate', macos: '0' }); +for (const dateTool of dateTools) { + test(`${dateTool.name} retention handles literal filenames, active references and empty directories`, (t) => { + const f = fixture(t); + const old = f.write('logs/2020-01-01 two [words].log', 'old'); + const active = f.write('logs/2020-01-01 active & protected.log', 'active'); + f.write('logs/keep/important.txt', 'keep'); + fs.mkdirSync(path.join(f.root, 'logs/empty directory')); + const result = f.run( + ` + t(){ :; } + date(){ command "$QA_DATE" "$@"; } + find_cron_api(){ printf '%s\n' "$1" >> "$QA_QUERIES"; [[ "$1" == *active* ]] && printf active; } + . "$QA_SCRIPT" 7 + `, + { + dir_log: path.join(f.root, 'logs'), + is_macos: dateTool.macos, + QA_DATE: dateTool.command, + QA_QUERIES: path.join(f.root, 'queries'), + QA_SCRIPT: path.join(repo, 'shell/rmlog.sh'), + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); + assert.equal(fs.existsSync(old), false); + assert.equal(fs.existsSync(active), true); + assert.equal( + fs.existsSync(path.join(f.root, 'logs/empty directory')), + false, + ); + assert.ok(fs.existsSync(path.join(f.root, 'logs/keep/important.txt'))); + const queries = fs + .readFileSync(path.join(f.root, 'queries'), 'utf8') + .trim() + .split('\n'); + for (const query of queries) + assert.equal( + new URLSearchParams(query).get('log_path').includes('.log'), + true, + ); + assert.ok( + queries.some( + (query) => + new URLSearchParams(query).get('log_path') === + '2020-01-01 active & protected.log', + ), + ); + }); + + test(`${dateTool.name} retention measures a dated log from local midnight`, (t) => { + const f = fixture(t, 'ql-retention-boundary-'); + const file = f.write('logs/2026-01-01.log', 'boundary'); + const result = f.run( + ` + t(){ :; }; find_cron_api(){ :; } + date(){ if [[ "$*" == +%s ]]; then printf 1767830401; else command "$QA_DATE" "$@"; fi; } + . "$QA_SCRIPT" 7 + `, + { + dir_log: path.join(f.root, 'logs'), + is_macos: dateTool.macos, + QA_DATE: dateTool.command, + QA_SCRIPT: path.join(repo, 'shell/rmlog.sh'), + TZ: 'UTC', + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal( + fs.existsSync(file), + false, + 'seven days plus one second is past retention', + ); + }); +} + +test('log streaming options are accepted by the actual platform tail', (t) => { + const f = fixture(t); + const today = spawnSync('date', ['+%F'], { encoding: 'utf8' }).stdout.trim(); + f.write(`syslog/${today}.log`, 'ready\n'); + const result = f.run( + extract('shell/update.sh', 'show_service_logs') + + ` + tail(){ printf '%s\\0' "$@"; } + show_service_logs --lines 0 + `, + { dir_data: f.root }, + ); + assert.equal(result.status, 0, result.stderr); + const args = result.stdout.split('\0').slice(0, -1); + const native = spawnSync('tail', args, { + encoding: 'utf8', + timeout: 250, + killSignal: 'SIGKILL', + }); + assert.equal(native.error?.code, 'ETIMEDOUT', native.stderr); + assert.equal(native.stderr, ''); +}); + +for (const version of ['2.23.0', '2.23.0-beta.1+build.2', 'invalid']) { + test(`legacy publishing ${ + version === 'invalid' + ? 'rejects an invalid version before Git changes' + : 'uses the complete version with GNU-compatible tools' + }`, (t) => { + const f = fixture(t); + f.write('version.yaml', `version: ${version}\n`); + f.write( + 'bin/git', + '#!/bin/bash\nprintf "%s\\n" "$*" >> "$QA_GIT_CALLS"\n', + true, + ); + f.write('bin/ts-node-transpile-only', '#!/bin/bash\nexit 0\n', true); + const result = f.run(source('shell/pub.sh'), { + PATH: + path.join(f.root, 'bin') + + path.delimiter + + (process.platform === 'darwin' && + fs.existsSync('/opt/homebrew/opt/grep/libexec/gnubin') + ? '/opt/homebrew/opt/grep/libexec/gnubin:' + : '') + + process.env.PATH, + QA_GIT_CALLS: path.join(f.root, 'git-calls'), + }); + if (version === 'invalid') { + assert.notEqual(result.status, 0); + assert.equal(fs.existsSync(path.join(f.root, 'git-calls')), false); + } else { + assert.equal(result.status, 0, result.stderr); + assert.ok( + fs + .readFileSync(path.join(f.root, 'git-calls'), 'utf8') + .includes(`tag -a v${version} -m release v${version}`), + ); + } + }); +} diff --git a/test/back/task-timeout-processes.test.cjs b/test/back/task-timeout-processes.test.cjs new file mode 100644 index 00000000..3d918063 --- /dev/null +++ b/test/back/task-timeout-processes.test.cjs @@ -0,0 +1,153 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const psTree = require('ps-tree'); +const test = require('node:test'); + +const bash = process.env.QL_TEST_BASH || '/bin/bash'; +const helper = path.resolve(__dirname, '../../shell/task-timeout.sh'); +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const tree = (pid) => + new Promise((resolve, reject) => + psTree(pid, (error, children) => + error ? reject(error) : resolve(children), + ), + ); +async function until(predicate, message) { + const deadline = Date.now() + 5000; + while (!(await predicate())) { + assert.ok(Date.now() < deadline, message); + await delay(20); + } +} +function alive(pid) { + try { + process.kill(pid, 0); + if (process.platform === 'linux') { + try { + const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); + if ( + ['Z', 'X', 'x'].includes( + stat.slice(stat.lastIndexOf(')') + 2).split(' ')[0], + ) + ) + return false; + } catch (error) { + if (error.code === 'ENOENT') return false; + throw error; + } + } + return true; + } catch (error) { + if (error.code === 'ESRCH') return false; + throw error; + } +} + +for (const mode of ['timeout', 'manual']) { + test( + `${mode} cleans up TERM-resistant children, grandchildren and the watchdog`, + { timeout: 12000, skip: process.platform === 'win32' }, + async (t) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'ql-timeout-processes-'), + ); + const known = new Set(); + let child; + t.after(async () => { + if (child) { + for (const p of await tree(child.pid).catch(() => [])) + known.add(Number(p.PID)); + } + for (const pid of known) { + if (pid > 1) { + try { + process.kill(pid, 'SIGKILL'); + } catch {} + } + } + fs.rmSync(root, { recursive: true, force: true }); + }); + fs.writeFileSync( + path.join(root, 'child.sh'), + ` + trap '' TERM + printf '%s' "$$" > "$QA_ROOT/child" + sleep 30 & + printf '%s' "$!" > "$QA_ROOT/grandchild" + wait + `, + ); + child = spawn( + bash, + [ + '-c', + ` + . "$QA_HELPER" + task() { + trap '' TERM + /bin/sh -c 'printf "%s" "$PPID"' > "$QA_ROOT/worker" + "$QA_BASH" "$QA_ROOT/child.sh" & + wait + } + run_task_command task + exit $? + `, + ], + { + detached: true, + env: { + ...process.env, + QA_ROOT: root, + QA_HELPER: helper, + QA_BASH: bash, + command_timeout_time: mode === 'timeout' ? '1s' : '1h', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + known.add(child.pid); + let result, + stderr = ''; + child.stdout.resume(); + child.stderr.on('data', (chunk) => (stderr += chunk)); + child.on('exit', (code, signal) => { + result = { code, signal }; + }); + await until( + () => + ['worker', 'child', 'grandchild'].every((name) => { + const file = path.join(root, name); + return ( + fs.existsSync(file) && Number(fs.readFileSync(file, 'utf8')) > 1 + ); + }), + 'task process tree did not initialize', + ); + const descendants = await tree(child.pid); + for (const p of descendants) known.add(Number(p.PID)); + for (const name of ['worker', 'child', 'grandchild']) + known.add(Number(fs.readFileSync(path.join(root, name), 'utf8'))); + assert.ok( + known.size >= 6, + 'must observe the wrapper, watchdog and task descendants', + ); + if (mode === 'manual') { + const worker = Number( + fs.readFileSync(path.join(root, 'worker'), 'utf8'), + ); + const wrapper = descendants.find((p) => Number(p.PID) === worker); + assert.ok(wrapper, 'worker must belong to this test process tree'); + process.kill(Number(wrapper.PPID), 'SIGTERM'); + } + await until(() => result, 'task wrapper did not exit'); + assert.equal(result.code, mode === 'timeout' ? 124 : 143, stderr); + await until( + () => [...known].every((pid) => !alive(pid)), + 'task or watchdog process survived cleanup', + ); + }, + ); +}