From 753078d3450ad5e973b538a8134477c2c3487142 Mon Sep 17 00:00:00 2001 From: redbotu Date: Sun, 17 May 2026 23:19:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=87=AA=E5=8A=A8=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E7=9B=B8=E5=85=B3=E8=84=9A=E6=9C=AC=E5=92=8C=E8=AF=B4?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PAM智能部署 Agent Skill 文档.md.md | 141 +++ .../PAM智能部署 Shell & Bat 脚本实现.md.md | 759 ++++++++++++++ doc_scripts/config.txt.example | 11 + doc_scripts/deploy.bat | 5 + doc_scripts/deploy.ps1 | 763 ++++++++++++++ doc_scripts/deploy.sh | 976 ++++++++++++++++++ doc_scripts/test_deploy.bat | 5 + doc_scripts/test_deploy.ps1 | 283 +++++ doc_scripts/test_deploy.sh | 323 ++++++ doc_scripts/当前脚本情况总结.md | 313 ++++++ 10 files changed, 3579 insertions(+) create mode 100644 doc_scripts/PAM智能部署 Agent Skill 文档.md.md create mode 100644 doc_scripts/PAM智能部署 Shell & Bat 脚本实现.md.md create mode 100644 doc_scripts/config.txt.example create mode 100644 doc_scripts/deploy.bat create mode 100644 doc_scripts/deploy.ps1 create mode 100644 doc_scripts/deploy.sh create mode 100644 doc_scripts/test_deploy.bat create mode 100644 doc_scripts/test_deploy.ps1 create mode 100644 doc_scripts/test_deploy.sh create mode 100644 doc_scripts/当前脚本情况总结.md diff --git a/doc_scripts/PAM智能部署 Agent Skill 文档.md.md b/doc_scripts/PAM智能部署 Agent Skill 文档.md.md new file mode 100644 index 0000000..040735b --- /dev/null +++ b/doc_scripts/PAM智能部署 Agent Skill 文档.md.md @@ -0,0 +1,141 @@ +--- +name: pam-smart-deploy +description: 基于 PAM HOME/NODE 流程执行软件发布、下载、升级、回滚、健康检查和日志采集。用于用户要求按机场、版本、软件包部署 PAM 应用,并需要根据用户输入在 MCP 直连部署和 API 脚本部署(config.txt + deploy.sh / deploy.ps1 / deploy.bat)之间切换时。 +--- + +# PAM智能部署 Skill + +按用户意图在 `MCP` 与 `API脚本` 两种模式之间选择,并完成以下闭环:HOME 端建版与发布、NODE 端下载、在线工作站动态发现、逐台升级、启动、健康检测、日志下载、失败回滚、结果汇总。 + +## 模式选择 + +1. 先识别用户期望的执行入口。 + - 用户明确说“用 MCP”“直接执行”“在线部署”“不要生成脚本”:使用 `MCP`。 + - 用户明确说“用脚本”“生成脚本”“输出 sh / ps1 / bat / config”“离线执行”“批量复用”:使用 `API脚本`。 + - 用户只说“帮我部署”,且当前环境存在可用的 PAM MCP:默认 `MCP`。 + - 用户只说“给我部署脚本”或“不要直接动环境”:默认 `API脚本`,且默认只生成文件不执行。 +2. 在两种模式都可行但用户意图不清时,只追问一个问题:`这次要直接通过 MCP 执行,还是生成/运行 API 脚本?` +3. 在开始执行前,明确告知本次采用的模式。 +4. 用户已经明确指定模式后,不要静默切换。若当前模式不可用,先说明原因,再请求切换到另一种模式。 + +## 输入参数 + +先收集并标准化以下参数。脚本模式下,优先写入 `config.txt`。 + +| 规范字段 | 脚本字段 | 必填 | 说明 | +| --- | --- | --- | --- | +| `HOME_BASE_URL` | `HOME_BASE_URL` | 是 | PAM HOME 基础地址 | +| `client_id` | `CLIENT_ID` | 是 | OAuth 客户端 ID | +| `client_secret` | `CLIENT_SECRET` | 是 | OAuth 客户端密钥 | +| `airportCode` | `AIRPORT_CODE` | 是 | 目标机场三字码 | +| `applicationName` | `APP_NAME` | 是 | 软件名称 | +| `moduleName` | `MODULE_NAME` | 是 | 模块名称 | +| `versionNumber` | `VERSION_NUMBER` | 是 | 目标版本号 | +| `zipFilePath` | `ZIP_FILE_PATH` | 是 | 本地软件包路径 | +| `actionType` | `ACTION_TYPE` | 否 | 升级类型,默认 `FULL` | +| `timeOut` | `TIMEOUT` | 否 | 单步超时,默认 `120` | +| `logName` | `LOG_NAME` | 否 | 需下载的日志文件名,默认 `app.log` | + +## 通用执行原则 + +1. 始终通过接口动态获取在线工作站 IP,不要要求用户手填 `TARGET_IPS`。 +2. 用户若指定单个或部分 IP,先调用在线 IP 接口,再对返回列表做过滤,不要跳过在线性校验。 +3. 任一步骤失败时,保留该步骤的原始响应、错误摘要和当前阶段名称。 +4. `TOKEN` 失效时,自动重取一次并重试当前步骤一次。 +5. 无论单机部署成功或失败,都下载对应日志。 +6. 在线工作站列表为空时,终止部署并明确报告“无在线工作站匹配该模块”。 +7. 用户要求“只生成脚本”“先给我文件”“不要执行”时,不要触发真实部署。 +8. Windows 脚本模式默认优先 `deploy.ps1`,不要默认使用 `deploy.bat`。 +9. 当前目录如果只有文档而没有真实脚本文件,先根据参考实现落地脚本,再决定是否执行。 + +## 统一部署流程 + +两种模式都遵循同一业务顺序,差异只在“通过 MCP 直接执行”还是“通过脚本封装执行”。 + +| 阶段 | 操作 | 关键接口 | +| --- | --- | --- | +| 1 | 获取 Token | `POST {HOME_BASE_URL}/oauth/token` | +| 2.1 | 新建版本记录 | `POST {HOME_BASE_URL}/api/version/upgrade` | +| 2.2 | 上传软件包 | `POST {HOME_BASE_URL}/api/version/upgrade/upload` | +| 2.3 | 发布版本 | `PUT {HOME_BASE_URL}/api/version/upgrade/profile?...` | +| 3.1 | 获取 Node 地址 | `GET {HOME_BASE_URL}/api/mcp/airport/target-node?airportCode={airportCode}` | +| 3.2 | 获取在线工作站 IP | `GET {HOME_BASE_URL}/node_proxy/{airportCode}/api/mcp/version/upgrade/ips?...` | +| 3.3 | 下载软件包到 Node | `GET {HOME_BASE_URL}/node_proxy/{airportCode}/api/mcp/version/upgrade/download-cloud?...` | +| 3.3b | 轮询下载进度 | `GET .../download-cloud/progress?...` | +| 4.1 | 对每个 IP 执行升级 | `POST {HOME_BASE_URL}/node_proxy/{airportCode}/api/mcp/version/upgrade` | +| 4.2 | 启动应用 | `POST {HOME_BASE_URL}/node_proxy/{airportCode}/api/mcp/version/upgrade/start-stop` | +| 4.3 | 健康检测 | `GET {HOME_BASE_URL}/node_proxy/{airportCode}/api/mcp/version/upgrade/verify?...` | +| 4.4 | 下载日志 | `GET {HOME_BASE_URL}/node_proxy/{airportCode}/api/mcp/version/upgrade/log-download?...` | +| 4.x | 失败回滚 | `POST {HOME_BASE_URL}/node_proxy/{airportCode}/api/mcp/version/upgrade/rollback` | + +调用 NODE 侧接口时,始终携带: + +- `Authorization: Basic {TOKEN}` +- `Target-Node: {NODE_URL}` +- `airport-code: {airportCode}`,仅在下载到 NODE 等需要时携带 + +## MCP 模式 + +1. 直接调用 PAM MCP 提供的能力完成上述流程,不生成本地脚本文件。 +2. 若 MCP 暴露的是高层工具,确保其实际覆盖以下关键环节:建版、上传、发布、取 Node、取在线 IP、下载到 Node、逐台升级、启动、校验、日志下载、回滚。 +3. 若 MCP 暴露的是通用 HTTP/REST 能力,则按“统一部署流程”中的接口顺序执行。 +4. 在 `MCP` 模式下,仍然要输出逐台 IP 的结果,不要只给出整体成功或失败。 +5. 用户若额外要求“顺手生成脚本留档”,可在部署完成后再生成脚本产物,但不要把脚本生成作为 MCP 主路径的前置步骤。 + +## API脚本模式 + +仅在脚本模式下读取并使用 `PAM智能部署 Shell & Bat 脚本实现.md.md` 作为参考实现。 + +1. 先把规范字段映射为脚本字段,并写入 `config.txt`。 +2. 根据操作系统选择脚本入口。 + - Linux / Mac:使用 `deploy.sh`。 + - Windows:优先使用 `deploy.ps1`。 + - `deploy.bat` 只在用户明确要求 Batch,或必须兼容旧入口,且确认特殊字符风险可接受时才使用。 +3. 若当前目录只有文档而没有真实脚本文件,先从参考实现中落地实际脚本文件,再执行。 +4. 若用户要求“只生成脚本不执行”,完成以下产物后即可结束: + - `config.txt` + - `deploy.sh` 或 `deploy.ps1` + - 如用户明确要求,再额外提供 `deploy.bat` +5. 执行脚本后,读取脚本输出和 `./logs/` 目录内容,整理成最终报告。 + +## 失败处理与回滚 + +1. `Step 2` 或 `Step 3` 失败时,终止整个部署,并指出失败阶段。 +2. `Step 4.1` 升级失败时,记录该 IP 失败原因,尝试回滚,然后继续处理其他在线 IP,除非用户要求全有或全无。 +3. `Step 4.3` 健康检测失败时,执行以下顺序: + - 调用 `start-stop` 停止应用,`runstart=false` + - 调用 `rollback` + - 再次执行健康检测 + - 下载回滚阶段日志 +4. 回滚是否成功也必须写入最终报告,不能仅记录“已尝试回滚”。 + +## 输出要求 + +最终输出至少包含: + +- 本次模式:`MCP` 或 `API脚本` +- 机场、应用、模块、版本 +- 在线工作站总数、成功数、失败数 +- 每个 IP 的状态、失败阶段、失败原因、回滚结果、日志位置或日志摘要 +- 如果是脚本模式:实际生成或执行的文件名 + +可按以下结构输出: + +```markdown +## PAM 智能部署报告 + +- 模式: MCP +- 机场: HET +- 应用: PAM +- 模块: Node +- 版本: 2.0.5 +- 总工作站数: 3 +- 成功: 2 +- 失败: 1 + +| IP | 状态 | 失败阶段 | 回滚结果 | 日志 | +| --- | --- | --- | --- | --- | +| 192.168.1.10 | Success | - | - | logs/deploy_192.168.1.10.log | +| 192.168.1.11 | Success | - | - | logs/deploy_192.168.1.11.log | +| 192.168.1.12 | Failed | Health Check | Rollback Failed | logs/deploy_192.168.1.12.log | +``` diff --git a/doc_scripts/PAM智能部署 Shell & Bat 脚本实现.md.md b/doc_scripts/PAM智能部署 Shell & Bat 脚本实现.md.md new file mode 100644 index 0000000..5bfc994 --- /dev/null +++ b/doc_scripts/PAM智能部署 Shell & Bat 脚本实现.md.md @@ -0,0 +1,759 @@ +# PAM智能部署 Shell & Bat 脚本实现 + +## 0. 与 Skill 对齐的使用约定 + +本文是 `API脚本` 模式的参考实现,不是 `MCP` 模式说明。Agent 使用本文时,先遵循以下规则: + +1. 用户明确要求 `MCP`、直接在线执行、不要生成脚本时,不要把本文作为主执行路径。 +2. 用户明确要求“脚本部署”“生成脚本”“离线执行”“输出 config / sh / ps1 / bat”时,再读取本文并生成或执行脚本。 +3. 当前目录如果只有本文档而没有真实脚本文件,Agent 需要先把对应代码块落地为真实文件,再决定是否执行。 +4. Windows 默认优先 `deploy.ps1`;`deploy.bat` 仅作为兼容入口或示例入口,不应作为默认正式方案。 +5. 当前文档中的字段名与 Skill 规范字段映射如下: + +| Skill 字段 | 脚本字段 | +| --- | --- | +| `HOME_BASE_URL` | `HOME_BASE_URL` | +| `client_id` | `CLIENT_ID` | +| `client_secret` | `CLIENT_SECRET` | +| `airportCode` | `AIRPORT_CODE` | +| `applicationName` | `APP_NAME` | +| `moduleName` | `MODULE_NAME` | +| `versionNumber` | `VERSION_NUMBER` | +| `zipFilePath` | `ZIP_FILE_PATH` | +| `actionType` | `ACTION_TYPE` | +| `timeOut` | `TIMEOUT` | +| `logName` | `LOG_NAME` | + +6. 若用户只要求“生成脚本不执行”,则产物至少应包含: + - `config.txt` + - `deploy.sh` 或 `deploy.ps1` + - 仅在用户明确要求时再提供 `deploy.bat` + +## 0.1 当前实现边界 + +1. Linux / Mac 路径以 `deploy.sh` 为主。 +2. Windows 文档里虽然展示了 `deploy.bat`,但它更适合作为兼容入口,不适合作为默认生产入口。 +3. 若要在 Windows 正式落地脚本模式,建议整理出独立 `deploy.ps1`,再由 Agent 优先执行该文件。 + +## 1. 配置文件规范 (config.txt) + +为了方便管理敏感信息(如 `client_secret`)和特殊字符,建议将配置信息存储在 `config.txt`文件中。脚本会自动读取该文件。 + +**config.txt 格式:** + +```ini +HOME_BASE_URL=https://pam.home.com +CLIENT_ID=your_client_id +CLIENT_SECRET=MySecret!Pass123&Special#Chars +AIRPORT_CODE=HET +APP_NAME=PAM +MODULE_NAME=Node +VERSION_NUMBER=2.0.5 +ZIP_FILE_PATH=/path/to/pam-2.0.5.zip +; TARGET_IPS 已废弃,脚本将自动从接口获取在线工作站 IP +ACTION_TYPE=FULL +TIMEOUT=120 +LOG_NAME=app.log ; 要下载的日志文件名,可根据实际软件调整 +``` + +**注意:** + +1. `=` 左侧为变量名,右侧为值。 + +2. **不要**在值两侧加引号(除非是 Windows Batch 中无法避免的情况,建议尽量不加)。 + +3. 特殊字符如 `!`、`%`、`&` 等在配置文件中是安全的,脚本会原样读取。 + +4. Windows 和 Linux 脚本都优先读取同目录下的 `config.txt`。如果不存在,则回退到脚本内定义的默认值。 + + +* * * + +## 2. Linux/Mac Shell 脚本 (deploy.sh) + +_(此部分与之前版本一致,Shell 脚本对特殊字符的处理比 Batch 友好得多,无需修改)_ + +```bash +#!/bin/bash + +# ============================================================================== +# PAM 智能部署脚本 (Linux/Mac) +# ============================================================================== + +# --- 读取配置文件 --- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_FILE="${SCRIPT_DIR}/config.txt" + +if [ -f "$CONFIG_FILE" ]; then + echo "[INFO] 正在读取配置文件: ${CONFIG_FILE}" + while IFS='=' read -r key value; do + # 忽略注释和空行 + [[ "$key" =~ ^#.*$ ]] && continue + [[ -z "$key" ]] && continue + + # 去除两端空格 + key=$(echo "$key" | xargs) + value=$(echo "$value" | xargs) + + # 动态设置变量 + declare "$key=$value" + done < "$CONFIG_FILE" +else + echo "[WARN] 未找到配置文件 ${CONFIG_FILE},使用默认配置" +fi + +# --- 默认配置 (如果 config.txt 缺失或未定义) --- +HOME_BASE_URL="${HOME_BASE_URL:-https://pam.home.com}" +CLIENT_ID="${CLIENT_ID:-your_client_id}" +CLIENT_SECRET="${CLIENT_SECRET:-your_client_secret}" +AIRPORT_CODE="${AIRPORT_CODE:-HET}" +APP_NAME="${APP_NAME:-PAM}" +MODULE_NAME="${MODULE_NAME:-Node}" +VERSION_NUMBER="${VERSION_NUMBER:-2.0.5}" +ZIP_FILE_PATH="${ZIP_FILE_PATH:-/path/to/pam-2.0.5.zip}" +ACTION_TYPE="${ACTION_TYPE:-FULL}" +TIMEOUT="${TIMEOUT:-120}" +LOG_NAME="${LOG_NAME:-app.log}" + +# --- 全局变量 --- +TOKEN="" +HASH_CODE="" +NODE_URL="" +SUCCESS_COUNT=0 +FAIL_COUNT=0 +TOTAL_COUNT=0 +RESULTS_JSON="[]" # 用于存储结果,便于最终报告生成 + +# --- 颜色输出 --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# --- 工具函数 --- + +# 获取 Token +get_token() { + log_info "正在获取 Token..." + # 注意:移除了 -s 参数,以便 Agent 能看到 curl 的详细错误信息 + local response + response=$(curl -X POST "${HOME_BASE_URL}/oauth/token" \ + -d "grant_type=client_credentials" \ + -d "client_id=${CLIENT_ID}" \ + -d "client_secret=${CLIENT_SECRET}" 2>&1) + + TOKEN=$(echo $response | jq -r '.access_token' 2>/dev/null) + + if [ -z "$TOKEN" ] || [ "$TOKEN" == "null" ]; then + log_error "获取 Token 失败: $response" + exit 1 + fi + log_info "Token 获取成功" +} + +# 通用 HTTP 请求函数 +http_request() { + local method=$1 + local url=$2 + local data=$3 + local headers=$4 + + local curl_cmd="curl -X ${method} '${url}'" + + # 添加认证头 + curl_cmd="${curl_cmd} -H 'Authorization: Basic ${TOKEN}'" + + # 添加额外 headers + if [ -n "$headers" ]; then + IFS=',' read -ra HEADER_ARRAY <<< "$headers" + for header in "${HEADER_ARRAY[@]}"; do + curl_cmd="${curl_cmd} -H '${header}'" + done + fi + + # 添加数据 + if [ -n "$data" ]; then + curl_cmd="${curl_cmd} -d '${data}'" + fi + + # 执行请求,重定向 stderr 到 stdout 以便捕获网络错误 + eval $curl_cmd 2>&1 +} + +# 上传文件函数 +upload_file() { + local url=$1 + local file_path=$2 + local form_data=$3 + + local curl_cmd="curl -X POST '${url}' -H 'Authorization: Basic ${TOKEN}'" + + # 添加 form 字段 + IFS='&' read -ra FORM_ARRAY <<< "$form_data" + for field in "${FORM_ARRAY[@]}"; do + key=$(echo $field | cut -d'=' -f1) + value=$(echo $field | cut -d'=' -f2) + curl_cmd="${curl_cmd} -F '${key}=${value}'" + done + + curl_cmd="${curl_cmd} -F 'file=@${file_path}'" + + # 执行请求,捕获错误信息 + eval $curl_cmd 2>&1 +} + +# 轮询进度函数 +poll_progress() { + local url=$1 + local max_retries=${2:-60} + local interval=${3:-2} + local retry=0 + + log_info "开始轮询进度..." + + while [ $retry -lt $max_retries ]; do + local response + response=$(http_request "GET" "$url") + + local status + # 尝试解析 JSON,如果失败则可能是网络错误 + status=$(echo $response | jq -r '.status // .success // ""' 2>/dev/null) + + if [ "$status" == "completed" ] || [ "$status" == "true" ]; then + log_info "操作完成" + return 0 + fi + + local error_msg + error_msg=$(echo $response | jq -r '.message // ""' 2>/dev/null) + + # 如果 jq 解析失败,说明返回的可能不是 JSON,而是 HTTP 错误页或 curl 错误 + if [ -z "$error_msg" ] && ! echo "$response" | grep -q "success"; then + log_error "请求异常,原始响应: $response" + return 1 + fi + + if echo "$error_msg" | grep -qi "fail\|error"; then + log_error "操作失败: $error_msg" + return 1 + fi + + retry=$((retry + 1)) + log_info "等待中... ($retry/$max_retries)" + sleep $interval + done + + log_error "操作超时" + return 1 +} + +# 下载日志并保存 +download_log() { + local ip=$1 + local log_name=$2 + local log_dir="./logs" + + mkdir -p "$log_dir" + local log_file="${log_dir}/deploy_${ip}.log" + + log_info "正在下载 ${ip} 的日志: ${log_name}..." + + # 使用 -o 保存文件,同时保留 stderr 信息以便调试 + http_request "GET" "${HOME_BASE_URL}/node_proxy/${AIRPORT_CODE}/api/mcp/version/upgrade/log-download?applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&airportCode=${AIRPORT_CODE}&targetIp=${ip}&logName=${log_name}" \ + "" \ + "Target-Node:${NODE_URL}" > "$log_file" 2>>"${log_dir}/error_${ip}.log" + + if [ $? -eq 0 ]; then + # 检查文件大小,如果为0可能是空响应但HTTP成功 + if [ ! -s "$log_file" ]; then + log_warn "日志文件为空 (HTTP可能返回204或无内容): ${ip}" + echo "Log content empty or no data" > "${log_file}.summary" + else + tail -n 5 "$log_file" > "${log_file}.summary" 2>/dev/null || true + log_info "日志已保存至: ${log_file}" + fi + else + log_error "日志下载命令执行失败 (请查看 error_${ip}.log)" + echo "Command execution failed, see error_${ip}.log" > "$log_file" + echo "Execution failed" > "${log_file}.summary" + fi +} + +# 添加结果到报告数据 +add_result() { + local ip=$1 + local status=$2 + local message=$3 + + RESULTS_JSON=$(echo $RESULTS_JSON | jq --arg ip "$ip" --arg status "$status" --arg msg "$message" '. + [{"ip": $ip, "status": $status, "message": $msg}]') + + if [ "$status" == "SUCCESS" ]; then + SUCCESS_COUNT=$((SUCCESS_COUNT + 1)) + else + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +# --- 主流程 --- + +main() { + log_info "==========================================" + log_info "PAM 智能部署开始" + log_info "机场: ${AIRPORT_CODE}, 版本: ${VERSION_NUMBER}" + log_info "模块: ${APP_NAME}/${MODULE_NAME}" + log_info "日志文件名: ${LOG_NAME}" + log_info "==========================================" + + # Step 1: 获取 Token + get_token + + # Step 2.1: 新建版本 + log_info "Step 2.1: 新建版本..." + local create_version_url="${HOME_BASE_URL}/api/version/upgrade" + local create_version_data="versionNumber=${VERSION_NUMBER}&applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&description=Auto Deploy" + http_request "POST" "$create_version_url" "$create_version_data" > /dev/null + log_info "版本创建完成" + + # Step 2.2: 上传软件包 + log_info "Step 2.2: 上传软件包..." + local upload_url="${HOME_BASE_URL}/api/version/upgrade/upload" + local upload_form="applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&versionNumber=${VERSION_NUMBER}" + HASH_CODE=$(upload_file "$upload_url" "$ZIP_FILE_PATH" "$upload_form") + log_info "软件包上传完成,Hash: ${HASH_CODE}" + + # Step 2.3: 发布版本 + log_info "Step 2.3: 发布版本..." + local publish_url="${HOME_BASE_URL}/api/version/upgrade/profile?versionNumber=${VERSION_NUMBER}&applicationName=${APP_NAME}&moduleName=${MODULE_NAME}" + local publish_data="{\"airportCodesWhite\":[\"${AIRPORT_CODE}\"],\"hashCode\":\"${HASH_CODE}\",\"state\":\"RELEASE\"}" + http_request "PUT" "$publish_url" "$publish_data" > /dev/null + log_info "版本发布完成" + + # Step 3.1: 获取 Node URL + log_info "Step 3.1: 获取 Node 地址..." + local node_info_url="${HOME_BASE_URL}/api/mcp/airport/target-node?airportCode=${AIRPORT_CODE}" + local node_response + node_response=$(http_request "GET" "$node_info_url") + NODE_URL=$(echo $node_response | jq -r 'keys[0]') + + if [ -z "$NODE_URL" ] || [ "$NODE_URL" == "null" ]; then + log_error "无法获取 Node 地址. Response: $node_response" + exit 1 + fi + log_info "Node URL: ${NODE_URL}" + + # Step 3.2: 获取在线工作站 IP (动态获取) + log_info "Step 3.2: 获取在线工作站列表..." + local ips_url="${HOME_BASE_URL}/node_proxy/${AIRPORT_CODE}/api/mcp/version/upgrade/ips?applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&airportCode=${AIRPORT_CODE}" + + local ips_response + ips_response=$(http_request "GET" "$ips_url" "" "Target-Node:${NODE_URL}") + + # 解析 IP 数组 + local ip_array + ip_array=($(echo $ips_response | jq -r '.[]' 2>/dev/null)) + + TOTAL_COUNT=${#ip_array[@]} + + if [ $TOTAL_COUNT -eq 0 ]; then + log_warn "未找到任何在线工作站!部署终止。" + log_warn "原始响应: $ips_response" + exit 0 + fi + + log_info "找到 ${TOTAL_COUNT} 个在线工作站: ${ip_array[*]}" + + # Step 3.3: 下载软件包到 Node + log_info "Step 3.3: 下载软件包到 Node..." + local download_url="${HOME_BASE_URL}/node_proxy/${AIRPORT_CODE}/api/mcp/version/upgrade/download-cloud" + local download_params="?versionNumber=${VERSION_NUMBER}&applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&timeOut=${TIMEOUT}" + + http_request "GET" "${download_url}${download_params}" \ + "" \ + "airport-code:${AIRPORT_CODE},Target-Node:${NODE_URL}" > /dev/null + + # 轮询下载进度 + local progress_url="${HOME_BASE_URL}/node_proxy/${AIRPORT_CODE}/api/mcp/version/upgrade/download-cloud/progress?applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&airportCode=${AIRPORT_CODE}" + poll_progress "$progress_url" 60 2 + if [ $? -ne 0 ]; then + log_error "软件包下载失败" + exit 1 + fi + + # Step 4: 升级推送至每个工作站 (动态循环) + for ip in "${ip_array[@]}"; do + log_info "------------------------------------------" + log_info "处理工作站: ${ip}" + log_info "------------------------------------------" + + local ip_status="SUCCESS" + local ip_message="" + + # 4.1: 执行升级 + log_info "Step 4.1: 执行升级..." + local upgrade_url="${HOME_BASE_URL}/node_proxy/${AIRPORT_CODE}/api/mcp/version/upgrade" + local upgrade_data="airportCode=${AIRPORT_CODE}&targetIp=${ip}&applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&versionNumber=${VERSION_NUMBER}&action=${ACTION_TYPE}&autoStart=false&timeOut=${TIMEOUT}" + + local upgrade_response + upgrade_response=$(http_request "POST" "$upgrade_url" "$upgrade_data" "Target-Node:${NODE_URL}") + + local upgrade_success + upgrade_success=$(echo $upgrade_response | jq -r '.success' 2>/dev/null) + + if [ "$upgrade_success" != "true" ]; then + ip_status="FAILURE" + ip_message="Upgrade failed" + log_error "升级失败: $upgrade_response" + log_warn "尝试回滚..." + + # 回滚逻辑 + local rollback_url="${HOME_BASE_URL}/node_proxy/${AIRPORT_CODE}/api/mcp/version/upgrade/rollback" + local rollback_data="airportCode=${AIRPORT_CODE}&targetIp=${ip}&applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&timeOut=${TIMEOUT}" + http_request "POST" "$rollback_url" "$rollback_data" "Target-Node:${NODE_URL}" > /dev/null + log_warn "已触发回滚" + else + # 4.2: 启动应用 + log_info "Step 4.2: 启动应用..." + local start_url="${HOME_BASE_URL}/node_proxy/${AIRPORT_CODE}/api/mcp/version/upgrade/start-stop" + local start_data="airportCode=${AIRPORT_CODE}&targetIp=${ip}&applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&runstart=true" + http_request "POST" "$start_url" "$start_data" "Target-Node:${NODE_URL}" > /dev/null + + # 4.3: 健康检测 + log_info "Step 4.3: 健康检测..." + local verify_url="${HOME_BASE_URL}/node_proxy/${AIRPORT_CODE}/api/mcp/version/upgrade/verify?applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&airportCode=${AIRPORT_CODE}&targetIp=${ip}" + local verify_response + verify_response=$(http_request "GET" "$verify_url" "" "Target-Node:${NODE_URL}") + + local verify_success + verify_success=$(echo $verify_response | jq -r '.success' 2>/dev/null) + + if [ "$verify_success" != "true" ]; then + ip_status="FAILURE" + ip_message=$(echo $verify_response | jq -r '.message // "Unknown error"' 2>/dev/null) + log_error "健康检测失败: ${ip_message} (原始响应: $verify_response)" + + # 健康检测失败也可触发回滚(可选) + log_warn "尝试回滚..." + local rollback_url="${HOME_BASE_URL}/node_proxy/${AIRPORT_CODE}/api/mcp/version/upgrade/rollback" + local rollback_data="airportCode=${AIRPORT_CODE}&targetIp=${ip}&applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&timeOut=${TIMEOUT}" + http_request "POST" "$rollback_url" "$rollback_data" "Target-Node:${NODE_URL}" > /dev/null + else + log_info "健康检测通过" + fi + fi + + # 4.4: 下载日志 (无论成功与否) + download_log "$ip" "${LOG_NAME}" + + # 记录结果 + add_result "$ip" "$ip_status" "$ip_message" + done + + # Step 5: 生成报告 + log_info "==========================================" + log_info "PAM 智能部署结束" + log_info "==========================================" + + echo "" + echo "====================== 部署报告 ======================" + echo -e "机场: ${AIRPORT_CODE} | 版本: ${VERSION_NUMBER}" + echo -e "总数: ${TOTAL_COUNT} | ${GREEN}成功: ${SUCCESS_COUNT}${NC} | ${RED}失败: ${FAIL_COUNT}${NC}" + echo "-----------------------------------------------------" + + # 打印每个 IP 的状态 + for ip in "${ip_array[@]}"; do + local status + status=$(echo $RESULTS_JSON | jq -r --arg ip "$ip" '.[] | select(.ip==$ip) | .status') + + if [ "$status" == "SUCCESS" ]; then + echo -e "[${GREEN}SUCCESS${NC}] ${ip}" + else + local msg + msg=$(echo $RESULTS_JSON | jq -r --arg ip "$ip" '.[] | select(.ip==$ip) | .message') + echo -e "[${RED}FAILED${NC}] ${ip} (Reason: ${msg:-N/A})" + fi + done + echo "======================================================" + log_info "详细日志文件保存在 ./logs/ 目录下" + log_info "网络错误日志保存在 ./logs/error_*.log 文件中" +} + +# 执行主函数 +main "$@" +``` + +## 3. Windows Batch 脚本 (deploy.bat) - **兼容入口,不建议作为默认正式方案** + +本节主要用于兼容旧入口,或说明 Batch 在特殊字符场景下的处理方式。即使引入了 **PowerShell 辅助读取配置并安全传递变量**,它仍不适合作为默认正式方案。 + +```bat +@echo off +chcp 65001 >nul +setlocal EnableDelayedExpansion + +:: ============================================================================== +:: PAM 智能部署脚本 (Windows) - 特殊字符安全版 +:: ============================================================================== + +:: --- 读取配置文件 (使用 PowerShell 避免 Batch 解析问题) --- +set "SCRIPT_DIR=%~dp0" +set "CONFIG_FILE=%SCRIPT_DIR%config.txt" + +if exist "%CONFIG_FILE%" ( + echo [INFO] 正在读取配置文件: %CONFIG_FILE% + :: 使用 PowerShell 读取 key=value 文件,并输出为 set 命令序列 + powershell -Command "$content = Get-Content '%CONFIG_FILE%'; foreach($line in $content) { if($line -match '^(.*?)=(.*)$') { Write-Output ('set "%%A=%%B"') } }" > config_set.tmp + + :: 执行生成的 set 命令 + for /f "usebackq delims=" %%L in ("config_set.tmp") do ( + %%L + ) + + :: 清理临时文件 + del /q config_set.tmp >nul 2>nul +) else ( + echo [WARN] 未找到配置文件 %CONFIG_FILE%,使用默认配置 +) + +:: --- 默认配置 (如果 config.txt 缺失或未定义) --- +set "HOME_BASE_URL=%HOME_BASE_URL: =%" +if "%HOME_BASE_URL%"=="" set "HOME_BASE_URL=https://pam.home.com" +set "CLIENT_ID=%CLIENT_ID: =%" +if "%CLIENT_ID%"=="" set "CLIENT_ID=your_client_id" +set "CLIENT_SECRET=%CLIENT_SECRET: =%" +if "%CLIENT_SECRET%"=="" set "CLIENT_SECRET=your_client_secret" +set "AIRPORT_CODE=%AIRPORT_CODE: =%" +if "%AIRPORT_CODE%"=="" set "AIRPORT_CODE=HET" +set "APP_NAME=%APP_NAME: =%" +if "%APP_NAME%"=="" set "APP_NAME=PAM" +set "MODULE_NAME=%MODULE_NAME: =%" +if "%MODULE_NAME%"=="" set "MODULE_NAME=Node" +set "VERSION_NUMBER=%VERSION_NUMBER: =%" +if "%VERSION_NUMBER%"=="" set "VERSION_NUMBER=2.0.5" +set "ZIP_FILE_PATH=%ZIP_FILE_PATH: =%" +if "%ZIP_FILE_PATH%"=="" set "ZIP_FILE_PATH=C:\path\to\pam-2.0.5.zip" +set "ACTION_TYPE=%ACTION_TYPE: =%" +if "%ACTION_TYPE%"=="" set "ACTION_TYPE=FULL" +set "TIMEOUT=%TIMEOUT: =%" +if "%TIMEOUT%"=="" set "TIMEOUT=120" +set "LOG_NAME=%LOG_NAME: =%" +if "%LOG_NAME%"=="" set "LOG_NAME=app.log" + +:: --- 全局变量 --- +set "TOKEN=" +set "HASH_CODE=" +set "NODE_URL=" +set "SUCCESS_COUNT=0" +set "FAIL_COUNT=0" +set "TOTAL_COUNT=0" + +:: 临时文件用于存储结果 +set "RESULTS_FILE=%TEMP%\deploy_results.json" +echo [] > "%RESULTS_FILE%" + +:: --- 工具函数 --- + +:: 获取 Token (使用 PowerShell 确保密码安全) +:GET_TOKEN +echo [INFO] 正在获取 Token... + +:: 通过 PowerShell 构造 POST 请求,避免 Batch 转义问题 +powershell -Command "$id = '%CLIENT_ID%'; $sec = '%CLIENT_SECRET%'; $url = '%HOME_BASE_URL%/oauth/token'; $body = 'grant_type=client_credentials&client_id=' + $id + '&client_secret=' + $sec; Invoke-RestMethod -Uri $url -Method POST -Body $body -ContentType 'application/x-www-form-urlencoded' | ConvertTo-Json" > token_response.json + +:: 检查 PowerShell 是否成功 +if not exist token_response.json ( + echo [ERROR] PowerShell 执行失败,请检查网络或配置。 + exit /b 1 +) + +for /f "delims=" %%i in ('powershell -Command "(Get-Content token_response.json | ConvertFrom-Json).access_token"') do ( + set "TOKEN=%%i" +) + +if "%TOKEN%"=="" ( + echo [ERROR] 获取 Token 失败。请检查 client_secret 是否正确,或查看 token_response.json 内容。 + type token_response.json + exit /b 1 +) +echo [INFO] Token 获取成功 +goto :eof + +:: 通用 HTTP 请求函数 (使用 PowerShell 辅助,确保参数安全) +:HTTP_REQUEST +set "METHOD=%1" +set "URL=%2" +set "DATA=%3" +set "EXTRA_HEADERS=%4" +set "OUTPUT_FILE=%5" +set "ERROR_FILE=%6" + +:: 构建 PowerShell 命令 +set "PS_CMD=powershell -Command """ +set "PS_CMD=!PS_CMD!$url = '%URL%'; $method = '%METHOD%'; " + +:: 处理 Headers +if defined EXTRA_HEADERS ( + set "PS_CMD=!PS_CMD!$headers = @{'Authorization'='Basic %TOKEN%'}; " + for %%H in (%EXTRA_HEADERS%) do ( + set "key=%%~H" + set "val=!key:*:=!" + set "key=!key:=%VAL%=!" + :: 这里简化处理,复杂 header 可能需要更精细的解析,通常 Target-Node 和 airport-code 是简单的 key-value + ) + :: 更简单的方式:直接拼接到命令中 +) + +:: 对于 HTTP 请求,如果数据中包含特殊字符,直接使用 PowerShell 的 Invoke-RestMethod 或 Invoke-WebRequest 更可靠 +:: 这里为了保持与 Batch 逻辑的一致性,我们使用 curl 但通过 PowerShell 调用 curl 以保留 stderr +if defined OUTPUT_FILE ( + powershell -Command "curl.exe -X '%METHOD%' '%URL%' -H 'Authorization: Basic %TOKEN%'" > "%OUTPUT_FILE%" 2>"%ERROR_FILE%" + :: 注意:如果 DATA 非空,需要重新构造命令。为了简化,我们假设大部分 POST 数据是简单的 form-urlencoded,且不含极端特殊字符,或者使用 PowerShell 内部变量传递。 + :: **更安全的做法**:对于包含复杂 Data 的请求,直接使用 PowerShell。 +) else ( + curl -X %METHOD% '%URL%' -H 'Authorization: Basic %TOKEN%' 2>&1 +) + +:: *修正*:为了确保所有请求(特别是带 DATA 的)都能正确处理特殊字符,我们将核心请求逻辑交给 PowerShell 执行,或者确保 curl 命令被正确转义。 +:: 鉴于 Batch 的局限性,以下是一个折中方案:使用 PowerShell 执行 curl。 + +goto :eof + +:: 由于 Batch 处理复杂 HTTP 请求困难,建议将核心 API 调用脚本提取为独立的 .ps1 文件,或接受此版本的限制。 +:: 为保证可用性和特殊字符安全,下面提供基于 PowerShell 封装的通用请求函数 + +:PS_HTTP_REQUEST +:: $1=Method, $2=URL, $3=Data(JSON), $4=Headers(Key=Value;Key=Value) +set "METHOD=%~1" +set "URL=%~2" +set "DATA=%~3" +set "HEADERS=%~4" +set "OUT_FILE=%~5" + +set "PS_SCRIPT=powershell -Command """ +set "PS_SCRIPT=!PS_SCRIPT!try {" + +if "%METHOD%"=="GET" ( + set "PS_CMD=!PS_CMD!IWR -Uri '%URL%' -Headers @{'Authorization'='Basic %TOKEN%'} !HEADERS!" +) else if "%METHOD%"=="POST" ( + if defined DATA ( + :: 如果 Data 是 JSON,直接传递;如果是 Form,需要转换 + set "PS_CMD=!PS_CMD!IWR -Uri '%URL%' -Method POST -Headers @{'Authorization'='Basic %TOKEN%'} !HEADERS! -Body '@'" + :: 注意:Body 的处理较为复杂,这里简化处理 + ) else ( + set "PS_CMD=!PS_CMD!IWR -Uri '%URL%' -Method POST -Headers @{'Authorization'='Basic %TOKEN%'} !HEADERS!" + ) +) + +:: ... (由于 Batch 动态构造 PowerShell 命令极易出错,生产环境强烈建议将 API 调用逻辑完全迁移至 PowerShell) +:: 此处为了演示,我们保留 curl 但强调特殊字符风险。对于包含特殊字符的请求,请单独使用 PowerShell 调用。 + +echo [WARN] 复杂的 HTTP 请求且含特殊字符时,Batch 脚本可能无法完美处理。建议将此类请求移至 .ps1 脚本中。 +goto :eof + +:: --- 主流程简化版 (仅展示 Token 获取的安全处理) --- + +:MAIN +echo ========================================== +echo PAM 智能部署开始 (注意:特殊字符支持有限,建议配置文件中避免使用极端特殊字符或改用 PowerShell 入口) +echo ========================================== + +call :GET_TOKEN + +:: ... 后续步骤由于 Batch 的局限性,对于含特殊字符的 Data/URL 仍可能存在风险。 +:: **最佳实践**:如果 CLIENT_SECRET 包含 !, %, &, # 等,请考虑将 `deploy.bat` 改为 `deploy.ps1`。 +echo [INFO] 脚本执行完成 (测试模式) + +endlocal +pause +``` + +### ⚠️ 重要说明:关于 Windows Batch 的特殊字符限制 + +经过测试,**Windows Batch 原生无法完美支持在变量中存储并安全传递包含** `!`**,** `%`**,** `&`**,** `<`**,** `>`**,** `&` **等字符的值用于后续命令参数**。即使使用 `EnableDelayedExpansion`,在构建 `curl -d "client_secret=!CLIENT_SECRET!"` 时,`!...!` 的语法冲突也无法避免。 + +**最终建议方案:** +如果你的 `client_secret` 确实包含这些特殊字符,**请不要使用 Batch 脚本作为入口**。请创建一个 `deploy.ps1` (PowerShell 脚本),它可以完美处理所有特殊字符,并且能直接调用 .NET API 或封装 `curl.exe`,实现与 Shell 脚本同等甚至更强的功能。 + +如果你必须使用 Batch,请尝试以下 **最小化修改** 来确保 Token 获取成功: + +1. 在 `config.txt` 中,确保 `CLIENT_SECRET` 没有引号。 + +2. 在 `deploy.bat` 中,使用我提供的 `powershell -Command "... Invoke-RestMethod ..."` 方式获取 Token,这是目前 Batch 环境下最安全的做法。 + + +* * * + +## 4. Linux/Mac 测试脚本 (test_deploy.sh) + +_(同前)_ + +## 5. Windows Batch 测试脚本 (test_deploy.bat) + +_(同前,注意测试脚本也需更新以使用 PowerShell 获取 Token)_ + +```bat +@echo off +chcp 65001 >nul +setlocal EnableDelayedExpansion + +:: ... (配置读取逻辑同上) ... + +:TEST_GET_TOKEN +echo ========================================== +echo 测试 1: 获取 Token +echo ========================================== + +:: 使用 PowerShell 获取,确保特殊字符安全 +powershell -Command "$id = '%CLIENT_ID%'; $sec = '%CLIENT_SECRET%'; $url = '%HOME_BASE_URL%/oauth/token'; $body = 'grant_type=client_credentials&client_id=' + $id + '&client_secret=' + $sec; Invoke-RestMethod -Uri $url -Method POST -Body $body -ContentType 'application/x-www-form-urlencoded' | ConvertTo-Json" > token_test.json + +if not exist token_test.json ( + echo %RED% Token 获取失败 (PowerShell 错误) + set /a FAIL_COUNT+=1 +) else ( + for /f "delims=" %%i in ('powershell -Command "(Get-Content token_test.json | ConvertFrom-Json).access_token"') do ( + set "TOKEN=%%i" + ) + + if "%TOKEN%"=="" ( + echo %RED% Token 获取失败 + type token_test.json + set /a FAIL_COUNT+=1 + ) else ( + echo %GREEN% Token 获取成功 + set /a PASS_COUNT+=1 + ) +) +goto :eof + +:: ... (其他测试步骤类似,对于涉及 POST Data 的测试,建议也使用 PowerShell) ... +``` + +## 6. 使用说明 + +### 前置依赖 + +1. **Linux/Mac**: + + * `curl`: 通常预装 + + * `jq`: JSON 解析工具 + +2. **Windows**: + + * `PowerShell`: 必须使用 PowerShell 5.0+ (Windows 10 默认) + + * `curl.exe`: Windows 10+ 自带 + + +### 关键配置建议 + +* **Linux/Mac**: `config.txt` 中的特殊字符可以安全使用。 + +* **Windows**: + + * Token 获取已通过 PowerShell 包装,支持特殊字符。 + + * 其他涉及 POST Body 包含复杂特殊字符的请求,Batch 脚本可能存在局限。如果遇到问题,请将对应步骤移至 PowerShell 脚本中执行。 diff --git a/doc_scripts/config.txt.example b/doc_scripts/config.txt.example new file mode 100644 index 0000000..8fb2c49 --- /dev/null +++ b/doc_scripts/config.txt.example @@ -0,0 +1,11 @@ +HOME_BASE_URL=https://pam.home.com +CLIENT_ID=your_client_id +CLIENT_SECRET=MySecret!Pass123&Special#Chars +AIRPORT_CODE=HET +APP_NAME=PAM +MODULE_NAME=Node +VERSION_NUMBER=2.0.5 +ZIP_FILE_PATH=C:\path\to\pam-2.0.5.zip +ACTION_TYPE=FULL +TIMEOUT=120 +LOG_NAME=app.log diff --git a/doc_scripts/deploy.bat b/doc_scripts/deploy.bat new file mode 100644 index 0000000..0e2e945 --- /dev/null +++ b/doc_scripts/deploy.bat @@ -0,0 +1,5 @@ +@echo off +setlocal +set "SCRIPT_DIR=%~dp0" +powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%deploy.ps1" %* +exit /b %ERRORLEVEL% diff --git a/doc_scripts/deploy.ps1 b/doc_scripts/deploy.ps1 new file mode 100644 index 0000000..e8302b0 --- /dev/null +++ b/doc_scripts/deploy.ps1 @@ -0,0 +1,763 @@ +param( + [string]$ConfigPath = (Join-Path $PSScriptRoot 'config.txt'), + [switch]$Help +) + +# PAM 部署主脚本(PowerShell 实现)。 +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Show-DeployUsage { + @' +Usage: + powershell -File .\deploy.ps1 [-ConfigPath .\config.txt] + +Notes: + - deploy.bat is only a wrapper for this script. + - The wrapper avoids cmd.exe delayed-expansion issues with CLIENT_SECRET values + containing exclamation marks. +'@ | Write-Host +} + +function Write-Info([string]$Message) { Write-Host "[INFO] $Message" } +function Write-WarnLog([string]$Message) { Write-Host "[WARN] $Message" } +function Write-ErrLog([string]$Message) { Write-Host "[ERROR] $Message" } + +function Convert-ResponseContent { + param([AllowNull()][string]$Content) + + if ([string]::IsNullOrWhiteSpace($Content)) { + return $null + } + + try { + if ($PSVersionTable.PSVersion.Major -ge 6) { + return $Content | ConvertFrom-Json -Depth 100 + } + + return $Content | ConvertFrom-Json + } catch { + return $Content + } +} + +function Get-ErrorBody { + param([System.Management.Automation.ErrorRecord]$ErrorRecord) + + try { + $response = $ErrorRecord.Exception.Response + if ($null -eq $response) { + return $ErrorRecord.Exception.Message + } + + $stream = $response.GetResponseStream() + if ($null -eq $stream) { + return $ErrorRecord.Exception.Message + } + + $reader = New-Object System.IO.StreamReader($stream) + try { + return $reader.ReadToEnd() + } finally { + $reader.Dispose() + } + } catch { + return $ErrorRecord.Exception.Message + } +} + +function Get-ResponseValue { + param( + $Response, + [string[]]$Candidates + ) + + foreach ($candidate in $Candidates) { + $current = $Response + foreach ($segment in ($candidate -split '\.')) { + if ($null -eq $current) { + break + } + + if ($current -is [string]) { + $current = $null + break + } + + if ($current -is [System.Collections.IDictionary]) { + if ($current.Contains($segment)) { + $current = $current[$segment] + } else { + $current = $null + break + } + } elseif ($current.PSObject.Properties.Name -contains $segment) { + $current = $current.$segment + } else { + $current = $null + break + } + } + + if ($null -ne $current -and -not [string]::IsNullOrWhiteSpace([string]$current)) { + return [string]$current + } + } + + return $null +} + +function Get-PamConfig { + param([string]$Path) + + $config = [ordered]@{} + + if (Test-Path -LiteralPath $Path) { + foreach ($rawLine in Get-Content -LiteralPath $Path -Encoding UTF8) { + $line = $rawLine.TrimEnd("`r") + if ($line -match '^\s*$' -or $line -match '^\s*[#;]') { + continue + } + + $index = $line.IndexOf('=') + if ($index -lt 1) { + continue + } + + $key = $line.Substring(0, $index).Trim() + $value = $line.Substring($index + 1).Trim() + if ($value -match '^(.*?\S)\s+[;#].*$') { + $value = $Matches[1] + } + + switch ($key) { + 'HOME_BASE_URL' { $config[$key] = $value } + 'CLIENT_ID' { $config[$key] = $value } + 'CLIENT_SECRET' { $config[$key] = $value } + 'AIRPORT_CODE' { $config[$key] = $value } + 'APP_NAME' { $config[$key] = $value } + 'MODULE_NAME' { $config[$key] = $value } + 'VERSION_NUMBER' { $config[$key] = $value } + 'ZIP_FILE_PATH' { $config[$key] = $value } + 'ACTION_TYPE' { $config[$key] = $value } + 'TIMEOUT' { $config[$key] = $value } + 'LOG_NAME' { $config[$key] = $value } + } + } + } else { + Write-WarnLog "Config file not found: $Path. Defaults will be used." + } + + $defaults = [ordered]@{ + HOME_BASE_URL = 'https://pam.home.com' + CLIENT_ID = 'your_client_id' + CLIENT_SECRET = 'your_client_secret' + AIRPORT_CODE = 'HET' + APP_NAME = 'PAM' + MODULE_NAME = 'Node' + VERSION_NUMBER = '2.0.5' + ZIP_FILE_PATH = 'C:\path\to\pam-2.0.5.zip' + ACTION_TYPE = 'FULL' + TIMEOUT = '120' + LOG_NAME = 'app.log' + } + + foreach ($name in $defaults.Keys) { + if (-not $config.Contains($name) -or [string]::IsNullOrWhiteSpace([string]$config[$name])) { + $config[$name] = $defaults[$name] + } + } + + return [pscustomobject]$config +} + +function Join-RequestPairs { + param([System.Collections.IDictionary]$Values) + + $pairs = foreach ($key in $Values.Keys) { + $encodedValue = [System.Uri]::EscapeDataString([string]$Values[$key]) + '{0}={1}' -f $key, $encodedValue + } + + return ($pairs -join '&') +} + +function Test-ZipFile { + param($Config) + + if (-not (Test-Path -LiteralPath $Config.ZIP_FILE_PATH)) { + throw "Package file not found: $($Config.ZIP_FILE_PATH)" + } +} + +function Invoke-PamWebRequest { + param( + [ValidateSet('GET', 'POST', 'PUT')] + [string]$Method, + [string]$Url, + [string]$Token, + [hashtable]$Headers = @{}, + [AllowNull()]$Body = $null, + [string]$ContentType = '', + [string]$OutFile = '' + ) + + $allHeaders = @{} + if ($Token) { + $allHeaders['Authorization'] = "Basic $Token" + } + foreach ($key in $Headers.Keys) { + $allHeaders[$key] = $Headers[$key] + } + + $params = @{ + Uri = $Url + Method = $Method + Headers = $allHeaders + ErrorAction = 'Stop' + } + + if ($PSVersionTable.PSVersion.Major -lt 6) { + $params['UseBasicParsing'] = $true + } + + if ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body -and "$Body" -ne '') { + $params['Body'] = $Body + } + + if ($ContentType) { + $params['ContentType'] = $ContentType + } + + if ($OutFile) { + $params['OutFile'] = $OutFile + } + + try { + $response = Invoke-WebRequest @params + if ($OutFile) { + return $OutFile + } + return Convert-ResponseContent $response.Content + } catch { + $body = Get-ErrorBody $_ + throw "Request failed [$Method] $Url`n$body" + } +} + +function Invoke-PamMultipartUpload { + param( + [string]$Url, + [string]$Token, + [string]$FilePath, + [hashtable]$Fields + ) + + Add-Type -AssemblyName System.Net.Http + + $client = [System.Net.Http.HttpClient]::new() + try { + $client.DefaultRequestHeaders.Authorization = + [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Basic', $Token) + + $content = [System.Net.Http.MultipartFormDataContent]::new() + foreach ($entry in $Fields.GetEnumerator()) { + $stringContent = [System.Net.Http.StringContent]::new([string]$entry.Value, [System.Text.Encoding]::UTF8) + $content.Add($stringContent, $entry.Key) + } + + $stream = [System.IO.File]::OpenRead($FilePath) + try { + $fileContent = [System.Net.Http.StreamContent]::new($stream) + $fileContent.Headers.ContentType = + [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/octet-stream') + $content.Add($fileContent, 'file', [System.IO.Path]::GetFileName($FilePath)) + + $response = $client.PostAsync($Url, $content).GetAwaiter().GetResult() + $body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + if (-not $response.IsSuccessStatusCode) { + throw "Upload failed [HTTP $([int]$response.StatusCode)]`n$body" + } + + return Convert-ResponseContent $body + } finally { + $stream.Dispose() + } + } finally { + $client.Dispose() + } +} + +function Get-Token { + param($Config) + + Write-Info 'Getting token...' + $body = Join-RequestPairs ([ordered]@{ + grant_type = 'client_credentials' + client_id = $Config.CLIENT_ID + client_secret = $Config.CLIENT_SECRET + }) + $response = Invoke-PamWebRequest -Method POST -Url "$($Config.HOME_BASE_URL)/oauth/token" -Token '' -Body $body -ContentType 'application/x-www-form-urlencoded' + $token = Get-ResponseValue -Response $response -Candidates @('access_token') + if (-not $token) { + throw "Invalid token response: $response" + } + return $token +} + +function New-VersionRecord { + param($Config, [string]$Token) + + Write-Info 'Step 2.1: create version record' + $body = Join-RequestPairs ([ordered]@{ + versionNumber = $Config.VERSION_NUMBER + applicationName = $Config.APP_NAME + moduleName = $Config.MODULE_NAME + description = 'Auto Deploy' + }) + [void](Invoke-PamWebRequest -Method POST -Url "$($Config.HOME_BASE_URL)/api/version/upgrade" -Token $Token -Body $body -ContentType 'application/x-www-form-urlencoded') +} + +function Upload-Package { + param($Config, [string]$Token) + + Write-Info 'Step 2.2: upload package' + $response = Invoke-PamMultipartUpload -Url "$($Config.HOME_BASE_URL)/api/version/upgrade/upload" -Token $Token -FilePath $Config.ZIP_FILE_PATH -Fields @{ + applicationName = $Config.APP_NAME + moduleName = $Config.MODULE_NAME + versionNumber = $Config.VERSION_NUMBER + } + + $hashCode = Get-ResponseValue -Response $response -Candidates @('hashCode', 'data.hashCode') + if (-not $hashCode -and $response -is [string]) { + $hashCode = $response.Trim() + } + if (-not $hashCode) { + throw "Unable to parse hashCode from upload response: $response" + } + + return $hashCode +} + +function Publish-Version { + param($Config, [string]$Token, [string]$HashCode) + + Write-Info 'Step 2.3: publish version' + $payload = @{ + airportCodesWhite = @($Config.AIRPORT_CODE) + hashCode = $HashCode + state = 'RELEASE' + } | ConvertTo-Json -Depth 5 + + $query = Join-RequestPairs ([ordered]@{ + versionNumber = $Config.VERSION_NUMBER + applicationName = $Config.APP_NAME + moduleName = $Config.MODULE_NAME + }) + + [void](Invoke-PamWebRequest -Method PUT -Url "$($Config.HOME_BASE_URL)/api/version/upgrade/profile?$query" -Token $Token -Body $payload -ContentType 'application/json') +} + +function Get-NodeUrl { + param($Config, [string]$Token) + + Write-Info 'Step 3.1: resolve node url' + $response = Invoke-PamWebRequest -Method GET -Url "$($Config.HOME_BASE_URL)/api/mcp/airport/target-node?airportCode=$($Config.AIRPORT_CODE)" -Token $Token + + if ($response -is [System.Collections.IDictionary]) { + return [string]($response.Keys | Select-Object -First 1) + } + + $propertyNames = @($response.PSObject.Properties.Name) + if ($propertyNames.Count -gt 0) { + return [string]$propertyNames[0] + } + + throw "Unable to resolve node url: $response" +} + +function Get-OnlineIps { + param($Config, [string]$Token, [string]$NodeUrl) + + Write-Info 'Step 3.2: query online IP list' + $query = Join-RequestPairs ([ordered]@{ + applicationName = $Config.APP_NAME + moduleName = $Config.MODULE_NAME + airportCode = $Config.AIRPORT_CODE + }) + + $response = Invoke-PamWebRequest -Method GET -Url "$($Config.HOME_BASE_URL)/node_proxy/$($Config.AIRPORT_CODE)/api/mcp/version/upgrade/ips?$query" -Token $Token -Headers @{ + 'Target-Node' = $NodeUrl + } + + $ips = @() + if ($response -is [System.Array]) { + $ips = @($response | ForEach-Object { [string]$_ } | Where-Object { $_ }) + } elseif ($response -is [System.Collections.IEnumerable] -and -not ($response -is [string])) { + $ips = @($response | ForEach-Object { [string]$_ } | Where-Object { $_ }) + } + + if ($ips.Count -eq 0) { + throw "No online workstation matched the module. Raw response: $response" + } + + return $ips +} + +function Wait-DownloadProgress { + param($Config, [string]$Token, [string]$NodeUrl) + + $query = Join-RequestPairs ([ordered]@{ + applicationName = $Config.APP_NAME + moduleName = $Config.MODULE_NAME + airportCode = $Config.AIRPORT_CODE + }) + $progressUrl = "$($Config.HOME_BASE_URL)/node_proxy/$($Config.AIRPORT_CODE)/api/mcp/version/upgrade/download-cloud/progress?$query" + + for ($attempt = 0; $attempt -lt 60; $attempt++) { + $response = Invoke-PamWebRequest -Method GET -Url $progressUrl -Token $Token -Headers @{ + 'Target-Node' = $NodeUrl + } + + $status = Get-ResponseValue -Response $response -Candidates @('status') + $successFlag = Get-ResponseValue -Response $response -Candidates @('success') + if ($status -eq 'completed' -or $successFlag -eq 'true') { + return + } + + $message = Get-ResponseValue -Response $response -Candidates @('message') + if ($message -and $message -match '(?i)fail|error') { + throw "Node download failed: $message" + } + + Start-Sleep -Seconds 2 + } + + throw 'Node download timed out.' +} + +function Download-CloudToNode { + param($Config, [string]$Token, [string]$NodeUrl) + + Write-Info 'Step 3.3: download package to node' + $query = Join-RequestPairs ([ordered]@{ + versionNumber = $Config.VERSION_NUMBER + applicationName = $Config.APP_NAME + moduleName = $Config.MODULE_NAME + timeOut = $Config.TIMEOUT + }) + + [void](Invoke-PamWebRequest -Method GET -Url "$($Config.HOME_BASE_URL)/node_proxy/$($Config.AIRPORT_CODE)/api/mcp/version/upgrade/download-cloud?$query" -Token $Token -Headers @{ + 'Target-Node' = $NodeUrl + 'airport-code' = $Config.AIRPORT_CODE + }) + + Wait-DownloadProgress -Config $Config -Token $Token -NodeUrl $NodeUrl +} + +function Invoke-UpgradeRequest { + param($Config, [string]$Token, [string]$NodeUrl, [string]$Ip) + + $body = Join-RequestPairs ([ordered]@{ + airportCode = $Config.AIRPORT_CODE + targetIp = $Ip + applicationName = $Config.APP_NAME + moduleName = $Config.MODULE_NAME + versionNumber = $Config.VERSION_NUMBER + action = $Config.ACTION_TYPE + autoStart = 'false' + timeOut = $Config.TIMEOUT + }) + + Invoke-PamWebRequest -Method POST -Url "$($Config.HOME_BASE_URL)/node_proxy/$($Config.AIRPORT_CODE)/api/mcp/version/upgrade" -Token $Token -Headers @{ + 'Target-Node' = $NodeUrl + } -Body $body -ContentType 'application/x-www-form-urlencoded' +} + +function Start-Application { + param($Config, [string]$Token, [string]$NodeUrl, [string]$Ip) + + $body = Join-RequestPairs ([ordered]@{ + airportCode = $Config.AIRPORT_CODE + targetIp = $Ip + applicationName = $Config.APP_NAME + moduleName = $Config.MODULE_NAME + runstart = 'true' + }) + + [void](Invoke-PamWebRequest -Method POST -Url "$($Config.HOME_BASE_URL)/node_proxy/$($Config.AIRPORT_CODE)/api/mcp/version/upgrade/start-stop" -Token $Token -Headers @{ + 'Target-Node' = $NodeUrl + } -Body $body -ContentType 'application/x-www-form-urlencoded') +} + +function Stop-Application { + param($Config, [string]$Token, [string]$NodeUrl, [string]$Ip) + + $body = Join-RequestPairs ([ordered]@{ + airportCode = $Config.AIRPORT_CODE + targetIp = $Ip + applicationName = $Config.APP_NAME + moduleName = $Config.MODULE_NAME + runstart = 'false' + }) + + [void](Invoke-PamWebRequest -Method POST -Url "$($Config.HOME_BASE_URL)/node_proxy/$($Config.AIRPORT_CODE)/api/mcp/version/upgrade/start-stop" -Token $Token -Headers @{ + 'Target-Node' = $NodeUrl + } -Body $body -ContentType 'application/x-www-form-urlencoded') +} + +function Verify-Ip { + param($Config, [string]$Token, [string]$NodeUrl, [string]$Ip) + + $query = Join-RequestPairs ([ordered]@{ + applicationName = $Config.APP_NAME + moduleName = $Config.MODULE_NAME + airportCode = $Config.AIRPORT_CODE + targetIp = $Ip + }) + + Invoke-PamWebRequest -Method GET -Url "$($Config.HOME_BASE_URL)/node_proxy/$($Config.AIRPORT_CODE)/api/mcp/version/upgrade/verify?$query" -Token $Token -Headers @{ + 'Target-Node' = $NodeUrl + } +} + +function Download-DeployLog { + param($Config, [string]$Token, [string]$NodeUrl, [string]$Ip) + + $logsDir = Join-Path $PSScriptRoot 'logs' + if (-not (Test-Path -LiteralPath $logsDir)) { + $null = New-Item -ItemType Directory -Path $logsDir + } + + $logFile = Join-Path $logsDir ("deploy_{0}.log" -f $Ip) + $errorFile = Join-Path $logsDir ("error_{0}.log" -f $Ip) + $query = Join-RequestPairs ([ordered]@{ + applicationName = $Config.APP_NAME + moduleName = $Config.MODULE_NAME + airportCode = $Config.AIRPORT_CODE + targetIp = $Ip + logName = $Config.LOG_NAME + }) + + try { + [void](Invoke-PamWebRequest -Method GET -Url "$($Config.HOME_BASE_URL)/node_proxy/$($Config.AIRPORT_CODE)/api/mcp/version/upgrade/log-download?$query" -Token $Token -Headers @{ + 'Target-Node' = $NodeUrl + } -OutFile $logFile) + + if ((Get-Item -LiteralPath $logFile).Length -gt 0) { + Get-Content -LiteralPath $logFile -Tail 5 | Set-Content -LiteralPath "$logFile.summary" + } else { + 'Log content empty or no data' | Set-Content -LiteralPath "$logFile.summary" + } + } catch { + Get-ErrorBody $_ | Set-Content -LiteralPath $errorFile + "Log download failed. See $errorFile" | Set-Content -LiteralPath $logFile + 'Log download failed' | Set-Content -LiteralPath "$logFile.summary" + } + + return $logFile +} + +function Invoke-Rollback { + param($Config, [string]$Token, [string]$NodeUrl, [string]$Ip, [bool]$StopFirst) + + if ($StopFirst) { + try { + Stop-Application -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip + } catch { + } + } + + try { + $body = Join-RequestPairs ([ordered]@{ + airportCode = $Config.AIRPORT_CODE + targetIp = $Ip + applicationName = $Config.APP_NAME + moduleName = $Config.MODULE_NAME + timeOut = $Config.TIMEOUT + }) + $response = Invoke-PamWebRequest -Method POST -Url "$($Config.HOME_BASE_URL)/node_proxy/$($Config.AIRPORT_CODE)/api/mcp/version/upgrade/rollback" -Token $Token -Headers @{ + 'Target-Node' = $NodeUrl + } -Body $body -ContentType 'application/x-www-form-urlencoded' + + $rollbackSuccess = Get-ResponseValue -Response $response -Candidates @('success') + if ($rollbackSuccess -and $rollbackSuccess -ne 'true') { + return 'ROLLBACK_FAILED' + } + } catch { + return 'ROLLBACK_REQUEST_FAILED' + } + + try { + $verify = Verify-Ip -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip + if ((Get-ResponseValue -Response $verify -Candidates @('success')) -eq 'true') { + return 'ROLLBACK_SUCCESS' + } + return 'ROLLBACK_VERIFY_FAILED' + } catch { + return 'ROLLBACK_VERIFY_FAILED' + } +} + +function Invoke-IpDeploy { + param( + $Config, + [string]$Token, + [string]$NodeUrl, + [string]$Ip + ) + + Write-Info "Processing IP: $Ip" + + try { + $upgrade = Invoke-UpgradeRequest -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip + } catch { + $logFile = Download-DeployLog -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip + return [pscustomobject]@{ + Ip = $Ip + Status = 'FAILED' + Stage = 'UPGRADE' + Message = 'Upgrade request failed' + Rollback = 'ROLLBACK_NOT_RUN' + LogFile = $logFile + } + } + + if ((Get-ResponseValue -Response $upgrade -Candidates @('success')) -ne 'true') { + $rollback = Invoke-Rollback -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip -StopFirst:$false + $logFile = Download-DeployLog -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip + $message = Get-ResponseValue -Response $upgrade -Candidates @('message') + if (-not $message) { $message = 'Upgrade failed' } + return [pscustomobject]@{ + Ip = $Ip + Status = 'FAILED' + Stage = 'UPGRADE' + Message = $message + Rollback = $rollback + LogFile = $logFile + } + } + + try { + Start-Application -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip + } catch { + $rollback = Invoke-Rollback -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip -StopFirst:$true + $logFile = Download-DeployLog -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip + return [pscustomobject]@{ + Ip = $Ip + Status = 'FAILED' + Stage = 'START' + Message = 'Application start failed' + Rollback = $rollback + LogFile = $logFile + } + } + + try { + $verify = Verify-Ip -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip + } catch { + $rollback = Invoke-Rollback -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip -StopFirst:$true + $logFile = Download-DeployLog -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip + return [pscustomobject]@{ + Ip = $Ip + Status = 'FAILED' + Stage = 'VERIFY' + Message = 'Health check request failed' + Rollback = $rollback + LogFile = $logFile + } + } + + if ((Get-ResponseValue -Response $verify -Candidates @('success')) -eq 'true') { + $logFile = Download-DeployLog -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip + return [pscustomobject]@{ + Ip = $Ip + Status = 'SUCCESS' + Stage = '-' + Message = '-' + Rollback = '-' + LogFile = $logFile + } + } + + $rollback = Invoke-Rollback -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip -StopFirst:$true + $logFile = Download-DeployLog -Config $Config -Token $Token -NodeUrl $NodeUrl -Ip $Ip + $message = Get-ResponseValue -Response $verify -Candidates @('message') + if (-not $message) { $message = 'Health check failed' } + return [pscustomobject]@{ + Ip = $Ip + Status = 'FAILED' + Stage = 'VERIFY' + Message = $message + Rollback = $rollback + LogFile = $logFile + } +} + +function Write-DeployReport { + param( + $Config, + [System.Collections.Generic.List[object]]$Results, + [int]$TotalCount + ) + + $successCount = @($Results | Where-Object { $_.Status -eq 'SUCCESS' }).Count + $failCount = @($Results | Where-Object { $_.Status -ne 'SUCCESS' }).Count + + Write-Host '' + Write-Host '====================== DEPLOY REPORT ======================' + Write-Host 'Mode: Batch/PowerShell' + Write-Host "Airport: $($Config.AIRPORT_CODE)" + Write-Host "Application: $($Config.APP_NAME)" + Write-Host "Module: $($Config.MODULE_NAME)" + Write-Host "Version: $($Config.VERSION_NUMBER)" + Write-Host "Total: $TotalCount" + Write-Host "Success: $successCount" + Write-Host "Failed: $failCount" + Write-Host '' + Write-Host ('{0,-18} {1,-8} {2,-12} {3,-22} {4}' -f 'IP', 'STATUS', 'STAGE', 'ROLLBACK', 'LOG') + foreach ($item in $Results) { + Write-Host ('{0,-18} {1,-8} {2,-12} {3,-22} {4}' -f $item.Ip, $item.Status, $item.Stage, $item.Rollback, $item.LogFile) + if ($item.Status -ne 'SUCCESS') { + Write-Host (" Reason: {0}" -f $item.Message) + } + } +} + +function Invoke-PamDeploy { + param([string]$ConfigPath) + + $config = Get-PamConfig -Path $ConfigPath + Test-ZipFile -Config $config + + Write-Info "Deploy start: airport=$($config.AIRPORT_CODE), version=$($config.VERSION_NUMBER), module=$($config.APP_NAME)/$($config.MODULE_NAME)" + + $token = Get-Token -Config $config + New-VersionRecord -Config $config -Token $token + $hashCode = Upload-Package -Config $config -Token $token + Publish-Version -Config $config -Token $token -HashCode $hashCode + $nodeUrl = Get-NodeUrl -Config $config -Token $token + $ips = Get-OnlineIps -Config $config -Token $token -NodeUrl $nodeUrl + Download-CloudToNode -Config $config -Token $token -NodeUrl $nodeUrl + + $results = [System.Collections.Generic.List[object]]::new() + foreach ($ip in $ips) { + $results.Add((Invoke-IpDeploy -Config $config -Token $token -NodeUrl $nodeUrl -Ip $ip)) + } + + Write-DeployReport -Config $config -Results $results -TotalCount $ips.Count +} + +if ($Help) { + Show-DeployUsage + exit 0 +} + +if ($MyInvocation.InvocationName -ne '.') { + try { + Invoke-PamDeploy -ConfigPath $ConfigPath + } catch { + Write-ErrLog $_ + exit 1 + } +} diff --git a/doc_scripts/deploy.sh b/doc_scripts/deploy.sh new file mode 100644 index 0000000..1457074 --- /dev/null +++ b/doc_scripts/deploy.sh @@ -0,0 +1,976 @@ +#!/usr/bin/env bash + +# PAM 部署主脚本(Shell 入口)。 + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_CONFIG_PATH="${SCRIPT_DIR}/config.txt" + +TOKEN="" +HASH_CODE="" +NODE_URL="" +TOTAL_COUNT=0 +SUCCESS_COUNT=0 +FAIL_COUNT=0 +RESULTS_FILE="" +ONLINE_IPS=() +HAS_JQ=0 +API_TRACE_FILE="" +API_TRACE_SEQ=0 +TRACE_ANNOUNCED=0 +CURRENT_TRACE_ID="" + +usage() { + cat <<'EOF' +用法: + ./deploy.sh [--config /path/to/config.txt] + +配置项: + HOME_BASE_URL + CLIENT_ID + CLIENT_SECRET + AIRPORT_CODE + APP_NAME + MODULE_NAME + VERSION_NUMBER + ZIP_FILE_PATH + ACTION_TYPE + TIMEOUT + LOG_NAME +EOF +} + +log_info() { printf '[INFO] %s\n' "$*"; } +log_warn() { printf '[WARN] %s\n' "$*"; } +log_error() { printf '[ERROR] %s\n' "$*" >&2; } + +timestamp_now() { + date '+%Y-%m-%d %H:%M:%S' +} + +ensure_trace_file() { + if [[ -n "$API_TRACE_FILE" ]]; then + return 0 + fi + + local trace_dir="${SCRIPT_DIR}/logs" + local trace_name + + mkdir -p "$trace_dir" + trace_name="api_trace_$(date '+%Y%m%d_%H%M%S')_$$.log" + API_TRACE_FILE="${trace_dir}/${trace_name}" + + { + printf 'PAM API TRACE LOG\n' + printf 'Started: %s\n' "$(timestamp_now)" + printf 'ScriptDir: %s\n' "$SCRIPT_DIR" + printf '\n' + } > "$API_TRACE_FILE" + + if (( TRACE_ANNOUNCED == 0 )); then + log_info "接口跟踪日志: $API_TRACE_FILE" + TRACE_ANNOUNCED=1 + fi +} + +next_trace_id() { + API_TRACE_SEQ=$((API_TRACE_SEQ + 1)) + printf -v CURRENT_TRACE_ID 'REQ-%04d' "$API_TRACE_SEQ" +} + +mask_sensitive_text() { + local text="$1" + + text="$(printf '%s' "$text" | sed -E \ + -e 's/(client_secret=)[^&[:space:]]+/\1***MASKED***/g' \ + -e 's/(Authorization: Basic )[^\r\n]+/\1***MASKED***/g' \ + -e 's/("access_token"[[:space:]]*:[[:space:]]*")[^"]+/\1***MASKED***/g' \ + -e 's/("client_secret"[[:space:]]*:[[:space:]]*")[^"]+/\1***MASKED***/g')" + + printf '%s' "$text" +} + +indent_text() { + local text="$1" + + if [[ -z "$text" ]]; then + printf ' (empty)\n' + else + printf '%s\n' "$text" | sed 's/^/ /' + fi +} + +trace_request() { + local request_id="$1" + local method="$2" + local url="$3" + local headers="$4" + local body="$5" + + ensure_trace_file + { + printf '[%s] [%s] REQUEST\n' "$(timestamp_now)" "$request_id" + printf 'METHOD: %s\n' "$method" + printf 'URL: %s\n' "$url" + printf 'HEADERS:\n' + indent_text "$(mask_sensitive_text "$headers")" + printf 'BODY:\n' + indent_text "$(mask_sensitive_text "$body")" + printf '\n' + } >> "$API_TRACE_FILE" +} + +trace_response() { + local request_id="$1" + local curl_exit="$2" + local http_code="$3" + local response="$4" + + ensure_trace_file + { + printf '[%s] [%s] RESPONSE\n' "$(timestamp_now)" "$request_id" + printf 'CURL_EXIT: %s\n' "$curl_exit" + printf 'HTTP_CODE: %s\n' "${http_code:-N/A}" + printf 'BODY:\n' + indent_text "$(mask_sensitive_text "$response")" + printf '\n' + } >> "$API_TRACE_FILE" +} + +trace_upload_request() { + local request_id="$1" + local url="$2" + local file_path="$3" + local fields="$4" + + ensure_trace_file + { + printf '[%s] [%s] REQUEST\n' "$(timestamp_now)" "$request_id" + printf 'METHOD: POST\n' + printf 'URL: %s\n' "$url" + printf 'UPLOAD_FILE: %s\n' "$file_path" + printf 'FORM_FIELDS:\n' + indent_text "$(mask_sensitive_text "$fields")" + printf '\n' + } >> "$API_TRACE_FILE" +} + +trace_download_result() { + local request_id="$1" + local url="$2" + local http_code="$3" + local curl_exit="$4" + local output_file="$5" + local error_text="$6" + + ensure_trace_file + { + printf '[%s] [%s] FILE_RESPONSE\n' "$(timestamp_now)" "$request_id" + printf 'URL: %s\n' "$url" + printf 'CURL_EXIT: %s\n' "$curl_exit" + printf 'HTTP_CODE: %s\n' "${http_code:-N/A}" + printf 'OUTPUT_FILE: %s\n' "$output_file" + if [[ -f "$output_file" ]]; then + printf 'OUTPUT_SIZE: %s\n' "$(wc -c < "$output_file" | tr -d ' ')" + fi + printf 'STDERR:\n' + indent_text "$(mask_sensitive_text "$error_text")" + printf '\n' + } >> "$API_TRACE_FILE" +} + +trim() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +strip_inline_comment() { + local value="$1" + local comment_regex='^(.*[^[:space:]])[[:space:]]+[;#].*$' + if [[ "$value" =~ $comment_regex ]]; then + printf '%s' "${BASH_REMATCH[1]}" + else + printf '%s' "$value" + fi +} + +is_success_http_code() { + local http_code="$1" + [[ "$http_code" =~ ^2[0-9][0-9]$ ]] +} + +set_defaults() { + : "${HOME_BASE_URL:=https://pam.home.com}" + : "${CLIENT_ID:=your_client_id}" + : "${CLIENT_SECRET:=your_client_secret}" + : "${AIRPORT_CODE:=HET}" + : "${APP_NAME:=PAM}" + : "${MODULE_NAME:=Node}" + : "${VERSION_NUMBER:=2.0.5}" + : "${ZIP_FILE_PATH:=/path/to/pam-2.0.5.zip}" + : "${ACTION_TYPE:=FULL}" + : "${TIMEOUT:=120}" + : "${LOG_NAME:=app.log}" +} + +load_config() { + local config_path="$1" + + if [[ -f "$config_path" ]]; then + while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do + raw_line="${raw_line%$'\r'}" + + local line + line="$(trim "$raw_line")" + [[ -z "$line" ]] && continue + case "$line" in + \#*|\;*) continue ;; + esac + [[ "$line" != *"="* ]] && continue + + local key="${line%%=*}" + local value="${line#*=}" + key="$(trim "$key")" + value="$(trim "$value")" + value="$(strip_inline_comment "$value")" + + case "$key" in + HOME_BASE_URL|CLIENT_ID|CLIENT_SECRET|AIRPORT_CODE|APP_NAME|MODULE_NAME|VERSION_NUMBER|ZIP_FILE_PATH|ACTION_TYPE|TIMEOUT|LOG_NAME) + printf -v "$key" '%s' "$value" + ;; + esac + done < "$config_path" + else + log_warn "未找到配置文件: $config_path,将使用默认值。" + fi + + set_defaults +} + +require_tool() { + local tool="$1" + if ! command -v "$tool" >/dev/null 2>&1; then + log_error "缺少依赖: $tool" + exit 1 + fi +} + +ensure_dependencies() { + require_tool curl + if command -v jq >/dev/null 2>&1; then + HAS_JQ=1 + else + HAS_JQ=0 + log_warn "未检测到 jq,将使用 Bash 兼容 JSON 解析。" + fi +} + +ensure_zip_file() { + if [[ ! -f "$ZIP_FILE_PATH" ]]; then + log_error "软件包不存在: $ZIP_FILE_PATH" + return 1 + fi +} + +sanitize_field() { + local value="$1" + value="${value//$'\r'/ }" + value="${value//$'\n'/ }" + value="${value//$'\t'/ }" + printf '%s' "$value" +} + +json_value() { + local input="$1" + local query="$2" + + if (( HAS_JQ == 1 )); then + printf '%s' "$input" | jq -r "$query // empty" 2>/dev/null + return 0 + fi + + case "$query" in + '.access_token') + json_get_string_by_key "$input" "access_token" + ;; + '.status') + json_get_string_by_key "$input" "status" + ;; + '.success') + json_get_scalar_by_key "$input" "success" + ;; + '.message') + json_get_string_by_key "$input" "message" + ;; + '.hashCode // .data.hashCode') + local value + value="$(json_get_string_by_key "$input" "hashCode")" + if [[ -n "$value" ]]; then + printf '%s' "$value" + else + json_get_nested_string_by_key "$input" "data" "hashCode" + fi + ;; + '.[]') + json_array_items "$input" + ;; + *) + return 1 + ;; + esac +} + +json_compact() { + local input="$1" + input="${input//$'\r'/}" + input="${input//$'\n'/}" + printf '%s' "$input" +} + +json_unescape_basic() { + local value="$1" + value="${value//\\\\/\\}" + value="${value//\\\"/\"}" + value="${value//\\n/$'\n'}" + value="${value//\\r/$'\r'}" + value="${value//\\t/$'\t'}" + printf '%s' "$value" +} + +json_get_string_by_key() { + local input="$1" + local key="$2" + local compact + local value + + compact="$(json_compact "$input")" + value="$(printf '%s' "$compact" | sed -nE 's/.*"'$key'"[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)".*/\1/p')" + [[ -n "$value" ]] && json_unescape_basic "$value" +} + +json_get_scalar_by_key() { + local input="$1" + local key="$2" + local compact + local value + + compact="$(json_compact "$input")" + value="$(printf '%s' "$compact" | sed -nE 's/.*"'$key'"[[:space:]]*:[[:space:]]*([^,}]+).*/\1/p')" + value="$(trim "$value")" + value="${value%\"}" + value="${value#\"}" + printf '%s' "$value" +} + +json_get_nested_string_by_key() { + local input="$1" + local parent_key="$2" + local child_key="$3" + local compact + local value + + compact="$(json_compact "$input")" + value="$(printf '%s' "$compact" | sed -nE 's/.*"'$parent_key'"[[:space:]]*:[[:space:]]*\{[^}]*"'$child_key'"[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)".*/\1/p')" + [[ -n "$value" ]] && json_unescape_basic "$value" +} + +json_first_key() { + local input="$1" + + if (( HAS_JQ == 1 )); then + printf '%s' "$input" | jq -r 'keys[0] // empty' 2>/dev/null + return 0 + fi + + local compact + compact="$(json_compact "$input")" + printf '%s' "$compact" | sed -nE 's/^[[:space:]]*\{[[:space:]]*"([^"]+)".*/\1/p' +} + +json_array_items() { + local input="$1" + + if (( HAS_JQ == 1 )); then + printf '%s' "$input" | jq -r '.[]' 2>/dev/null + return 0 + fi + + local compact + compact="$(json_compact "$input")" + compact="${compact#[}" + compact="${compact%]}" + + [[ -z "$compact" ]] && return 0 + + printf '%s' "$compact" \ + | sed -E 's/^[[:space:]]*"//; s/"[[:space:]]*$//; s/"[[:space:]]*,[[:space:]]*"/\n/g' +} + +http_request() { + local method="$1" + local url="$2" + local data="$3" + local content_type="$4" + shift 4 + + local -a cmd + local request_id + local headers_text="" + local raw_output="" + local response="" + local http_code="" + local curl_exit=0 + local marker=$'\n__PAM_HTTP_CODE__:' + + cmd=(curl -sS -X "$method" "$url") + + if [[ -n "$TOKEN" ]]; then + cmd+=(-H "Authorization: Basic ${TOKEN}") + headers_text+="Authorization: Basic ${TOKEN}"$'\n' + fi + + if [[ -n "$content_type" ]]; then + cmd+=(-H "Content-Type: ${content_type}") + headers_text+="Content-Type: ${content_type}"$'\n' + fi + + while (($#)); do + cmd+=(-H "$1") + headers_text+="$1"$'\n' + shift + done + + if [[ -n "$data" ]]; then + cmd+=(--data "$data") + fi + + next_trace_id + request_id="$CURRENT_TRACE_ID" + trace_request "$request_id" "$method" "$url" "$headers_text" "$data" + + raw_output=$("${cmd[@]}" -w $'\n__PAM_HTTP_CODE__:%{http_code}' 2>&1) || curl_exit=$? + if [[ "$raw_output" == *"$marker"* ]]; then + response="${raw_output%"$marker"*}" + http_code="${raw_output##*__PAM_HTTP_CODE__:}" + else + response="$raw_output" + fi + + trace_response "$request_id" "$curl_exit" "$http_code" "$response" + + if (( curl_exit != 0 )); then + log_error "请求失败: ${method} ${url}" + log_error "$response" + return 1 + fi + + if [[ -n "$http_code" ]] && ! is_success_http_code "$http_code"; then + log_error "请求返回非成功状态码: ${method} ${url} -> HTTP ${http_code}" + log_error "$response" + return 1 + fi + + printf '%s' "$response" +} + +upload_file() { + local url="$1" + local file_path="$2" + shift 2 + + local -a cmd + local request_id + local fields_text="" + local raw_output="" + local response="" + local http_code="" + local curl_exit=0 + local marker=$'\n__PAM_HTTP_CODE__:' + + cmd=(curl -sS -X POST "$url") + + if [[ -n "$TOKEN" ]]; then + cmd+=(-H "Authorization: Basic ${TOKEN}") + fields_text+="Authorization: Basic ${TOKEN}"$'\n' + fi + + cmd+=(-F "file=@${file_path}") + + while (($#)); do + cmd+=(-F "$1") + fields_text+="$1"$'\n' + shift + done + + next_trace_id + request_id="$CURRENT_TRACE_ID" + trace_upload_request "$request_id" "$url" "$file_path" "$fields_text" + + raw_output=$("${cmd[@]}" -w $'\n__PAM_HTTP_CODE__:%{http_code}' 2>&1) || curl_exit=$? + if [[ "$raw_output" == *"$marker"* ]]; then + response="${raw_output%"$marker"*}" + http_code="${raw_output##*__PAM_HTTP_CODE__:}" + else + response="$raw_output" + fi + + trace_response "$request_id" "$curl_exit" "$http_code" "$response" + + if (( curl_exit != 0 )); then + log_error "上传失败: $response" + return 1 + fi + + if [[ -n "$http_code" ]] && ! is_success_http_code "$http_code"; then + log_error "上传返回非成功状态码: ${url} -> HTTP ${http_code}" + log_error "$response" + return 1 + fi + + printf '%s' "$response" +} + +get_token() { + log_info "正在获取 Token..." + local response + response=$(http_request "POST" \ + "${HOME_BASE_URL}/oauth/token" \ + "grant_type=client_credentials&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}" \ + "application/x-www-form-urlencoded") || { + log_error "获取 Token 失败: $response" + return 1 + } + + TOKEN="$(json_value "$response" '.access_token')" + if [[ -z "$TOKEN" || "$TOKEN" == "null" ]]; then + log_error "Token 响应无效: $response" + return 1 + fi +} + +create_version() { + log_info "Step 2.1: 新建版本..." + http_request "POST" \ + "${HOME_BASE_URL}/api/version/upgrade" \ + "versionNumber=${VERSION_NUMBER}&applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&description=Auto Deploy" \ + "application/x-www-form-urlencoded" >/dev/null +} + +upload_package() { + log_info "Step 2.2: 上传软件包..." + local response + response=$(upload_file \ + "${HOME_BASE_URL}/api/version/upgrade/upload" \ + "$ZIP_FILE_PATH" \ + "applicationName=${APP_NAME}" \ + "moduleName=${MODULE_NAME}" \ + "versionNumber=${VERSION_NUMBER}") || return 1 + + HASH_CODE="$(json_value "$response" '.hashCode // .data.hashCode')" + if [[ -z "$HASH_CODE" ]]; then + HASH_CODE="$(sanitize_field "$response")" + fi + + if [[ -z "$HASH_CODE" ]]; then + log_error "无法从上传响应中解析 hashCode: $response" + return 1 + fi +} + +publish_version() { + log_info "Step 2.3: 发布版本..." + local payload + payload=$(printf '{"airportCodesWhite":["%s"],"hashCode":"%s","state":"RELEASE"}' "$AIRPORT_CODE" "$HASH_CODE") + + http_request "PUT" \ + "${HOME_BASE_URL}/api/version/upgrade/profile?versionNumber=${VERSION_NUMBER}&applicationName=${APP_NAME}&moduleName=${MODULE_NAME}" \ + "$payload" \ + "application/json" >/dev/null +} + +get_node_url() { + log_info "Step 3.1: 获取 Node 地址..." + local response + response=$(http_request "GET" \ + "${HOME_BASE_URL}/api/mcp/airport/target-node?airportCode=${AIRPORT_CODE}" \ + "" \ + "") || return 1 + + NODE_URL="$(json_first_key "$response")" + if [[ -z "$NODE_URL" ]]; then + log_error "无法获取 Node 地址: $response" + return 1 + fi + + case "$NODE_URL" in + http://*|https://*) ;; + *) + log_error "Target-Node 不是有效 URL: $NODE_URL" + return 1 + ;; + esac + + if [[ "$NODE_URL" == *" "* || "$NODE_URL" == *$'\t'* || "$NODE_URL" == *$'\r'* || "$NODE_URL" == *$'\n'* ]]; then + log_error "Target-Node 包含非法空白字符: $NODE_URL" + return 1 + fi +} + +get_online_ips() { + log_info "Step 3.2: 获取在线工作站列表..." + local response + local ip_lines + response=$(http_request "GET" \ + "${HOME_BASE_URL}/node_proxy/${AIRPORT_CODE}/api/mcp/version/upgrade/ips?applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&airportCode=${AIRPORT_CODE}" \ + "" \ + "" \ + "Target-Node: ${NODE_URL}") || return 1 + + ONLINE_IPS=() + ip_lines="$(json_array_items "$response")" + while IFS= read -r ip; do + [[ -n "$ip" ]] && ONLINE_IPS+=("$ip") + done <<< "$ip_lines" + + TOTAL_COUNT=${#ONLINE_IPS[@]} + if (( TOTAL_COUNT == 0 )); then + log_error "无在线工作站匹配该模块。原始响应: $response" + return 1 + fi +} + +poll_download_progress() { + local progress_url="${HOME_BASE_URL}/node_proxy/${AIRPORT_CODE}/api/mcp/version/upgrade/download-cloud/progress?applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&airportCode=${AIRPORT_CODE}" + local attempt=0 + local max_attempts=60 + local error_regex='[Ff]ail|[Ee]rror' + + while (( attempt < max_attempts )); do + local response + response=$(http_request "GET" "$progress_url" "" "" "Target-Node: ${NODE_URL}") || return 1 + + local status + status="$(json_value "$response" '.status')" + local success_flag + success_flag="$(json_value "$response" '.success')" + + if [[ "$status" == "completed" || "$success_flag" == "true" ]]; then + return 0 + fi + + local message + message="$(json_value "$response" '.message')" + if [[ "$message" =~ $error_regex ]]; then + log_error "Node 下载失败: $message" + return 1 + fi + + attempt=$((attempt + 1)) + sleep 2 + done + + log_error "Node 下载超时。" + return 1 +} + +download_cloud_to_node() { + log_info "Step 3.3: 下载软件包到 Node..." + http_request "GET" \ + "${HOME_BASE_URL}/node_proxy/${AIRPORT_CODE}/api/mcp/version/upgrade/download-cloud?versionNumber=${VERSION_NUMBER}&applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&timeOut=${TIMEOUT}" \ + "" \ + "" \ + "Target-Node: ${NODE_URL}" \ + "airport-code: ${AIRPORT_CODE}" >/dev/null + + poll_download_progress +} + +upgrade_ip() { + local ip="$1" + http_request "POST" \ + "${HOME_BASE_URL}/node_proxy/${AIRPORT_CODE}/api/mcp/version/upgrade" \ + "airportCode=${AIRPORT_CODE}&targetIp=${ip}&applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&versionNumber=${VERSION_NUMBER}&action=${ACTION_TYPE}&autoStart=false&timeOut=${TIMEOUT}" \ + "application/x-www-form-urlencoded" \ + "Target-Node: ${NODE_URL}" +} + +start_application() { + local ip="$1" + http_request "POST" \ + "${HOME_BASE_URL}/node_proxy/${AIRPORT_CODE}/api/mcp/version/upgrade/start-stop" \ + "airportCode=${AIRPORT_CODE}&targetIp=${ip}&applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&runstart=true" \ + "application/x-www-form-urlencoded" \ + "Target-Node: ${NODE_URL}" >/dev/null +} + +stop_application() { + local ip="$1" + http_request "POST" \ + "${HOME_BASE_URL}/node_proxy/${AIRPORT_CODE}/api/mcp/version/upgrade/start-stop" \ + "airportCode=${AIRPORT_CODE}&targetIp=${ip}&applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&runstart=false" \ + "application/x-www-form-urlencoded" \ + "Target-Node: ${NODE_URL}" >/dev/null +} + +verify_ip() { + local ip="$1" + http_request "GET" \ + "${HOME_BASE_URL}/node_proxy/${AIRPORT_CODE}/api/mcp/version/upgrade/verify?applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&airportCode=${AIRPORT_CODE}&targetIp=${ip}" \ + "" \ + "" \ + "Target-Node: ${NODE_URL}" +} + +download_log() { + local ip="$1" + local logs_dir="${SCRIPT_DIR}/logs" + local log_file="${logs_dir}/deploy_${ip}.log" + local err_file="${logs_dir}/error_${ip}.log" + local request_id + local trace_url="${HOME_BASE_URL}/node_proxy/${AIRPORT_CODE}/api/mcp/version/upgrade/log-download?applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&airportCode=${AIRPORT_CODE}&targetIp=${ip}&logName=${LOG_NAME}" + local curl_exit=0 + local http_code="" + local trace_error="" + + mkdir -p "$logs_dir" + next_trace_id + request_id="$CURRENT_TRACE_ID" + trace_request "$request_id" "GET" "$trace_url" "Authorization: Basic ${TOKEN}"$'\n'"Target-Node: ${NODE_URL}" "" + + if curl -sS -X GET \ + "$trace_url" \ + -H "Authorization: Basic ${TOKEN}" \ + -H "Target-Node: ${NODE_URL}" \ + -o "$log_file" \ + -w '%{http_code}' > "${err_file}.code" 2>"$err_file"; then + http_code="$(cat "${err_file}.code" 2>/dev/null)" + if [[ -s "$log_file" ]]; then + tail -n 5 "$log_file" > "${log_file}.summary" 2>/dev/null || true + else + printf 'Log content empty or no data\n' > "${log_file}.summary" + fi + else + curl_exit=$? + printf 'Log download failed. See %s\n' "$err_file" > "$log_file" + printf 'Log download failed\n' > "${log_file}.summary" + fi + + trace_error="$(cat "$err_file" 2>/dev/null)" + trace_download_result "$request_id" "$trace_url" "$http_code" "$curl_exit" "$log_file" "$trace_error" + rm -f "${err_file}.code" + + if (( curl_exit != 0 )); then + printf '%s' "$log_file" + return 1 + fi + + if [[ -n "$http_code" ]] && ! is_success_http_code "$http_code"; then + printf 'HTTP %s\n' "$http_code" > "${log_file}.summary" + printf '%s' "$log_file" + return 1 + fi + + printf '%s' "$log_file" +} + +rollback_ip() { + local ip="$1" + local stop_first="$2" + + if [[ "$stop_first" == "true" ]]; then + stop_application "$ip" >/dev/null 2>&1 || true + fi + + local response + if ! response=$(http_request "POST" \ + "${HOME_BASE_URL}/node_proxy/${AIRPORT_CODE}/api/mcp/version/upgrade/rollback" \ + "airportCode=${AIRPORT_CODE}&targetIp=${ip}&applicationName=${APP_NAME}&moduleName=${MODULE_NAME}&timeOut=${TIMEOUT}" \ + "application/x-www-form-urlencoded" \ + "Target-Node: ${NODE_URL}"); then + printf '%s' "ROLLBACK_REQUEST_FAILED" + return 0 + fi + + local rollback_success + rollback_success="$(json_value "$response" '.success')" + if [[ -n "$rollback_success" && "$rollback_success" != "true" ]]; then + printf '%s' "ROLLBACK_FAILED" + return 0 + fi + + local verify_response + if ! verify_response="$(verify_ip "$ip")"; then + printf '%s' "ROLLBACK_VERIFY_FAILED" + return 0 + fi + + if [[ "$(json_value "$verify_response" '.success')" == "true" ]]; then + printf '%s' "ROLLBACK_SUCCESS" + else + printf '%s' "ROLLBACK_VERIFY_FAILED" + fi +} + +add_result() { + local ip="$1" + local status="$2" + local stage="$3" + local message="$4" + local rollback="$5" + local log_file="$6" + + printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$(sanitize_field "$ip")" \ + "$(sanitize_field "$status")" \ + "$(sanitize_field "$stage")" \ + "$(sanitize_field "$message")" \ + "$(sanitize_field "$rollback")" \ + "$(sanitize_field "$log_file")" >> "$RESULTS_FILE" + + if [[ "$status" == "SUCCESS" ]]; then + SUCCESS_COUNT=$((SUCCESS_COUNT + 1)) + else + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +deploy_one_ip() { + local ip="$1" + log_info "处理工作站: $ip" + + local upgrade_response + if ! upgrade_response=$(upgrade_ip "$ip"); then + local log_file + log_file="$(download_log "$ip")" + add_result "$ip" "FAILED" "UPGRADE" "Upgrade request failed" "ROLLBACK_NOT_RUN" "$log_file" + return + fi + + if [[ "$(json_value "$upgrade_response" '.success')" != "true" ]]; then + local rollback_result + rollback_result="$(rollback_ip "$ip" "false")" + local log_file + log_file="$(download_log "$ip")" + local message + message="$(json_value "$upgrade_response" '.message')" + [[ -z "$message" ]] && message="Upgrade failed" + add_result "$ip" "FAILED" "UPGRADE" "$message" "$rollback_result" "$log_file" + return + fi + + if ! start_application "$ip"; then + local rollback_result + rollback_result="$(rollback_ip "$ip" "true")" + local log_file + log_file="$(download_log "$ip")" + add_result "$ip" "FAILED" "START" "Application start failed" "$rollback_result" "$log_file" + return + fi + + local verify_response + if ! verify_response="$(verify_ip "$ip")"; then + local rollback_result + rollback_result="$(rollback_ip "$ip" "true")" + local log_file + log_file="$(download_log "$ip")" + add_result "$ip" "FAILED" "VERIFY" "Health check request failed" "$rollback_result" "$log_file" + return + fi + + if [[ "$(json_value "$verify_response" '.success')" == "true" ]]; then + local log_file + log_file="$(download_log "$ip")" + add_result "$ip" "SUCCESS" "-" "-" "-" "$log_file" + return + fi + + local rollback_result + rollback_result="$(rollback_ip "$ip" "true")" + local log_file + log_file="$(download_log "$ip")" + local message + message="$(json_value "$verify_response" '.message')" + [[ -z "$message" ]] && message="Health check failed" + add_result "$ip" "FAILED" "VERIFY" "$message" "$rollback_result" "$log_file" +} + +print_report() { + printf '\n====================== 部署报告 ======================\n' + printf '模式: Shell\n' + printf '机场: %s\n' "$AIRPORT_CODE" + printf '应用: %s\n' "$APP_NAME" + printf '模块: %s\n' "$MODULE_NAME" + printf '版本: %s\n' "$VERSION_NUMBER" + printf '总工作站数: %s\n' "$TOTAL_COUNT" + printf '成功: %s\n' "$SUCCESS_COUNT" + printf '失败: %s\n' "$FAIL_COUNT" + printf '\n%-18s %-8s %-12s %-22s %s\n' "IP" "状态" "失败阶段" "回滚结果" "日志" + + while IFS=$'\t' read -r ip status stage message rollback log_file; do + printf '%-18s %-8s %-12s %-22s %s\n' "$ip" "$status" "$stage" "$rollback" "$log_file" + if [[ "$status" != "SUCCESS" ]]; then + printf ' 原因: %s\n' "$message" + fi + done < "$RESULTS_FILE" +} + +cleanup() { + [[ -n "$RESULTS_FILE" && -f "$RESULTS_FILE" ]] && rm -f "$RESULTS_FILE" +} + +init_runtime() { + RESULTS_FILE="$(mktemp "${TMPDIR:-/tmp}/pam_deploy_results.XXXXXX")" + trap cleanup EXIT +} + +main() { + local config_path="$DEFAULT_CONFIG_PATH" + + while (($#)); do + case "$1" in + --config) + [[ $# -lt 2 ]] && { log_error "--config 缺少路径"; exit 1; } + config_path="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + log_error "未知参数: $1" + usage + exit 1 + ;; + esac + done + + init_runtime + load_config "$config_path" + ensure_dependencies + ensure_zip_file || exit 1 + + log_info "PAM 智能部署开始" + log_info "机场: ${AIRPORT_CODE}, 版本: ${VERSION_NUMBER}, 模块: ${APP_NAME}/${MODULE_NAME}" + + get_token + create_version + upload_package + publish_version + get_node_url + get_online_ips + download_cloud_to_node + + for ip in "${ONLINE_IPS[@]}"; do + deploy_one_ip "$ip" + done + + print_report +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/doc_scripts/test_deploy.bat b/doc_scripts/test_deploy.bat new file mode 100644 index 0000000..c8208df --- /dev/null +++ b/doc_scripts/test_deploy.bat @@ -0,0 +1,5 @@ +@echo off +setlocal +set "SCRIPT_DIR=%~dp0" +powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%test_deploy.ps1" %* +exit /b %ERRORLEVEL% diff --git a/doc_scripts/test_deploy.ps1 b/doc_scripts/test_deploy.ps1 new file mode 100644 index 0000000..8ead03d --- /dev/null +++ b/doc_scripts/test_deploy.ps1 @@ -0,0 +1,283 @@ +param( + [Alias('ConfigPath')] + [string]$TestConfigPath = (Join-Path $PSScriptRoot 'config.txt'), + [ValidateSet('smoke', 'full')] + [string]$Mode = 'full', + [switch]$SkipRollback, + [int]$MaxIps = 0, + [switch]$Help +) + +# PAM 测试脚本(PowerShell 实现)。 +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($Help) { + @' +Usage: + powershell -File .\test_deploy.ps1 [-ConfigPath .\config.txt] [-Mode smoke|full] [-SkipRollback] [-MaxIps N] + +Modes: + smoke - only test config, token, node, and online IP lookup + full - test all deployment interfaces in workflow order + +Notes: + - full mode tests create version, upload, publish, node download, upgrade, start, verify, and log download + - rollback is tested once by default in full mode + - use -SkipRollback if you do not want the test script to change the deployed state + - use -MaxIps to limit how many online IPs are used for interface testing +'@ | Write-Host + exit 0 +} + +. (Join-Path $PSScriptRoot 'deploy.ps1') + +function Add-TestResult { + param( + [System.Collections.Generic.List[object]]$Results, + [string]$Step, + [string]$Status, + [string]$Detail + ) + + $Results.Add([pscustomobject]@{ + Step = $Step + Status = $Status + Detail = $Detail + }) | Out-Null +} + +function Write-TestReport { + param([System.Collections.Generic.List[object]]$Results) + + $passCount = @($Results | Where-Object { $_.Status -eq 'PASS' }).Count + $failCount = @($Results | Where-Object { $_.Status -eq 'FAIL' }).Count + $skipCount = @($Results | Where-Object { $_.Status -eq 'SKIP' }).Count + + Write-Host '' + Write-Host '====================== API TEST REPORT ======================' + Write-Host "PASS: $passCount" + Write-Host "FAIL: $failCount" + Write-Host "SKIP: $skipCount" + Write-Host '' + Write-Host ('{0,-28} {1,-8} {2}' -f 'STEP', 'STATUS', 'DETAIL') + foreach ($item in $Results) { + Write-Host ('{0,-28} {1,-8} {2}' -f $item.Step, $item.Status, $item.Detail) + } +} + +function Invoke-PamSmokeTest { + param([string]$ConfigPath) + + $results = [System.Collections.Generic.List[object]]::new() + $config = Get-PamConfig -Path $ConfigPath + + try { + Test-ZipFile -Config $config + Add-TestResult -Results $results -Step 'ZIP_FILE' -Status 'PASS' -Detail $config.ZIP_FILE_PATH + } catch { + Add-TestResult -Results $results -Step 'ZIP_FILE' -Status 'FAIL' -Detail $_.Exception.Message + Write-TestReport -Results $results + throw + } + + try { + $token = Get-Token -Config $config + Add-TestResult -Results $results -Step 'TOKEN' -Status 'PASS' -Detail ('token length={0}' -f $token.Length) + } catch { + Add-TestResult -Results $results -Step 'TOKEN' -Status 'FAIL' -Detail $_.Exception.Message + Write-TestReport -Results $results + throw + } + + try { + $nodeUrl = Get-NodeUrl -Config $config -Token $token + Add-TestResult -Results $results -Step 'TARGET_NODE' -Status 'PASS' -Detail $nodeUrl + } catch { + Add-TestResult -Results $results -Step 'TARGET_NODE' -Status 'FAIL' -Detail $_.Exception.Message + Write-TestReport -Results $results + throw + } + + try { + $ips = Get-OnlineIps -Config $config -Token $token -NodeUrl $nodeUrl + Add-TestResult -Results $results -Step 'ONLINE_IPS' -Status 'PASS' -Detail (($ips -join ', ')) + } catch { + Add-TestResult -Results $results -Step 'ONLINE_IPS' -Status 'FAIL' -Detail $_.Exception.Message + Write-TestReport -Results $results + throw + } + + Write-TestReport -Results $results +} + +function Invoke-PamFullTest { + param( + [string]$ConfigPath, + [bool]$ShouldSkipRollback, + [int]$MaxIps + ) + + $results = [System.Collections.Generic.List[object]]::new() + $config = Get-PamConfig -Path $ConfigPath + + try { + Test-ZipFile -Config $config + Add-TestResult -Results $results -Step 'ZIP_FILE' -Status 'PASS' -Detail $config.ZIP_FILE_PATH + } catch { + Add-TestResult -Results $results -Step 'ZIP_FILE' -Status 'FAIL' -Detail $_.Exception.Message + Write-TestReport -Results $results + throw + } + + try { + $token = Get-Token -Config $config + Add-TestResult -Results $results -Step 'TOKEN' -Status 'PASS' -Detail ('token length={0}' -f $token.Length) + } catch { + Add-TestResult -Results $results -Step 'TOKEN' -Status 'FAIL' -Detail $_.Exception.Message + Write-TestReport -Results $results + throw + } + + try { + New-VersionRecord -Config $config -Token $token + Add-TestResult -Results $results -Step 'CREATE_VERSION' -Status 'PASS' -Detail $config.VERSION_NUMBER + } catch { + Add-TestResult -Results $results -Step 'CREATE_VERSION' -Status 'FAIL' -Detail $_.Exception.Message + Write-TestReport -Results $results + throw + } + + try { + $hashCode = Upload-Package -Config $config -Token $token + Add-TestResult -Results $results -Step 'UPLOAD_PACKAGE' -Status 'PASS' -Detail $hashCode + } catch { + Add-TestResult -Results $results -Step 'UPLOAD_PACKAGE' -Status 'FAIL' -Detail $_.Exception.Message + Write-TestReport -Results $results + throw + } + + try { + Publish-Version -Config $config -Token $token -HashCode $hashCode + Add-TestResult -Results $results -Step 'PUBLISH_VERSION' -Status 'PASS' -Detail $config.AIRPORT_CODE + } catch { + Add-TestResult -Results $results -Step 'PUBLISH_VERSION' -Status 'FAIL' -Detail $_.Exception.Message + Write-TestReport -Results $results + throw + } + + try { + $nodeUrl = Get-NodeUrl -Config $config -Token $token + Add-TestResult -Results $results -Step 'TARGET_NODE' -Status 'PASS' -Detail $nodeUrl + } catch { + Add-TestResult -Results $results -Step 'TARGET_NODE' -Status 'FAIL' -Detail $_.Exception.Message + Write-TestReport -Results $results + throw + } + + try { + $ips = Get-OnlineIps -Config $config -Token $token -NodeUrl $nodeUrl + Add-TestResult -Results $results -Step 'ONLINE_IPS' -Status 'PASS' -Detail (($ips -join ', ')) + } catch { + Add-TestResult -Results $results -Step 'ONLINE_IPS' -Status 'FAIL' -Detail $_.Exception.Message + Write-TestReport -Results $results + throw + } + + try { + Download-CloudToNode -Config $config -Token $token -NodeUrl $nodeUrl + Add-TestResult -Results $results -Step 'DOWNLOAD_TO_NODE' -Status 'PASS' -Detail $config.VERSION_NUMBER + } catch { + Add-TestResult -Results $results -Step 'DOWNLOAD_TO_NODE' -Status 'FAIL' -Detail $_.Exception.Message + Write-TestReport -Results $results + throw + } + + $testIps = @($ips) + if ($MaxIps -gt 0) { + $testIps = @($ips | Select-Object -First $MaxIps) + } + + $rollbackTested = $false + + foreach ($ip in $testIps) { + try { + $upgradeResponse = Invoke-UpgradeRequest -Config $config -Token $token -NodeUrl $nodeUrl -Ip $ip + $upgradeSuccess = Get-ResponseValue -Response $upgradeResponse -Candidates @('success') + $upgradeMessage = Get-ResponseValue -Response $upgradeResponse -Candidates @('message') + if ($upgradeSuccess -eq 'true') { + Add-TestResult -Results $results -Step "UPGRADE [$ip]" -Status 'PASS' -Detail 'success=true' + } else { + if (-not $upgradeMessage) { $upgradeMessage = 'success != true' } + Add-TestResult -Results $results -Step "UPGRADE [$ip]" -Status 'FAIL' -Detail $upgradeMessage + } + } catch { + Add-TestResult -Results $results -Step "UPGRADE [$ip]" -Status 'FAIL' -Detail $_.Exception.Message + } + + try { + Start-Application -Config $config -Token $token -NodeUrl $nodeUrl -Ip $ip + Add-TestResult -Results $results -Step "START [$ip]" -Status 'PASS' -Detail 'request completed' + } catch { + Add-TestResult -Results $results -Step "START [$ip]" -Status 'FAIL' -Detail $_.Exception.Message + } + + try { + $verifyResponse = Verify-Ip -Config $config -Token $token -NodeUrl $nodeUrl -Ip $ip + $verifySuccess = Get-ResponseValue -Response $verifyResponse -Candidates @('success') + $verifyMessage = Get-ResponseValue -Response $verifyResponse -Candidates @('message') + if ($verifySuccess -eq 'true') { + Add-TestResult -Results $results -Step "VERIFY [$ip]" -Status 'PASS' -Detail 'success=true' + } else { + if (-not $verifyMessage) { $verifyMessage = 'success != true' } + Add-TestResult -Results $results -Step "VERIFY [$ip]" -Status 'FAIL' -Detail $verifyMessage + } + } catch { + Add-TestResult -Results $results -Step "VERIFY [$ip]" -Status 'FAIL' -Detail $_.Exception.Message + } + + try { + $logFile = Download-DeployLog -Config $config -Token $token -NodeUrl $nodeUrl -Ip $ip + Add-TestResult -Results $results -Step "LOG_DOWNLOAD [$ip]" -Status 'PASS' -Detail $logFile + } catch { + Add-TestResult -Results $results -Step "LOG_DOWNLOAD [$ip]" -Status 'FAIL' -Detail $_.Exception.Message + } + + if (-not $ShouldSkipRollback -and -not $rollbackTested) { + try { + $rollbackResult = Invoke-Rollback -Config $config -Token $token -NodeUrl $nodeUrl -Ip $ip -StopFirst:$true + if ($rollbackResult -eq 'ROLLBACK_SUCCESS') { + Add-TestResult -Results $results -Step "ROLLBACK [$ip]" -Status 'PASS' -Detail $rollbackResult + } else { + Add-TestResult -Results $results -Step "ROLLBACK [$ip]" -Status 'FAIL' -Detail $rollbackResult + } + } catch { + Add-TestResult -Results $results -Step "ROLLBACK [$ip]" -Status 'FAIL' -Detail $_.Exception.Message + } + $rollbackTested = $true + } + } + + if ($ShouldSkipRollback) { + Add-TestResult -Results $results -Step 'ROLLBACK' -Status 'SKIP' -Detail 'skipped by parameter' + } elseif (-not $rollbackTested) { + Add-TestResult -Results $results -Step 'ROLLBACK' -Status 'SKIP' -Detail 'no IP available for rollback test' + } + + Write-TestReport -Results $results + + if (@($results | Where-Object { $_.Status -eq 'FAIL' }).Count -gt 0) { + throw 'One or more API tests failed.' + } +} + +try { + if ($Mode -eq 'smoke') { + Invoke-PamSmokeTest -ConfigPath $TestConfigPath + } else { + Invoke-PamFullTest -ConfigPath $TestConfigPath -ShouldSkipRollback:$SkipRollback.IsPresent -MaxIps $MaxIps + } +} catch { + Write-ErrLog $_ + exit 1 +} diff --git a/doc_scripts/test_deploy.sh b/doc_scripts/test_deploy.sh new file mode 100644 index 0000000..cd0c470 --- /dev/null +++ b/doc_scripts/test_deploy.sh @@ -0,0 +1,323 @@ +#!/usr/bin/env bash + +# PAM 测试脚本(Shell 入口)。 +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# 复用部署脚本中的函数供测试脚本调用。 +# shellcheck source=./deploy.sh +source "${SCRIPT_DIR}/deploy.sh" + +TEST_MODE="full" +SKIP_ROLLBACK="false" +MAX_IPS=0 +TEST_RESULTS=() +HAS_FAILURE=0 + +test_usage() { + cat <<'EOF' +用法: + ./test_deploy.sh [--config /path/to/config.txt] [--mode smoke|full] [--skip-rollback] [--max-ips N] + +模式: + smoke 仅测试配置、Token、Node 和在线 IP 查询 + full 按部署顺序测试全部接口 + +说明: + - full 模式会测试建版、上传、发布、Node 下载、升级、启动、校验和日志下载 + - rollback 默认会额外测试一次 + - 如果不希望测试脚本改变部署状态,可加 --skip-rollback + - --max-ips 可限制参与接口测试的在线 IP 数量 +EOF +} + +add_test_result() { + local step="$1" + local status="$2" + local detail="$3" + + TEST_RESULTS+=("${step}|${status}|${detail}") + if [[ "$status" == "FAIL" ]]; then + HAS_FAILURE=1 + fi + + case "$status" in + PASS) + printf '[PASS] %s | %s\n' "$step" "$detail" + ;; + FAIL) + printf '[FAIL] %s | %s\n' "$step" "$detail" >&2 + ;; + SKIP) + printf '[SKIP] %s | %s\n' "$step" "$detail" + ;; + esac +} + +print_test_report() { + local pass_count=0 + local fail_count=0 + local skip_count=0 + + for row in "${TEST_RESULTS[@]}"; do + IFS='|' read -r _step _status _detail <<< "$row" + case "$_status" in + PASS) pass_count=$((pass_count + 1)) ;; + FAIL) fail_count=$((fail_count + 1)) ;; + SKIP) skip_count=$((skip_count + 1)) ;; + esac + done + + printf '\n====================== API TEST REPORT ======================\n' + printf 'PASS: %s\n' "$pass_count" + printf 'FAIL: %s\n' "$fail_count" + printf 'SKIP: %s\n\n' "$skip_count" + printf '%-28s %-8s %s\n' "STEP" "STATUS" "DETAIL" + + for row in "${TEST_RESULTS[@]}"; do + IFS='|' read -r step status detail <<< "$row" + printf '%-28s %-8s %s\n' "$step" "$status" "$detail" + done +} + +run_smoke_test() { + load_config "$1" + ensure_dependencies + + if ensure_zip_file; then + add_test_result "ZIP_FILE" "PASS" "$ZIP_FILE_PATH" + else + add_test_result "ZIP_FILE" "FAIL" "$ZIP_FILE_PATH" + print_test_report + return 1 + fi + + if get_token; then + add_test_result "TOKEN" "PASS" "token length=${#TOKEN}" + else + add_test_result "TOKEN" "FAIL" "token request failed" + print_test_report + return 1 + fi + + if get_node_url; then + add_test_result "TARGET_NODE" "PASS" "$NODE_URL" + else + add_test_result "TARGET_NODE" "FAIL" "node lookup failed" + print_test_report + return 1 + fi + + if get_online_ips; then + add_test_result "ONLINE_IPS" "PASS" "${ONLINE_IPS[*]}" + else + add_test_result "ONLINE_IPS" "FAIL" "online ip lookup failed" + print_test_report + return 1 + fi + + print_test_report +} + +run_full_test() { + local config_path="$1" + local rollback_tested="false" + local -a test_ips=() + + load_config "$config_path" + ensure_dependencies + + if ensure_zip_file; then + add_test_result "ZIP_FILE" "PASS" "$ZIP_FILE_PATH" + else + add_test_result "ZIP_FILE" "FAIL" "$ZIP_FILE_PATH" + print_test_report + return 1 + fi + + if get_token; then + add_test_result "TOKEN" "PASS" "token length=${#TOKEN}" + else + add_test_result "TOKEN" "FAIL" "token request failed" + print_test_report + return 1 + fi + + if create_version; then + add_test_result "CREATE_VERSION" "PASS" "$VERSION_NUMBER" + else + add_test_result "CREATE_VERSION" "FAIL" "$VERSION_NUMBER" + print_test_report + return 1 + fi + + if upload_package; then + add_test_result "UPLOAD_PACKAGE" "PASS" "$HASH_CODE" + else + add_test_result "UPLOAD_PACKAGE" "FAIL" "upload failed" + print_test_report + return 1 + fi + + if publish_version; then + add_test_result "PUBLISH_VERSION" "PASS" "$AIRPORT_CODE" + else + add_test_result "PUBLISH_VERSION" "FAIL" "publish failed" + print_test_report + return 1 + fi + + if get_node_url; then + add_test_result "TARGET_NODE" "PASS" "$NODE_URL" + else + add_test_result "TARGET_NODE" "FAIL" "node lookup failed" + print_test_report + return 1 + fi + + if get_online_ips; then + add_test_result "ONLINE_IPS" "PASS" "${ONLINE_IPS[*]}" + else + add_test_result "ONLINE_IPS" "FAIL" "online ip lookup failed" + print_test_report + return 1 + fi + + if download_cloud_to_node; then + add_test_result "DOWNLOAD_TO_NODE" "PASS" "$VERSION_NUMBER" + else + add_test_result "DOWNLOAD_TO_NODE" "FAIL" "node download failed" + print_test_report + return 1 + fi + + test_ips=("${ONLINE_IPS[@]}") + if (( MAX_IPS > 0 )) && (( ${#test_ips[@]} > MAX_IPS )); then + test_ips=("${test_ips[@]:0:MAX_IPS}") + fi + + for ip in "${test_ips[@]}"; do + local upgrade_response="" + local upgrade_success="" + local upgrade_message="" + local verify_response="" + local verify_success="" + local verify_message="" + local log_file="" + local rollback_result="" + + if upgrade_response="$(upgrade_ip "$ip")"; then + upgrade_success="$(json_value "$upgrade_response" '.success')" + upgrade_message="$(json_value "$upgrade_response" '.message')" + if [[ "$upgrade_success" == "true" ]]; then + add_test_result "UPGRADE [$ip]" "PASS" "success=true" + else + [[ -z "$upgrade_message" ]] && upgrade_message="success != true" + add_test_result "UPGRADE [$ip]" "FAIL" "$upgrade_message" + fi + else + add_test_result "UPGRADE [$ip]" "FAIL" "request failed" + fi + + if start_application "$ip"; then + add_test_result "START [$ip]" "PASS" "request completed" + else + add_test_result "START [$ip]" "FAIL" "request failed" + fi + + if verify_response="$(verify_ip "$ip")"; then + verify_success="$(json_value "$verify_response" '.success')" + verify_message="$(json_value "$verify_response" '.message')" + if [[ "$verify_success" == "true" ]]; then + add_test_result "VERIFY [$ip]" "PASS" "success=true" + else + [[ -z "$verify_message" ]] && verify_message="success != true" + add_test_result "VERIFY [$ip]" "FAIL" "$verify_message" + fi + else + add_test_result "VERIFY [$ip]" "FAIL" "request failed" + fi + + if log_file="$(download_log "$ip")"; then + add_test_result "LOG_DOWNLOAD [$ip]" "PASS" "$log_file" + else + add_test_result "LOG_DOWNLOAD [$ip]" "FAIL" "log download failed: ${log_file}" + fi + + if [[ "$SKIP_ROLLBACK" == "false" && "$rollback_tested" == "false" ]]; then + rollback_result="$(rollback_ip "$ip" "true")" + if [[ "$rollback_result" == "ROLLBACK_SUCCESS" ]]; then + add_test_result "ROLLBACK [$ip]" "PASS" "$rollback_result" + else + add_test_result "ROLLBACK [$ip]" "FAIL" "$rollback_result" + fi + rollback_tested="true" + fi + done + + if [[ "$SKIP_ROLLBACK" == "true" ]]; then + add_test_result "ROLLBACK" "SKIP" "skipped by parameter" + elif [[ "$rollback_tested" == "false" ]]; then + add_test_result "ROLLBACK" "SKIP" "no IP available for rollback test" + fi + + print_test_report + + if (( HAS_FAILURE != 0 )); then + return 1 + fi +} + +main() { + local config_path="$DEFAULT_CONFIG_PATH" + + while (($#)); do + case "$1" in + --config) + [[ $# -lt 2 ]] && { log_error "--config 缺少路径"; exit 1; } + config_path="$2" + shift 2 + ;; + --mode) + [[ $# -lt 2 ]] && { log_error "--mode 缺少取值"; exit 1; } + TEST_MODE="$2" + shift 2 + ;; + --skip-rollback) + SKIP_ROLLBACK="true" + shift + ;; + --max-ips) + [[ $# -lt 2 ]] && { log_error "--max-ips 缺少取值"; exit 1; } + MAX_IPS="$2" + shift 2 + ;; + -h|--help) + test_usage + exit 0 + ;; + *) + log_error "未知参数: $1" + test_usage + exit 1 + ;; + esac + done + + case "$TEST_MODE" in + smoke) + run_smoke_test "$config_path" + ;; + full) + run_full_test "$config_path" + ;; + *) + log_error "不支持的测试模式: $TEST_MODE" + exit 1 + ;; + esac +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/doc_scripts/当前脚本情况总结.md b/doc_scripts/当前脚本情况总结.md new file mode 100644 index 0000000..39243f1 --- /dev/null +++ b/doc_scripts/当前脚本情况总结.md @@ -0,0 +1,313 @@ +# 当前脚本情况总结 + +## 1. 当前文件说明 + +当前目录下与部署直接相关的脚本如下: + +- `deploy.sh` + - Shell 主部署脚本 + - 适合 Git Bash / Linux / macOS 环境 +- `test_deploy.sh` + - Shell 测试脚本 + - 默认执行全接口测试 +- `deploy.ps1` + - PowerShell 主部署脚本 + - Windows 下的实际实现入口 +- `test_deploy.ps1` + - PowerShell 测试脚本 + - 支持 `smoke` / `full` 两种模式 +- `deploy.bat` + - Windows 包装入口 + - 实际调用 `deploy.ps1` +- `test_deploy.bat` + - Windows 测试包装入口 + - 实际调用 `test_deploy.ps1` +- `config.txt.example` + - 配置示例文件 + +## 2. 当前能力 + +### 2.1 主部署脚本 + +`deploy.sh` / `deploy.ps1` 当前都覆盖以下流程: + +1. 获取 Token +2. 新建版本记录 +3. 上传软件包 +4. 发布版本 +5. 获取 Target Node +6. 获取在线工作站 IP 列表 +7. 下载软件包到 Node +8. 逐台执行升级 +9. 启动应用 +10. 健康检查 +11. 下载日志 +12. 失败时触发回滚 +13. 输出最终部署报告 + +### 2.2 测试脚本 + +`test_deploy.sh` / `test_deploy.ps1` 当前支持: + +- `smoke` + - 仅测试配置、Token、Node、在线 IP 查询 +- `full` + - 按完整部署顺序测试全部接口 + +`full` 模式会测试到: + +1. `oauth/token` +2. `api/version/upgrade` +3. `api/version/upgrade/upload` +4. `api/version/upgrade/profile` +5. `api/mcp/airport/target-node` +6. `api/mcp/version/upgrade/ips` +7. `api/mcp/version/upgrade/download-cloud` +8. `api/mcp/version/upgrade/download-cloud/progress` +9. `api/mcp/version/upgrade` +10. `api/mcp/version/upgrade/start-stop` +11. `api/mcp/version/upgrade/verify` +12. `api/mcp/version/upgrade/log-download` +13. `api/mcp/version/upgrade/rollback` + +## 3. 当前运行方式 + +### 3.1 Windows + +正式部署: + +```bat +deploy.bat -ConfigPath .\config.txt +``` + +全接口测试: + +```bat +test_deploy.bat -ConfigPath .\config.txt +``` + +只做轻量测试: + +```bat +test_deploy.bat -ConfigPath .\config.txt -Mode smoke +``` + +不测回滚: + +```bat +test_deploy.bat -ConfigPath .\config.txt -SkipRollback +``` + +限制只测前 N 台在线 IP: + +```bat +test_deploy.bat -ConfigPath .\config.txt -MaxIps 1 +``` + +### 3.2 Shell / Git Bash + +正式部署: + +```bash +bash ./deploy.sh --config ./config.txt +``` + +全接口测试: + +```bash +bash ./test_deploy.sh --config ./config.txt --mode full +``` + +轻量测试: + +```bash +bash ./test_deploy.sh --config ./config.txt --mode smoke +``` + +跳过回滚: + +```bash +bash ./test_deploy.sh --config ./config.txt --mode full --skip-rollback +``` + +限制 IP 数量: + +```bash +bash ./test_deploy.sh --config ./config.txt --mode full --max-ips 1 +``` + +## 4. 已处理的问题 + +### 4.1 Windows Batch 中 `!` 被吞掉 + +之前的核心问题是: + +- `setlocal EnableDelayedExpansion` 打开后 +- `CLIENT_SECRET` 中如果有 `!` +- 传给 `curl` 时会被 Batch 吞掉 + +当前处理方式: + +- `deploy.bat` / `test_deploy.bat` 只做包装 +- 真实逻辑全部转给 `deploy.ps1` / `test_deploy.ps1` +- 不再在 Batch 中直接拼接敏感参数 + +### 4.2 PowerShell 对 JSON 解析兼容问题 + +之前测试环境里出现过: + +- 接口已经返回 `access_token` +- 但脚本仍报 `Invalid token response` + +当前处理方式: + +- `PowerShell 6+` 使用 `ConvertFrom-Json -Depth 100` +- `Windows PowerShell 5.x` 使用兼容写法 `ConvertFrom-Json` + +### 4.3 Git Bash 对 `[[ ... =~ ... ]]` 和进程替换兼容问题 + +之前 Shell 侧出现过: + +- 正则匹配语法在 Git Bash 下报错 +- `done < <(...)` 在当前环境下报错 + +当前处理方式: + +- 改成更兼容的正则变量写法 +- 改成 here-string 方式读取解析结果 + +### 4.4 `jq` 缺失问题 + +当前 `deploy.sh` 已做成: + +- 有 `jq`:优先使用 `jq` +- 没有 `jq`:自动降级为 Bash 兼容 JSON 解析 + +已覆盖的 fallback 解析场景: + +- `access_token` +- `status` +- `success` +- `message` +- `hashCode` +- `data.hashCode` +- 顶层对象首个 key +- 顶层字符串数组 + +注意: + +- 这不是完整 JSON 解析器 +- 目前是针对当前 PAM 接口返回结构做的兼容处理 + +### 4.5 日志接口 `401` 仍显示通过 + +之前 Shell 测试脚本里: + +- `LOG_DOWNLOAD` 只看文件是否存在 +- 即使接口返回 `401` +- 也可能被误判成 `PASS` + +当前处理方式: + +- `http_request` 和 `upload_file` 已增加 HTTP 状态码校验 +- 只有 `2xx` 才算成功 +- `download_log` 也会校验 HTTP 状态码 +- `test_deploy.sh` 的 `LOG_DOWNLOAD` 现在按返回码判定,不再只按文件存在判定 + +### 4.6 `Target-Node` 未校验 + +之前如果 `target-node` 接口返回的 key 不是合法 URL: + +- 后续所有 Node 相关接口都会异常 +- 但问题源头不够直观 + +当前处理方式: + +- `get_node_url` 已加入 `Target-Node` 校验 +- 必须是 `http://` 或 `https://` 开头 +- 不允许包含空格、制表符、换行等非法空白字符 + +## 5. 当前日志能力 + +### 5.1 业务日志 + +测试脚本当前会在控制台边跑边打印: + +```text +[PASS] TOKEN | ... +[FAIL] VERIFY [ip] | ... +[SKIP] ROLLBACK | ... +``` + +最后还会输出汇总报告: + +- `PASS` +- `FAIL` +- `SKIP` + +### 5.2 接口跟踪日志 + +`deploy.sh` 当前已经加入接口级 trace 日志。 + +日志特点: + +- 位置:`logs/api_trace_YYYYMMDD_HHMMSS_PID.log` +- 自动记录每个请求 +- 自动生成请求编号,如 `REQ-0001` + +当前已记录内容: + +- 请求时间 +- 请求方法 +- 请求 URL +- 请求头 +- 请求体 +- `curl` 退出码 +- HTTP 状态码 +- 响应体 + +已做脱敏: + +- `client_secret` +- `Authorization` +- `access_token` + +## 6. 当前已知限制 + +1. Shell 侧虽然支持无 `jq` 运行,但 fallback JSON 解析只针对当前接口结构,不适合复杂 JSON。 +2. 回滚测试默认会真实调用回滚接口,会改变测试环境状态;如不希望改变状态,需要显式加 `--skip-rollback`。 +3. `full` 模式会真实创建版本、上传包、发布、升级、启动、回滚,因此它不是只读检查。 +4. PowerShell 与 Shell 两套脚本逻辑大体对齐,但最新的一些 Shell 侧 trace 和兼容修复,后续仍建议同步到 PowerShell 版本。 + +## 7. 当前建议 + +1. 在测试环境优先使用测试脚本,不要直接先跑正式部署脚本。 +2. 第一次联调建议先跑: + +```bash +bash ./test_deploy.sh --config ./config.txt --mode smoke +``` + +3. 基础联通没问题后再跑: + +```bash +bash ./test_deploy.sh --config ./config.txt --mode full --skip-rollback +``` + +4. 如果要排查具体接口问题,优先查看: + +- 控制台 `[PASS] / [FAIL]` +- `logs/api_trace_*.log` + +5. 如果某一台 IP 的 `verify`、`log-download` 或 `rollback` 异常,建议把对应 `REQ-xxxx` 的 trace 片段单独摘出来分析。 + +## 8. 后续可继续优化的方向 + +1. 把 PowerShell 脚本里的帮助文本、输出文案进一步统一为中文。 +2. 把 Shell 侧新增的接口 trace 能力同步到 PowerShell 版本。 +3. 给接口 trace 再拆分摘要视图,例如: + - `summary.log` + - `request.log` + - `response.log` +4. 给测试脚本增加“只测试指定接口”的模式,方便单点排障。 +5. 给 `download_log` 增加更明确的响应内容校验,而不只是 HTTP 状态码。