固化部署体检与备份合同刷新(08-02-deployment-preflight-backup-contract-refresh)

这个提交包含在:
2026-08-02 11:59:42 +08:00
父节点 ea985de975
当前提交 7138d2b85c
共修改 18 个文件,包含 1321 行新增和 34 行删除
+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 ]]
+6 -2
查看文件
@@ -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`),且不参与真实业务
+35 -18
查看文件
@@ -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"
+86
查看文件
@@ -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
}
+166
查看文件
@@ -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
+8 -4
查看文件
@@ -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:
+102 -1
查看文件
@@ -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 文件',