新增自动部署相关脚本和说明

This commit is contained in:
redbotu 2026-05-17 23:19:52 +08:00
parent 064f9adaab
commit 753078d345
10 changed files with 3579 additions and 0 deletions

View File

@ -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 |
```

View File

@ -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 脚本中执行。

View File

@ -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

5
doc_scripts/deploy.bat Normal file
View File

@ -0,0 +1,5 @@
@echo off
setlocal
set "SCRIPT_DIR=%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%deploy.ps1" %*
exit /b %ERRORLEVEL%

763
doc_scripts/deploy.ps1 Normal file
View File

@ -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
}
}

976
doc_scripts/deploy.sh Normal file
View File

@ -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

View File

@ -0,0 +1,5 @@
@echo off
setlocal
set "SCRIPT_DIR=%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%test_deploy.ps1" %*
exit /b %ERRORLEVEL%

283
doc_scripts/test_deploy.ps1 Normal file
View File

@ -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
}

323
doc_scripts/test_deploy.sh Normal file
View File

@ -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

View File

@ -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 状态码。