固化部署体检与备份合同刷新(08-02-deployment-preflight-backup-contract-refresh)
这个提交包含在:
+174
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
DEPLOY_SERVER="${DEPLOY_SERVER:-}"
|
||||
DEPLOY_REFRESH_SERVER="${DEPLOY_REFRESH_SERVER:-}"
|
||||
DEPLOY_CONTRACT_UNIT="${DEPLOY_CONTRACT_UNIT:-hyb-oa-automatic-backup-daily.service}"
|
||||
|
||||
# shellcheck source=deployment-contract-lib.sh
|
||||
source "$ROOT_DIR/deploy/trialrun/deployment-contract-lib.sh"
|
||||
|
||||
CHECK_NAMES=(
|
||||
deploy_ssh_identity
|
||||
contract_six_keys
|
||||
repository_root
|
||||
repository_writable
|
||||
env_readable
|
||||
data_directories
|
||||
runtime_unique
|
||||
runtime_build
|
||||
storage_mount
|
||||
pgdata_mount
|
||||
backup_build
|
||||
root_ssh_identity
|
||||
refresh_program
|
||||
)
|
||||
declare -A CHECK_RESULTS=()
|
||||
declare -A CONTRACT=()
|
||||
for check_name in "${CHECK_NAMES[@]}"; do
|
||||
CHECK_RESULTS["$check_name"]="FAIL"
|
||||
done
|
||||
|
||||
if deployment_contract_safe_ssh_target "$DEPLOY_SERVER" \
|
||||
&& ssh "$DEPLOY_SERVER" true >/dev/null 2>&1; then
|
||||
CHECK_RESULTS[deploy_ssh_identity]="PASS"
|
||||
fi
|
||||
|
||||
if deployment_contract_load "$DEPLOY_SERVER" "$DEPLOY_CONTRACT_UNIT" CONTRACT; then
|
||||
CHECK_RESULTS[contract_six_keys]="PASS"
|
||||
|
||||
remote_results="$(ssh "$DEPLOY_SERVER" \
|
||||
"REMOTE_DIR='${CONTRACT[REMOTE_DIR]}' REMOTE_ENV_FILE='${CONTRACT[REMOTE_ENV_FILE]}' COMPOSE_FILE='${CONTRACT[COMPOSE_FILE]}' COMPOSE_PROJECT_NAME='${CONTRACT[COMPOSE_PROJECT_NAME]}' POSTGRES_DATA_DIR='${CONTRACT[POSTGRES_DATA_DIR]}' STORAGE_HOST_DIR='${CONTRACT[STORAGE_HOST_DIR]}' CONTRACT_UNIT='${DEPLOY_CONTRACT_UNIT}' bash -s" 2>/dev/null <<'REMOTE'
|
||||
set -uo pipefail
|
||||
|
||||
emit() {
|
||||
local name="$1"
|
||||
shift
|
||||
if ("$@") >/dev/null 2>&1; then
|
||||
printf '%s=PASS\n' "$name"
|
||||
else
|
||||
printf '%s=FAIL\n' "$name"
|
||||
fi
|
||||
}
|
||||
|
||||
repository_root() {
|
||||
[[ -d "$REMOTE_DIR" && -f "$REMOTE_DIR/server/package.json" \
|
||||
&& -f "$REMOTE_DIR/$COMPOSE_FILE" ]]
|
||||
}
|
||||
repository_writable() { [[ -w "$REMOTE_DIR" ]]; }
|
||||
env_readable() { [[ -f "$REMOTE_ENV_FILE" && ! -L "$REMOTE_ENV_FILE" && -r "$REMOTE_ENV_FILE" ]]; }
|
||||
data_directories() { [[ -f "$POSTGRES_DATA_DIR/PG_VERSION" && -d "$STORAGE_HOST_DIR" ]]; }
|
||||
|
||||
container_ids() {
|
||||
local service="$1"
|
||||
docker ps \
|
||||
--filter "label=com.docker.compose.project=$COMPOSE_PROJECT_NAME" \
|
||||
--filter "label=com.docker.compose.service=$service" \
|
||||
--format '{{.ID}}'
|
||||
}
|
||||
|
||||
container_env() {
|
||||
local container="$1"
|
||||
local wanted="$2"
|
||||
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$container" \
|
||||
| sed -n "s/^${wanted}=//p" | tail -n 1
|
||||
}
|
||||
|
||||
mount_source() {
|
||||
local container="$1"
|
||||
local destination="$2"
|
||||
docker inspect -f '{{json .Mounts}}' "$container" | python3 -c '
|
||||
import json, sys
|
||||
destination = sys.argv[1]
|
||||
mounts = json.load(sys.stdin)
|
||||
matches = [item.get("Source", "") for item in mounts if item.get("Destination") == destination]
|
||||
if len(matches) != 1 or not matches[0]:
|
||||
raise SystemExit(1)
|
||||
print(matches[0])
|
||||
' "$destination"
|
||||
}
|
||||
|
||||
mapfile -t servers < <(container_ids server 2>/dev/null || true)
|
||||
mapfile -t dbs < <(container_ids db 2>/dev/null || true)
|
||||
server_id="${servers[0]:-}"
|
||||
db_id="${dbs[0]:-}"
|
||||
runtime_unique_ok=0
|
||||
if [[ "${#servers[@]}" -eq 1 && "${#dbs[@]}" -eq 1 \
|
||||
&& -n "$server_id" && -n "$db_id" ]]; then
|
||||
runtime_unique_ok=1
|
||||
fi
|
||||
|
||||
runtime_unique() { [[ "$runtime_unique_ok" == 1 ]]; }
|
||||
runtime_build() {
|
||||
[[ "$runtime_unique_ok" == 1 ]] || return 1
|
||||
active_build="$(container_env "$server_id" BUILD_SHA)"
|
||||
[[ "$active_build" =~ ^[0-9a-fA-F]{40,64}$ ]]
|
||||
}
|
||||
storage_mount() {
|
||||
[[ "$runtime_unique_ok" == 1 ]] || return 1
|
||||
storage_destination="$(container_env "$server_id" STORAGE_DIR)"
|
||||
[[ "$storage_destination" == /* ]] || return 1
|
||||
[[ "$(mount_source "$server_id" "$storage_destination")" == "$STORAGE_HOST_DIR" ]]
|
||||
}
|
||||
pgdata_mount() {
|
||||
[[ "$runtime_unique_ok" == 1 ]] || return 1
|
||||
[[ "$(mount_source "$db_id" /var/lib/postgresql/data)" == "$POSTGRES_DATA_DIR" ]]
|
||||
}
|
||||
backup_build() {
|
||||
runtime_build || return 1
|
||||
unit_env="$(systemctl show "$CONTRACT_UNIT" -p Environment --value)"
|
||||
unit_build=""
|
||||
for entry in $unit_env; do
|
||||
if [[ "${entry%%=*}" == "BUILD_SHA" ]]; then
|
||||
unit_build="${entry#*=}"
|
||||
fi
|
||||
done
|
||||
[[ "$unit_build" == "$active_build" ]]
|
||||
}
|
||||
|
||||
emit repository_root repository_root
|
||||
emit repository_writable repository_writable
|
||||
emit env_readable env_readable
|
||||
emit data_directories data_directories
|
||||
emit runtime_unique runtime_unique
|
||||
emit runtime_build runtime_build
|
||||
emit storage_mount storage_mount
|
||||
emit pgdata_mount pgdata_mount
|
||||
emit backup_build backup_build
|
||||
REMOTE
|
||||
)"
|
||||
while IFS='=' read -r result_name result_value; do
|
||||
case "$result_name" in
|
||||
repository_root|repository_writable|env_readable|data_directories|runtime_unique|runtime_build|storage_mount|pgdata_mount|backup_build)
|
||||
[[ "$result_value" == "PASS" || "$result_value" == "FAIL" ]] \
|
||||
&& CHECK_RESULTS["$result_name"]="$result_value"
|
||||
;;
|
||||
esac
|
||||
done <<<"$remote_results"
|
||||
fi
|
||||
|
||||
refresh_output=""
|
||||
refresh_status=255
|
||||
if deployment_contract_safe_ssh_target "$DEPLOY_REFRESH_SERVER"; then
|
||||
refresh_output="$(ssh "$DEPLOY_REFRESH_SERVER" probe 2>/dev/null)"
|
||||
refresh_status=$?
|
||||
fi
|
||||
if [[ "$refresh_status" -ne 255 ]]; then
|
||||
CHECK_RESULTS[root_ssh_identity]="PASS"
|
||||
fi
|
||||
if [[ "$refresh_status" -eq 0 \
|
||||
&& "$refresh_output" == "[deployment-contract-refresh][PASS] probe" ]]; then
|
||||
CHECK_RESULTS[refresh_program]="PASS"
|
||||
fi
|
||||
|
||||
failure_count=0
|
||||
for check_name in "${CHECK_NAMES[@]}"; do
|
||||
printf '[deployment-preflight] %s %s\n' "$check_name" "${CHECK_RESULTS[$check_name]}"
|
||||
if [[ "${CHECK_RESULTS[$check_name]}" != "PASS" ]]; then
|
||||
failure_count=$((failure_count + 1))
|
||||
fi
|
||||
done
|
||||
printf '[deployment-preflight] summary PASS=%s FAIL=%s\n' \
|
||||
"$((${#CHECK_NAMES[@]} - failure_count))" "$failure_count"
|
||||
[[ "$failure_count" -eq 0 ]]
|
||||
@@ -71,7 +71,9 @@
|
||||
- [ ] 本地工作树 clean,目标 commit 已进入 upstream;部署日志中的 commit 与 `/ready.data.buildSha` 一致
|
||||
- [ ] **EXTERNAL_GATED**:操作人已在边缘暂停 `/api/auth/forgot-password`、`/api/auth/reset-password`、`/api/auth/email-login/code`、`/api/auth/email-login/verify`;部署脚本不会自动修改边缘配置,未人工确认四端点关闭不得发布,配置预检、迁移、新应用和 smoke 全部通过后才人工恢复
|
||||
- [ ] 标准路径已成功刷新 upstream;若因受控离线恢复使用 unpublished override,日志包含批准原因码和 `UNPUBLISHED-OVERRIDE` 标识
|
||||
- [ ] 已有部署通过 `DEPLOY_SERVER=<ssh-target> bash deploy/trialrun/deploy-existing.sh` 执行;首次初始化才使用完整显式参数的 `deploy.sh`
|
||||
- [ ] 已有部署通过 `DEPLOY_SERVER=<deploy-ssh-target> DEPLOY_REFRESH_SERVER=<forced-command-ssh-target> bash deploy/trialrun/deploy-existing.sh` 执行;首次初始化才使用完整显式参数的 `deploy.sh`
|
||||
- [ ] forced-command 使用独立专用 key,同一 key 不存在不受限 root 授权;root-owned target 只接受精确 `probe/refresh`,不调用部署用户可写仓库脚本
|
||||
- [ ] 13 项聚合体检在任何 mkdir/rsync 前全部执行并汇总;报告只有固定检查名与 PASS/FAIL,任一失败均阻断同步
|
||||
- [ ] 未从 Compose `working_dir` 猜仓库根目录;同步前 preflight 已确认仓库根标识、普通远端 `.env`、PG_VERSION 与附件目录,且该检查发生在 rsync 之前
|
||||
- [ ] 脚本自动注入完整后端 SHA 与 12 位前端 SHA,并完成构建、init 与 smoke
|
||||
- [ ] 构建日志显示 lockfile 未变化时 client/server 依赖层使用缓存;build SHA 变化没有触发 `apk add` 或两次 `npm ci`
|
||||
@@ -90,13 +92,15 @@
|
||||
- [ ] 若真实 OCR 未配置,已使用 `OCR_PROVIDER=off` 而不是 mock,并确认实际作业拓扑中的 server/worker 已重建;真实票据由财务查看 PDF 原件人工核对
|
||||
- [ ] 若本次包含前端体验修复,已在容器内确认 `/app/client/dist` 含关键 test id 或公网 asset hash 已更新
|
||||
- [ ] 公网 bundle 扫描确认包含匹配的 12 位构建标识,且不包含完整提交 SHA
|
||||
- [ ] wrapper 在应用与公网检查成功后自动刷新四个固定 service 的 build drop-in;四个最终 SHA 与活动容器一致,四个 timer 均 enabled/active
|
||||
- [ ] 若出现 `DEPLOYED_BUT_CONTRACT_REFRESH_FAILED`,已按 root 身份、程序可用性、unit SHA、timer 状态四类处理,并将本次发布标记为“应用已部署但合同未收口”,未误报完整成功
|
||||
- [ ] 登录验证已由 live smoke 与 release gate 覆盖;仅首次接入或第三方后台配置变更时补做人工确认
|
||||
|
||||
## C. 发布后(Post-check)
|
||||
- [ ] 公网 live smoke(只读,不做写操作):`bash scripts/qa/run-web-gates.sh smoke-live`
|
||||
- [ ] 若本地已配置 `LIVE_SMOKE_*`,确认 `bash deploy/trialrun/deploy.sh` 输出中包含 live smoke 结果
|
||||
- [ ] 已确认 release gate / live smoke 结果为通过;关键链路不再作为每次发布的手工必做项
|
||||
- [ ] 若自动备份 units 已安装,已脱敏核对其冻结的 Compose project、build、PGDATA、附件宿主挂载与当前唯一活动 server/db;任一项变化均已用现有安装器按完整合同刷新,三个 timer 仍为 enabled/active,未直接手改 unit
|
||||
- [ ] 已脱敏核对 automatic-backup/WARCH3 四个 service 的冻结 Compose/build/PGDATA/附件合同与当前唯一活动 server/db;build 由 wrapper 的固定 drop-in 自动刷新,路径或完整合同变化才使用现有 installer 人工修复,四个 timer 均 enabled/active
|
||||
- [ ] 查看 server 日志(无异常错误风暴):
|
||||
- [ ] `docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env logs --tail=200 server`
|
||||
- [ ] 已确认 smoke 账号为独立账号(`smoke-admin` / `smoke-employee`),且不参与真实业务
|
||||
|
||||
@@ -3,37 +3,35 @@ set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
DEPLOY_SERVER="${DEPLOY_SERVER:-}"
|
||||
DEPLOY_REFRESH_SERVER="${DEPLOY_REFRESH_SERVER:-}"
|
||||
DEPLOY_CONTRACT_UNIT="${DEPLOY_CONTRACT_UNIT:-hyb-oa-automatic-backup-daily.service}"
|
||||
|
||||
# shellcheck source=deployment-contract-lib.sh
|
||||
source "$ROOT_DIR/deploy/trialrun/deployment-contract-lib.sh"
|
||||
|
||||
fail() {
|
||||
echo "[deploy-existing][ERROR] $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ "$DEPLOY_SERVER" =~ ^([A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$ ]] \
|
||||
deployment_contract_safe_ssh_target "$DEPLOY_SERVER" \
|
||||
|| fail "必须显式提供安全的 DEPLOY_SERVER"
|
||||
[[ "$DEPLOY_CONTRACT_UNIT" =~ ^[A-Za-z0-9_.@-]+\.service$ ]] \
|
||||
deployment_contract_safe_ssh_target "$DEPLOY_REFRESH_SERVER" \
|
||||
|| fail "必须显式提供安全的 DEPLOY_REFRESH_SERVER"
|
||||
deployment_contract_safe_service "$DEPLOY_CONTRACT_UNIT" \
|
||||
|| fail "DEPLOY_CONTRACT_UNIT 格式无效"
|
||||
command -v ssh >/dev/null 2>&1 || fail "缺少 ssh"
|
||||
|
||||
unit_environment="$(ssh "$DEPLOY_SERVER" \
|
||||
"systemctl show '${DEPLOY_CONTRACT_UNIT}' -p Environment --value")"
|
||||
[[ -n "$unit_environment" ]] || fail "无法读取既有部署合同 unit"
|
||||
|
||||
declare -A contract=()
|
||||
for entry in $unit_environment; do
|
||||
key="${entry%%=*}"
|
||||
value="${entry#*=}"
|
||||
case "$key" in
|
||||
REMOTE_DIR|REMOTE_ENV_FILE|COMPOSE_FILE|COMPOSE_PROJECT_NAME|POSTGRES_DATA_DIR|STORAGE_HOST_DIR)
|
||||
contract["$key"]="$value"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
echo "[deploy-existing] 开始只读聚合体检"
|
||||
DEPLOY_SERVER="$DEPLOY_SERVER" \
|
||||
DEPLOY_REFRESH_SERVER="$DEPLOY_REFRESH_SERVER" \
|
||||
DEPLOY_CONTRACT_UNIT="$DEPLOY_CONTRACT_UNIT" \
|
||||
bash "$ROOT_DIR/deploy/trialrun/check-existing-deployment.sh" \
|
||||
|| fail "部署前聚合体检未通过"
|
||||
|
||||
for key in REMOTE_DIR REMOTE_ENV_FILE COMPOSE_FILE COMPOSE_PROJECT_NAME POSTGRES_DATA_DIR STORAGE_HOST_DIR; do
|
||||
[[ -n "${contract[$key]:-}" ]] || fail "既有部署合同缺少 $key"
|
||||
done
|
||||
deployment_contract_load "$DEPLOY_SERVER" "$DEPLOY_CONTRACT_UNIT" contract \
|
||||
|| fail "无法读取完整既有部署合同"
|
||||
|
||||
echo "[deploy-existing] 已读取受控 unit 合同;路径与标识不回显"
|
||||
DEPLOY_SERVER="$DEPLOY_SERVER" \
|
||||
@@ -44,5 +42,24 @@ COMPOSE_PROJECT_NAME="${contract[COMPOSE_PROJECT_NAME]}" \
|
||||
POSTGRES_DATA_DIR="${contract[POSTGRES_DATA_DIR]}" \
|
||||
STORAGE_HOST_DIR="${contract[STORAGE_HOST_DIR]}" \
|
||||
DEPLOY_DATA_MODE=existing \
|
||||
DEPLOY_DEFER_FINAL_SUCCESS=1 \
|
||||
SYNC_ENV=0 \
|
||||
bash "$ROOT_DIR/deploy/trialrun/deploy.sh"
|
||||
|
||||
refresh_output=""
|
||||
refresh_status=0
|
||||
if refresh_output="$(ssh "$DEPLOY_REFRESH_SERVER" refresh 2>/dev/null)"; then
|
||||
if [[ "$refresh_output" != "[deployment-contract-refresh][PASS] refreshed" ]]; then
|
||||
refresh_status=127
|
||||
fi
|
||||
else
|
||||
refresh_status=$?
|
||||
fi
|
||||
|
||||
refresh_category="$(deployment_contract_refresh_category "$refresh_status" "$refresh_output")"
|
||||
if [[ "$refresh_category" != "success" ]]; then
|
||||
echo "[deploy-existing][DEPLOYED_BUT_CONTRACT_REFRESH_FAILED] category=${refresh_category}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[deploy-existing][PASS] 应用发布与备份合同刷新完整通过"
|
||||
|
||||
+10
-1
@@ -15,6 +15,7 @@ DEPLOY_DATA_MODE="${DEPLOY_DATA_MODE:-}"
|
||||
ENABLE_WORKER="${ENABLE_WORKER:-0}"
|
||||
ENABLE_MINIO="${ENABLE_MINIO:-0}"
|
||||
SYNC_ENV="${SYNC_ENV:-0}"
|
||||
DEPLOY_DEFER_FINAL_SUCCESS="${DEPLOY_DEFER_FINAL_SUCCESS:-0}"
|
||||
COMPOSE_FILE="deploy/trialrun/docker-compose.yml"
|
||||
LOCAL_ENV_FILE="deploy/trialrun/.env"
|
||||
ENV_CONTRACT_SCRIPT="deploy/trialrun/validate-env-contract.js"
|
||||
@@ -92,6 +93,10 @@ if [[ "$DEPLOY_DATA_MODE" != "existing" && "$DEPLOY_DATA_MODE" != "initialize" ]
|
||||
echo "[ERROR] DEPLOY_DATA_MODE 必须显式设为 existing 或 initialize" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$DEPLOY_DEFER_FINAL_SUCCESS" != "0" && "$DEPLOY_DEFER_FINAL_SUCCESS" != "1" ]]; then
|
||||
echo "[ERROR] DEPLOY_DEFER_FINAL_SUCCESS 只能为 0 或 1" >&2
|
||||
exit 1
|
||||
fi
|
||||
if paths_overlap "$REMOTE_DIR" "$POSTGRES_DATA_DIR"; then
|
||||
echo "[ERROR] POSTGRES_DATA_DIR 不得与 REMOTE_DIR 同目录或相互嵌套,避免源码同步触碰数据库" >&2
|
||||
exit 1
|
||||
@@ -356,7 +361,11 @@ PUBLIC_EXPECTED_BUILD_SHA="$SOURCE_SHORT_COMMIT" \
|
||||
PUBLIC_FORBIDDEN_BUILD_SHA="$SOURCE_COMMIT" \
|
||||
node "${ROOT_DIR}/scripts/qa/check-public-bundle-copy.mjs" "$PUBLIC_BASE_URL"
|
||||
|
||||
echo ">>> 10. 部署完成"
|
||||
if [[ "$DEPLOY_DEFER_FINAL_SUCCESS" == "1" ]]; then
|
||||
echo ">>> 10. 应用发布检查完成,等待外部备份合同刷新"
|
||||
else
|
||||
echo ">>> 10. 部署完成"
|
||||
fi
|
||||
echo " 来源提交: ${SOURCE_COMMIT}"
|
||||
echo " 已自动执行:契约校验 + /ready + external deps smoke + runtime smoke + 公网前端资源文案扫描"
|
||||
echo " 若本地已配置 LIVE_SMOKE_*,部署后会自动补跑网页 live smoke"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
DEPLOY_CONTRACT_KEYS=(
|
||||
REMOTE_DIR
|
||||
REMOTE_ENV_FILE
|
||||
COMPOSE_FILE
|
||||
COMPOSE_PROJECT_NAME
|
||||
POSTGRES_DATA_DIR
|
||||
STORAGE_HOST_DIR
|
||||
)
|
||||
|
||||
deployment_contract_safe_ssh_target() {
|
||||
[[ "${1:-}" =~ ^([A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$ ]]
|
||||
}
|
||||
|
||||
deployment_contract_safe_service() {
|
||||
[[ "${1:-}" =~ ^[A-Za-z0-9_.@-]+\.service$ ]]
|
||||
}
|
||||
|
||||
deployment_contract_safe_absolute_path() {
|
||||
local value="${1:-}"
|
||||
[[ -n "$value" && "$value" == /* && "$value" =~ ^/[A-Za-z0-9._/-]+$ \
|
||||
&& "$value" != "/" && "$value" != */ && "$value" != *"//"* \
|
||||
&& "/$value/" != *"/../"* && "/$value/" != *"/./"* ]]
|
||||
}
|
||||
|
||||
deployment_contract_safe_compose_file() {
|
||||
local value="${1:-}"
|
||||
[[ -n "$value" && "$value" != /* && "$value" =~ ^[A-Za-z0-9._/-]+$ \
|
||||
&& "/$value/" != *"/../"* && "/$value/" != *"/./"* ]]
|
||||
}
|
||||
|
||||
deployment_contract_safe_project() {
|
||||
[[ "${1:-}" =~ ^[a-z0-9][a-z0-9_-]*$ ]]
|
||||
}
|
||||
|
||||
deployment_contract_load() {
|
||||
local target="$1"
|
||||
local unit="$2"
|
||||
local destination_name="$3"
|
||||
local unit_environment key value
|
||||
local -n destination="$destination_name"
|
||||
|
||||
destination=()
|
||||
deployment_contract_safe_ssh_target "$target" || return 1
|
||||
deployment_contract_safe_service "$unit" || return 1
|
||||
unit_environment="$(ssh "$target" "systemctl show '${unit}' -p Environment --value" 2>/dev/null)" \
|
||||
|| return 1
|
||||
[[ -n "$unit_environment" ]] || return 1
|
||||
|
||||
for entry in $unit_environment; do
|
||||
key="${entry%%=*}"
|
||||
value="${entry#*=}"
|
||||
case "$key" in
|
||||
REMOTE_DIR|REMOTE_ENV_FILE|COMPOSE_FILE|COMPOSE_PROJECT_NAME|POSTGRES_DATA_DIR|STORAGE_HOST_DIR)
|
||||
destination["$key"]="$value"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
for key in "${DEPLOY_CONTRACT_KEYS[@]}"; do
|
||||
[[ -n "${destination[$key]:-}" ]] || return 1
|
||||
done
|
||||
deployment_contract_safe_absolute_path "${destination[REMOTE_DIR]}" || return 1
|
||||
deployment_contract_safe_absolute_path "${destination[REMOTE_ENV_FILE]}" || return 1
|
||||
deployment_contract_safe_absolute_path "${destination[POSTGRES_DATA_DIR]}" || return 1
|
||||
deployment_contract_safe_absolute_path "${destination[STORAGE_HOST_DIR]}" || return 1
|
||||
deployment_contract_safe_compose_file "${destination[COMPOSE_FILE]}" || return 1
|
||||
deployment_contract_safe_project "${destination[COMPOSE_PROJECT_NAME]}" || return 1
|
||||
}
|
||||
|
||||
deployment_contract_refresh_category() {
|
||||
local status="${1:-1}"
|
||||
local output="${2:-}"
|
||||
if [[ "$status" == "0" \
|
||||
&& "$output" == "[deployment-contract-refresh][PASS] refreshed" ]]; then
|
||||
printf 'success'
|
||||
return 0
|
||||
fi
|
||||
case "$status" in
|
||||
255) printf 'root_identity_unavailable' ;;
|
||||
126|127) printf 'refresh_program_unavailable' ;;
|
||||
43) printf 'timer_not_active' ;;
|
||||
*) printf 'unit_build_not_refreshed' ;;
|
||||
esac
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SOURCE="$ROOT_DIR/scripts/refresh-deployment-contract.sh"
|
||||
PUBLIC_KEY_FILE="${DEPLOY_REFRESH_PUBLIC_KEY_FILE:-}"
|
||||
MARKER="# hyb-oa-managed-deployment-contract-refresh"
|
||||
MODE="${1:-install}"
|
||||
INSTALL_TEST_MODE=0
|
||||
ROOT_PREFIX=""
|
||||
|
||||
fail() {
|
||||
echo "[deployment-contract-refresh-installer][ERROR] $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ "$(id -u)" != "0" ]]; then
|
||||
test_root="${HYB_CONTRACT_REFRESH_INSTALL_TEST_ROOT:-}"
|
||||
if [[ "$test_root" =~ ^/tmp/[A-Za-z0-9._/-]+$ && "$test_root" != */ \
|
||||
&& "$test_root" != *"//"* && "/$test_root/" != *"/../"* ]]; then
|
||||
INSTALL_TEST_MODE=1
|
||||
ROOT_PREFIX="$test_root"
|
||||
else
|
||||
fail "root privileges are required"
|
||||
fi
|
||||
fi
|
||||
TARGET="${ROOT_PREFIX}/usr/local/sbin/hyb-oa-deployment-contract-refresh"
|
||||
AUTHORIZED_KEYS="${ROOT_PREFIX}/root/.ssh/authorized_keys"
|
||||
SSH_DIRECTORY="${ROOT_PREFIX}/root/.ssh"
|
||||
[[ -f "$SOURCE" && ! -L "$SOURCE" ]] || fail "reviewed refresh source is missing"
|
||||
|
||||
remove_managed_entry() {
|
||||
python3 - "$AUTHORIZED_KEYS" "$MARKER" "$TARGET" "$INSTALL_TEST_MODE" <<'PY'
|
||||
import os, sys, tempfile
|
||||
path, marker, target, test_mode = sys.argv[1:]
|
||||
if not os.path.exists(path):
|
||||
raise SystemExit(0)
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
lines = handle.read().splitlines()
|
||||
result = []
|
||||
index = 0
|
||||
forced_prefix = f'command="{target}",no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-pty,no-user-rc '
|
||||
while index < len(lines):
|
||||
line = lines[index]
|
||||
if line == marker:
|
||||
if index + 1 >= len(lines) or not lines[index + 1].startswith(forced_prefix):
|
||||
raise SystemExit("managed forced-command marker is malformed")
|
||||
index += 2
|
||||
continue
|
||||
result.append(line)
|
||||
index += 1
|
||||
directory = os.path.dirname(path)
|
||||
fd, temp_path = tempfile.mkstemp(prefix=".authorized_keys.", dir=directory, text=True)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
if result:
|
||||
handle.write("\n".join(result) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temp_path, 0o600)
|
||||
if test_mode != "1":
|
||||
os.chown(temp_path, 0, 0)
|
||||
os.replace(temp_path, path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
PY
|
||||
}
|
||||
|
||||
install_entry() {
|
||||
[[ -n "$PUBLIC_KEY_FILE" && -f "$PUBLIC_KEY_FILE" && ! -L "$PUBLIC_KEY_FILE" ]] \
|
||||
|| fail "DEPLOY_REFRESH_PUBLIC_KEY_FILE must name a regular public-key file"
|
||||
key_line="$(sed -e '/^[[:space:]]*#/d' -e '/^[[:space:]]*$/d' "$PUBLIC_KEY_FILE")"
|
||||
[[ "$(printf '%s\n' "$key_line" | wc -l)" -eq 1 ]] \
|
||||
|| fail "public-key file must contain exactly one key"
|
||||
read -r key_type key_blob key_comment <<<"$key_line"
|
||||
[[ "$key_type" =~ ^(ssh-ed25519|ecdsa-sha2-nistp(256|384|521)|ssh-rsa)$ \
|
||||
&& "$key_blob" =~ ^[A-Za-z0-9+/]+={0,3}$ ]] \
|
||||
|| fail "public key format is invalid"
|
||||
|
||||
if [[ "$INSTALL_TEST_MODE" == "0" ]]; then
|
||||
install -d -o root -g root -m 0700 "$SSH_DIRECTORY"
|
||||
else
|
||||
install -d -m 0700 "$SSH_DIRECTORY"
|
||||
fi
|
||||
[[ ! -L "$SSH_DIRECTORY" && ! -L "$AUTHORIZED_KEYS" ]] \
|
||||
|| fail "root SSH paths must not be symbolic links"
|
||||
touch "$AUTHORIZED_KEYS"
|
||||
if [[ "$INSTALL_TEST_MODE" == "0" ]]; then
|
||||
chown root:root "$AUTHORIZED_KEYS"
|
||||
fi
|
||||
chmod 0600 "$AUTHORIZED_KEYS"
|
||||
|
||||
PUBLIC_KEY_BLOB="$key_blob" python3 - "$AUTHORIZED_KEYS" "$MARKER" "$TARGET" "$key_type" "$key_line" "$INSTALL_TEST_MODE" <<'PY'
|
||||
import os, re, sys, tempfile
|
||||
path, marker, target, key_type, key_line, test_mode = sys.argv[1:]
|
||||
key_blob = os.environ["PUBLIC_KEY_BLOB"]
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
lines = handle.read().splitlines()
|
||||
unmanaged = []
|
||||
kept = []
|
||||
pattern = re.compile(r"(?:^|\s)(?:ssh-ed25519|ecdsa-sha2-nistp(?:256|384|521)|ssh-rsa)\s+([A-Za-z0-9+/=]+)(?:\s|$)")
|
||||
forced_prefix = f'command="{target}",no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-pty,no-user-rc '
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
line = lines[index]
|
||||
if line == marker:
|
||||
if index + 1 >= len(lines) or not lines[index + 1].startswith(forced_prefix):
|
||||
raise SystemExit("managed forced-command marker is malformed")
|
||||
index += 2
|
||||
continue
|
||||
match = pattern.search(line)
|
||||
if match and match.group(1) == key_blob:
|
||||
unmanaged.append(line)
|
||||
kept.append(line)
|
||||
index += 1
|
||||
if unmanaged:
|
||||
raise SystemExit("dedicated key already has unmanaged or unrestricted root authorization")
|
||||
forced = f'command="{target}",no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-pty,no-user-rc {key_line}'
|
||||
kept.extend([marker, forced])
|
||||
directory = os.path.dirname(path)
|
||||
fd, temp_path = tempfile.mkstemp(prefix=".authorized_keys.", dir=directory, text=True)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write("\n".join(kept) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temp_path, 0o600)
|
||||
if test_mode != "1":
|
||||
os.chown(temp_path, 0, 0)
|
||||
os.replace(temp_path, path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
PY
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
install)
|
||||
if [[ "$INSTALL_TEST_MODE" == "0" ]]; then
|
||||
install -d -o root -g root -m 0755 "$(dirname "$TARGET")"
|
||||
install -o root -g root -m 0755 "$SOURCE" "$TARGET"
|
||||
[[ -f "$TARGET" && ! -L "$TARGET" \
|
||||
&& "$(stat -c %u:%g:%a "$TARGET")" == "0:0:755" ]] \
|
||||
|| fail "installed target ownership or mode is invalid"
|
||||
probe_env=()
|
||||
else
|
||||
install -d -m 0755 "$(dirname "$TARGET")"
|
||||
install -m 0755 "$SOURCE" "$TARGET"
|
||||
[[ -f "$TARGET" && ! -L "$TARGET" && "$(stat -c %a "$TARGET")" == "755" ]] \
|
||||
|| fail "installed target mode is invalid"
|
||||
probe_env=(env HYB_CONTRACT_REFRESH_TEST_ROOT="$ROOT_PREFIX")
|
||||
fi
|
||||
"${probe_env[@]}" env SSH_ORIGINAL_COMMAND=probe "$TARGET" >/dev/null \
|
||||
|| fail "installed target probe failed"
|
||||
install_entry
|
||||
echo "[deployment-contract-refresh-installer][PASS] forced command installed"
|
||||
;;
|
||||
uninstall)
|
||||
remove_managed_entry
|
||||
echo "[deployment-contract-refresh-installer][PASS] authorization removed; target retained"
|
||||
;;
|
||||
*)
|
||||
fail "unsupported mode"
|
||||
;;
|
||||
esac
|
||||
@@ -71,7 +71,7 @@ curl -fsS <shadow-ready-url>
|
||||
- 反向代理:`caddy` 正在运行,`oa.hyinda.com` 应反代到 `.env` 中 `APP_HOST_PORT` 对应的宿主回环端口。
|
||||
- 部署目录通过 `REMOTE_DIR` 显式传入。
|
||||
- 远端目录当前不是 git 工作树;发布应优先从本地执行 `bash deploy/trialrun/deploy.sh`,由脚本 `rsync` 到服务器
|
||||
- 已有部署优先执行 `DEPLOY_SERVER=<ssh-target> bash deploy/trialrun/deploy-existing.sh`;包装脚本只从受控 automatic-backup systemd unit 读取 allowlist 参数且不回显路径。不得把 Compose `com.docker.compose.project.working_dir` 当仓库根目录,该标签可能指向 `deploy/trialrun` 配置目录。
|
||||
- 已有部署优先执行 `DEPLOY_SERVER=<deploy-ssh-target> DEPLOY_REFRESH_SERVER=<forced-command-ssh-target> bash deploy/trialrun/deploy-existing.sh`;包装脚本只从受控 automatic-backup systemd unit 读取 allowlist 参数且不回显路径。两个 SSH target 是独立身份:前者按部署用户执行同步/Compose,后者只能执行 root-owned forced-command 的 `probe/refresh`。不得把 Compose `com.docker.compose.project.working_dir` 当仓库根目录,该标签可能指向 `deploy/trialrun` 配置目录。
|
||||
- 远端容器编排当前实际为 `docker-compose 1.29.2`;本 runbook 命令以 `docker-compose` 为准
|
||||
- 所有 Compose 命令必须显式使用同一个 `COMPOSE_PROJECT_NAME` 和 `-p`;不得依赖目录名推导项目身份,也不得写死容器名。
|
||||
- 当前公网 `.env` 已确认:`APP_ORIGIN=https://oa.hyinda.com`、`COOKIE_SECURE=true`、`PUBLIC_SECURITY_HARDENING_ENABLED=true`
|
||||
@@ -268,7 +268,7 @@ manifest 必须至少记录一个真实来源标识:活动 `/ready` 有构建
|
||||
- 生产与 ARM 都直接使用独立企微运维群机器人,不依赖 OA server、数据库或个人工号。Webhook 正文只由用户手工写入两台主机的 root-only `0600` 文件;仓库、普通 `.env`、systemd unit、日志和聊天只能出现受控文件路径,不能出现 URL 或 key。
|
||||
- 失败告警只包含阶段、状态、finding 数和类别建议;原始命令诊断只保留在源端状态目录的 `0600` 最近失败日志。同一失败降噪;首次失败、状态恶化、新 finding、恢复通知和每季度首次恢复提醒才发送。新部署构建与最近恢复点 build SHA 不同只记 finding,缺失、损坏、超龄、未来时间或 Compose project 不同仍硬失败。
|
||||
|
||||
自动备份 unit 会冻结安装时的 Compose project、build、PGDATA 和附件宿主挂载。每次发布、回滚或直接重建容器后,若这些 units 已安装,必须把 unit 中的四项身份与唯一活动 server/db 逐项脱敏核对;任一项变化都要使用现有安装器和完整当前合同刷新 units,再复核三个 timer 与全部运行身份,不能直接手改单个 unit,也不能等次日定时告警后再处理。`phase_stale_check_failed` 是 watchdog 外层阶段,它也可能表示读取成功状态前的运行身份检查失败;应先看安全子类别和 unit/活动容器身份,不能仅凭该阶段认定归档已经超龄或损坏。
|
||||
自动备份 unit 会冻结安装时的 Compose project、build、PGDATA 和附件宿主挂载。已有部署入口在写前聚合核对这些身份,并在应用发布后通过 root-owned forced-command 只刷新 automatic-backup 三个 service 与 WARCH3 service 的 build drop-in,再验证四个 timer;路径、project、PGDATA、附件或完整 unit 合同变化仍必须用原安装器人工刷新,不能扩张 forced-command 的写入面。`phase_stale_check_failed` 是 watchdog 外层阶段,它也可能表示读取成功状态前的运行身份检查失败;应先看安全子类别和 unit/活动容器身份,不能仅凭该阶段认定归档已经超龄或损坏。
|
||||
|
||||
创建机器人后,由用户分别登录两台主机,在 root shell 中用静默输入把完整 Webhook 写入受控路径;不要把值作为命令参数、环境文件内容或聊天消息交给 Codex:
|
||||
|
||||
@@ -424,13 +424,17 @@ systemctl status hyb-oa-public-app-health.service --no-pager
|
||||
|
||||
步骤:
|
||||
1) 先做备份(见 E14 脚本)
|
||||
2) 已有部署从本地仓库执行 `DEPLOY_SERVER=<ssh-target> bash deploy/trialrun/deploy-existing.sh`(推荐);它从受控 systemd unit 读取既有 `REMOTE_DIR`、env、Compose project 与数据目录合同,不从 Docker 标签猜路径,也不回显参数值。首次初始化或 unit 不存在时,才使用完整显式参数执行 `deploy.sh`。
|
||||
2) 已有部署从本地仓库执行 `DEPLOY_SERVER=<deploy-ssh-target> DEPLOY_REFRESH_SERVER=<forced-command-ssh-target> bash deploy/trialrun/deploy-existing.sh`(推荐);它从受控 systemd unit 读取既有 `REMOTE_DIR`、env、Compose project 与数据目录合同,不从 Docker 标签猜路径,也不回显参数值。首次初始化或 unit 不存在时,才使用完整显式参数执行 `deploy.sh`。
|
||||
- 正式使用新版入口前,root 在单独获批窗口执行 `DEPLOY_REFRESH_PUBLIC_KEY_FILE=<dedicated-public-key-file> bash deploy/trialrun/install-deployment-contract-refresh.sh install`。专用 key 不得同时存在不受限 root 授权;installer 会安装 root-owned 自包含 target 和 forced-command 行,但 installer 本身不进入免密授权。
|
||||
- 新版入口先运行 `check-existing-deployment.sh` 的 13 项只读聚合体检。报告只含固定项名与 PASS/FAIL;任何失败都在 mkdir/rsync 前阻断,且前项失败不会跳过后续独立检查。
|
||||
- `DEPLOY_REFRESH_SERVER` 必须是只携带专用 forced-command key 的 SSH alias。不得用 `sudo -n true` 代替该身份检查,也不得复用仍具有不受限 root 登录能力的同一 key。
|
||||
- `deploy.sh` 只同步已验证提交的 Git archive,不依赖远端 `git pull`,也不会把本地 ignored 文件带入发布。
|
||||
- 在任何 rsync 前,远端 preflight 必须确认 existing 目标同时含 `server/package.json` 与 Compose 文件、PGDATA 含 `PG_VERSION`、storage 存在,且 `SYNC_ENV=0` 的 `.env` 是普通文件。失败即停止,不能先同步再验证。
|
||||
- `REMOTE_DIR` 必须与 PostgreSQL、附件宿主目录完全分离;位于同步树内的 `REMOTE_ENV_FILE` 按精确相对路径动态保护。
|
||||
- 在现网目录统一任务中,新的 `COMPOSE_PROJECT_NAME`、`POSTGRES_DATA_DIR`、`STORAGE_HOST_DIR` 必须与活动数据目录复制在同一停机窗口启用;本地提交本身不授权切换。
|
||||
- 旧栈停止命令必须使用冻结阶段由 `docker compose ls` 实测的旧 project;不得拿新 project 名去停止旧容器。
|
||||
- `artifacts/` 和 `output/` 是运行期证据/截图目录,不作为源码发布输入,也不由 `rsync --delete` 清理远端历史证据。
|
||||
- 应用、`/ready`、smoke 和公网检查成功后,wrapper 才调用 forced-command `refresh`,以活动容器 build 原子刷新四个固定 service 的 systemd drop-in并验证四个 timer。失败时固定输出 `DEPLOYED_BUT_CONTRACT_REFRESH_FAILED`、退出非零,并明确表示应用已发布但合同未收口;不自动回滚应用。
|
||||
3) 构建并重启:
|
||||
```bash
|
||||
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
|
||||
@@ -468,7 +472,7 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT##*:}/ready"
|
||||
重建后必须补做:
|
||||
- `smoke-external-deps.js`
|
||||
- `smoke-trialrun-runtime.js`
|
||||
- 若自动备份 units 已安装,核对其 Compose project、build、PGDATA、附件宿主挂载与当前唯一活动 server/db;不一致时用安装器完整刷新并复核三个 timer
|
||||
- 已有部署由 wrapper 自动核对 Compose project、build、PGDATA、附件宿主挂载,并在发布后刷新 automatic-backup 三个 service 与 WARCH3 service 的 build drop-in,复核四个 timer。路径或完整 unit 合同变化仍用原 installer 人工修复,不让 forced-command 扩权处理
|
||||
- 公网页面视觉检查:至少覆盖 `/invoice-pool`、`/reimbursements`、`/mobile/capture` 的 390px 视口
|
||||
|
||||
发布/回滚 Checklist:
|
||||
|
||||
@@ -21,6 +21,22 @@ const deployScriptPath = path.resolve(
|
||||
process.env.ENV_CONTRACT_DEPLOY_SCRIPT_PATH || path.join(rootDir, 'deploy/trialrun/deploy.sh'),
|
||||
);
|
||||
const deployExistingScriptPath = path.join(rootDir, 'deploy/trialrun/deploy-existing.sh');
|
||||
const deploymentContractLibraryPath = path.join(
|
||||
rootDir,
|
||||
'deploy/trialrun/deployment-contract-lib.sh',
|
||||
);
|
||||
const deploymentPreflightScriptPath = path.join(
|
||||
rootDir,
|
||||
'deploy/trialrun/check-existing-deployment.sh',
|
||||
);
|
||||
const deploymentRefreshInstallerPath = path.join(
|
||||
rootDir,
|
||||
'deploy/trialrun/install-deployment-contract-refresh.sh',
|
||||
);
|
||||
const deploymentRefreshHelperPath = path.join(
|
||||
rootDir,
|
||||
'scripts/refresh-deployment-contract.sh',
|
||||
);
|
||||
const remotePreflightScriptPath = path.join(rootDir, 'deploy/trialrun/preflight-remote.sh');
|
||||
const dockerfilePath = path.join(rootDir, 'deploy/trialrun/Dockerfile');
|
||||
const timerScriptPath = path.resolve(
|
||||
@@ -251,6 +267,10 @@ const runbookContent = readFile(runbookPath);
|
||||
const checklistContent = readFile(checklistPath);
|
||||
const deployScriptContent = readFile(deployScriptPath);
|
||||
const deployExistingScriptContent = readFile(deployExistingScriptPath);
|
||||
const deploymentContractLibraryContent = readFile(deploymentContractLibraryPath);
|
||||
const deploymentPreflightScriptContent = readFile(deploymentPreflightScriptPath);
|
||||
const deploymentRefreshInstallerContent = readFile(deploymentRefreshInstallerPath);
|
||||
const deploymentRefreshHelperContent = readFile(deploymentRefreshHelperPath);
|
||||
const remotePreflightScriptContent = readFile(remotePreflightScriptPath);
|
||||
const dockerfileContent = readFile(dockerfilePath);
|
||||
const timerScriptContent = readFile(timerScriptPath);
|
||||
@@ -557,7 +577,12 @@ if (deployScriptContent.indexOf('>>> 3. 同步前只读校验远端部署合同'
|
||||
}
|
||||
|
||||
for (const requiredFragment of [
|
||||
'systemctl show',
|
||||
'DEPLOY_REFRESH_SERVER',
|
||||
'check-existing-deployment.sh',
|
||||
'deployment_contract_load',
|
||||
'DEPLOY_DEFER_FINAL_SUCCESS=1',
|
||||
'ssh "$DEPLOY_REFRESH_SERVER" refresh',
|
||||
'DEPLOYED_BUT_CONTRACT_REFRESH_FAILED',
|
||||
'DEPLOY_DATA_MODE=existing',
|
||||
'SYNC_ENV=0',
|
||||
'路径与标识不回显',
|
||||
@@ -567,6 +592,82 @@ for (const requiredFragment of [
|
||||
}
|
||||
}
|
||||
|
||||
for (const requiredFragment of [
|
||||
'systemctl show',
|
||||
'DEPLOY_CONTRACT_KEYS',
|
||||
'REMOTE_DIR',
|
||||
'REMOTE_ENV_FILE',
|
||||
'COMPOSE_FILE',
|
||||
'COMPOSE_PROJECT_NAME',
|
||||
'POSTGRES_DATA_DIR',
|
||||
'STORAGE_HOST_DIR',
|
||||
'deployment_contract_refresh_category',
|
||||
]) {
|
||||
if (!deploymentContractLibraryContent.includes(requiredFragment)) {
|
||||
errors.push(`deployment-contract-lib.sh 缺少共享部署合同: ${requiredFragment}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const requiredFragment of [
|
||||
'set -uo pipefail',
|
||||
'CHECK_NAMES=(',
|
||||
'repository_writable',
|
||||
'env_readable',
|
||||
'runtime_unique',
|
||||
'storage_mount',
|
||||
'pgdata_mount',
|
||||
'backup_build',
|
||||
'root_ssh_identity',
|
||||
'refresh_program',
|
||||
'summary PASS=',
|
||||
]) {
|
||||
if (!deploymentPreflightScriptContent.includes(requiredFragment)) {
|
||||
errors.push(`check-existing-deployment.sh 缺少聚合体检合同: ${requiredFragment}`);
|
||||
}
|
||||
}
|
||||
if (/^set\s+-[A-Za-z]*e[A-Za-z]*(?:\s|$)/m.test(deploymentPreflightScriptContent)) {
|
||||
errors.push('check-existing-deployment.sh 主流程不得启用 set -e');
|
||||
}
|
||||
|
||||
for (const requiredFragment of [
|
||||
'SSH_ORIGINAL_COMMAND',
|
||||
'probe) probe',
|
||||
'refresh) refresh',
|
||||
'20-active-build.conf',
|
||||
'refresh_program_integrity_invalid',
|
||||
'unit_build_not_refreshed',
|
||||
'timer_not_active',
|
||||
'systemctl daemon-reload',
|
||||
]) {
|
||||
if (!deploymentRefreshHelperContent.includes(requiredFragment)) {
|
||||
errors.push(`refresh-deployment-contract.sh 缺少 forced-command 合同: ${requiredFragment}`);
|
||||
}
|
||||
}
|
||||
for (const forbiddenFragment of ['eval ', 'bash -c', 'REMOTE_DIR/']) {
|
||||
if (deploymentRefreshHelperContent.includes(forbiddenFragment)) {
|
||||
errors.push(`refresh-deployment-contract.sh 包含禁止的动态执行片段: ${forbiddenFragment}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const requiredFragment of [
|
||||
'no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-pty,no-user-rc',
|
||||
'dedicated key already has unmanaged or unrestricted root authorization',
|
||||
'install -o root -g root -m 0755',
|
||||
]) {
|
||||
if (!deploymentRefreshInstallerContent.includes(requiredFragment)) {
|
||||
errors.push(`install-deployment-contract-refresh.sh 缺少受限安装合同: ${requiredFragment}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (deployExistingScriptContent.indexOf('check-existing-deployment.sh')
|
||||
> deployExistingScriptContent.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"')) {
|
||||
errors.push('deploy-existing.sh 必须在应用部署前执行聚合体检');
|
||||
}
|
||||
if (deployExistingScriptContent.indexOf('ssh "$DEPLOY_REFRESH_SERVER" refresh')
|
||||
< deployExistingScriptContent.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"')) {
|
||||
errors.push('deploy-existing.sh 必须在应用部署成功后刷新备份合同');
|
||||
}
|
||||
|
||||
for (const requiredFragment of [
|
||||
'REMOTE_DIR 是现存仓库根目录',
|
||||
'SYNC_ENV=0 要求同步前已有普通远端 .env 文件',
|
||||
|
||||
在新工单中引用
屏蔽一个用户