- 用户决定仅用部署前手动轻量快照,不再推进对象存储、ARM/hyinda-20c 备份 - refresh-deployment-contract 新增 /etc/hyb-oa-backup-mode 显式模式: 无文件保持原自动模式;manual_snapshot 要求三个备份 timer 停用且备份 service 已退出,ops timer 仍须运行;内容/属主/符号链接异常 fail-closed - probe 部署前与 refresh 部署后均校验模式与 timer/service 一致性 - 新增 44 backup_mode_invalid / 45 backup_timer_not_disabled 分类及合同测试 - 记录 10-05 ARM 与 hyinda-20c 只读核验结果、远端切换顺序与回滚 - 远端切换未执行,需另行批准 Co-Authored-By: Claude Opus 5.5 <[email protected]>
258 行
8.0 KiB
Bash
可执行文件
258 行
8.0 KiB
Bash
可执行文件
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
PATH="/usr/sbin:/usr/bin:/sbin:/bin"
|
|
export PATH
|
|
umask 077
|
|
unset BASH_ENV ENV CDPATH GLOBIGNORE LD_PRELOAD LD_LIBRARY_PATH PYTHONPATH NODE_OPTIONS
|
|
|
|
SERVICES=(
|
|
hyb-oa-automatic-backup-daily.service
|
|
hyb-oa-automatic-backup-watchdog.service
|
|
hyb-oa-automatic-backup-drill-reminder.service
|
|
hyb-oa-warch3-ops.service
|
|
)
|
|
BACKUP_TIMERS=(
|
|
hyb-oa-automatic-backup-daily.timer
|
|
hyb-oa-automatic-backup-watchdog.timer
|
|
hyb-oa-automatic-backup-drill-reminder.timer
|
|
)
|
|
BACKUP_SERVICES=(
|
|
hyb-oa-automatic-backup-daily.service
|
|
hyb-oa-automatic-backup-watchdog.service
|
|
hyb-oa-automatic-backup-drill-reminder.service
|
|
)
|
|
OPS_TIMERS=(
|
|
hyb-oa-warch3-ops.timer
|
|
)
|
|
CONTRACT_SERVICE="hyb-oa-automatic-backup-daily.service"
|
|
DROPIN_NAME="20-active-build.conf"
|
|
ROOT_PREFIX=""
|
|
TEST_MODE=0
|
|
|
|
fail() {
|
|
local code="$1"
|
|
local category="$2"
|
|
echo "[deployment-contract-refresh][FAIL] category=${category}" >&2
|
|
exit "$code"
|
|
}
|
|
|
|
# Exit 255 is reserved for the SSH client transport layer. Do not use it for
|
|
# helper failures, because callers classify 255 as root_identity_unavailable.
|
|
|
|
if [[ "$(id -u)" != "0" ]]; then
|
|
test_root="${HYB_CONTRACT_REFRESH_TEST_ROOT:-}"
|
|
if [[ "$test_root" =~ ^/tmp/[A-Za-z0-9._/-]+$ && "$test_root" != */ \
|
|
&& "$test_root" != *"//"* && "/$test_root/" != *"/../"* ]]; then
|
|
TEST_MODE=1
|
|
ROOT_PREFIX="$test_root"
|
|
PATH="$test_root/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
|
export PATH
|
|
else
|
|
fail 70 root_required
|
|
fi
|
|
fi
|
|
|
|
UNIT_ROOT="${ROOT_PREFIX}/etc/systemd/system"
|
|
# Absent file = automatic backup mode (all backup timers must run). The only
|
|
# accepted content is "manual_snapshot": backup timers must then be disabled
|
|
# and inactive, because backups are manual Lighthouse snapshots before deploys.
|
|
BACKUP_MODE_FILE="${ROOT_PREFIX}/etc/hyb-oa-backup-mode"
|
|
BACKUP_MODE=""
|
|
LOCK_FILE="${ROOT_PREFIX}/run/lock/hyb-oa-deployment-contract-refresh.lock"
|
|
EXPECTED_TARGET="${ROOT_PREFIX}/usr/local/sbin/hyb-oa-deployment-contract-refresh"
|
|
|
|
need_command() {
|
|
command -v "$1" >/dev/null 2>&1 || fail 70 runtime_prerequisite_missing
|
|
}
|
|
|
|
for required_command in systemctl docker install mktemp mv mkdir chmod flock stat; do
|
|
need_command "$required_command"
|
|
done
|
|
|
|
validate_self() {
|
|
if [[ "$TEST_MODE" == "0" ]]; then
|
|
[[ "$0" == "$EXPECTED_TARGET" && -f "$EXPECTED_TARGET" && ! -L "$EXPECTED_TARGET" \
|
|
&& "$(stat -c %u:%g:%a "$EXPECTED_TARGET")" == "0:0:755" ]] \
|
|
|| fail 70 refresh_program_integrity_invalid
|
|
else
|
|
[[ -f "$0" && ! -L "$0" ]] || fail 70 refresh_program_integrity_invalid
|
|
fi
|
|
}
|
|
|
|
systemctl_value() {
|
|
local unit="$1"
|
|
local property="$2"
|
|
systemctl show "$unit" -p "$property" --value
|
|
}
|
|
|
|
unit_environment_value() {
|
|
local unit="$1"
|
|
local wanted="$2"
|
|
local entry value=""
|
|
for entry in $(systemctl_value "$unit" Environment); do
|
|
if [[ "${entry%%=*}" == "$wanted" ]]; then
|
|
value="${entry#*=}"
|
|
fi
|
|
done
|
|
[[ -n "$value" ]] || return 1
|
|
printf '%s' "$value"
|
|
}
|
|
|
|
validate_fixed_units() {
|
|
local unit fragment owner
|
|
for unit in "${SERVICES[@]}"; do
|
|
[[ "$(systemctl_value "$unit" LoadState)" == "loaded" ]] \
|
|
|| fail 70 service_not_loaded
|
|
fragment="$(systemctl_value "$unit" FragmentPath)"
|
|
[[ -n "$fragment" ]] || fail 70 service_fragment_missing
|
|
if [[ "$TEST_MODE" == "0" ]]; then
|
|
[[ "$fragment" == /etc/systemd/system/* && -f "$fragment" && ! -L "$fragment" ]] \
|
|
|| fail 70 service_fragment_invalid
|
|
owner="$(stat -c %u "$fragment")"
|
|
[[ "$owner" == "0" ]] || fail 70 service_fragment_not_root_owned
|
|
fi
|
|
done
|
|
}
|
|
|
|
resolve_active_build() {
|
|
local project
|
|
local -a servers=()
|
|
project="$(unit_environment_value "$CONTRACT_SERVICE" COMPOSE_PROJECT_NAME)" \
|
|
|| fail 70 compose_project_missing
|
|
[[ "$project" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || fail 70 compose_project_invalid
|
|
mapfile -t servers < <(docker ps \
|
|
--filter "label=com.docker.compose.project=$project" \
|
|
--filter "label=com.docker.compose.service=server" \
|
|
--format '{{.ID}}')
|
|
[[ "${#servers[@]}" -eq 1 && -n "${servers[0]}" ]] \
|
|
|| fail 70 active_server_not_unique
|
|
ACTIVE_BUILD="$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "${servers[0]}" \
|
|
| sed -n 's/^BUILD_SHA=//p' | tail -n 1)"
|
|
ACTIVE_BUILD="${ACTIVE_BUILD,,}"
|
|
[[ "$ACTIVE_BUILD" =~ ^[0-9a-f]{40,64}$ ]] || fail 70 active_build_invalid
|
|
ACTIVE_FRONTEND_BUILD="${ACTIVE_BUILD:0:12}"
|
|
}
|
|
|
|
write_build_dropin() {
|
|
local service="$1"
|
|
local dropin_dir="$UNIT_ROOT/${service}.d"
|
|
local target="$dropin_dir/$DROPIN_NAME"
|
|
local temporary
|
|
|
|
if [[ "$TEST_MODE" == "0" ]]; then
|
|
install -d -o root -g root -m 0755 "$dropin_dir"
|
|
else
|
|
mkdir -p "$dropin_dir"
|
|
chmod 0755 "$dropin_dir"
|
|
fi
|
|
temporary="$(mktemp "$dropin_dir/.${DROPIN_NAME}.XXXXXX")"
|
|
{
|
|
printf '[Service]\n'
|
|
printf 'Environment=BUILD_SHA=%s\n' "$ACTIVE_BUILD"
|
|
printf 'Environment=VITE_BUILD_SHA=%s\n' "$ACTIVE_FRONTEND_BUILD"
|
|
} >"$temporary"
|
|
chmod 0644 "$temporary"
|
|
if [[ "$TEST_MODE" == "0" ]]; then
|
|
chown root:root "$temporary"
|
|
fi
|
|
mv -f -- "$temporary" "$target"
|
|
}
|
|
|
|
verify_builds() {
|
|
local service service_build service_frontend_build
|
|
for service in "${SERVICES[@]}"; do
|
|
service_build="$(unit_environment_value "$service" BUILD_SHA)" \
|
|
|| fail 42 unit_build_not_refreshed
|
|
service_frontend_build="$(unit_environment_value "$service" VITE_BUILD_SHA)" \
|
|
|| fail 42 unit_build_not_refreshed
|
|
[[ "$service_build" == "$ACTIVE_BUILD" \
|
|
&& "$service_frontend_build" == "$ACTIVE_FRONTEND_BUILD" ]] \
|
|
|| fail 42 unit_build_not_refreshed
|
|
done
|
|
}
|
|
|
|
resolve_backup_mode() {
|
|
local content
|
|
if [[ ! -e "$BACKUP_MODE_FILE" && ! -L "$BACKUP_MODE_FILE" ]]; then
|
|
BACKUP_MODE="automatic"
|
|
return 0
|
|
fi
|
|
[[ -f "$BACKUP_MODE_FILE" && ! -L "$BACKUP_MODE_FILE" ]] \
|
|
|| fail 44 backup_mode_invalid
|
|
if [[ "$TEST_MODE" == "0" ]]; then
|
|
[[ "$(stat -c %u:%g "$BACKUP_MODE_FILE")" == "0:0" ]] \
|
|
&& [[ "$(stat -c %a "$BACKUP_MODE_FILE")" =~ ^(644|600|444|400)$ ]] \
|
|
|| fail 44 backup_mode_invalid
|
|
fi
|
|
content="$(<"$BACKUP_MODE_FILE")"
|
|
[[ "$content" == "manual_snapshot" ]] || fail 44 backup_mode_invalid
|
|
BACKUP_MODE="manual_snapshot"
|
|
}
|
|
|
|
timer_state() {
|
|
local timer="$1"
|
|
printf '%s:%s' \
|
|
"$(systemctl is-enabled "$timer" 2>/dev/null || true)" \
|
|
"$(systemctl is-active "$timer" 2>/dev/null || true)"
|
|
}
|
|
|
|
verify_timers() {
|
|
local timer
|
|
for timer in "${OPS_TIMERS[@]}"; do
|
|
[[ "$(timer_state "$timer")" == "enabled:active" ]] || fail 43 timer_not_active
|
|
done
|
|
for timer in "${BACKUP_TIMERS[@]}"; do
|
|
if [[ "$BACKUP_MODE" == "manual_snapshot" ]]; then
|
|
[[ "$(timer_state "$timer")" == "disabled:inactive" ]] \
|
|
|| fail 45 backup_timer_not_disabled
|
|
else
|
|
[[ "$(timer_state "$timer")" == "enabled:active" ]] || fail 43 timer_not_active
|
|
fi
|
|
done
|
|
[[ "$BACKUP_MODE" == "manual_snapshot" ]] || return 0
|
|
# A disabled timer does not stop an in-flight run; the switch is complete
|
|
# only after every backup service has exited (a failed last run still counts).
|
|
local service state
|
|
for service in "${BACKUP_SERVICES[@]}"; do
|
|
state="$(systemctl is-active "$service" 2>/dev/null || true)"
|
|
[[ "$state" == "inactive" || "$state" == "failed" ]] \
|
|
|| fail 45 backup_timer_not_disabled
|
|
done
|
|
}
|
|
|
|
probe() {
|
|
validate_self
|
|
validate_fixed_units
|
|
resolve_backup_mode
|
|
resolve_active_build
|
|
# Fail before any release write when timers disagree with the backup mode.
|
|
verify_timers
|
|
echo "[deployment-contract-refresh][PASS] probe"
|
|
}
|
|
|
|
refresh() {
|
|
validate_self
|
|
validate_fixed_units
|
|
resolve_backup_mode
|
|
resolve_active_build
|
|
mkdir -p "$(dirname "$LOCK_FILE")"
|
|
exec 9>"$LOCK_FILE"
|
|
flock -n 9 || fail 42 refresh_already_running
|
|
local service
|
|
for service in "${SERVICES[@]}"; do
|
|
write_build_dropin "$service"
|
|
done
|
|
systemctl daemon-reload
|
|
verify_builds
|
|
verify_timers
|
|
echo "[deployment-contract-refresh][PASS] refreshed"
|
|
}
|
|
|
|
[[ "$#" -eq 0 ]] || fail 64 unsupported_forced_command
|
|
case "${SSH_ORIGINAL_COMMAND:-}" in
|
|
probe) probe ;;
|
|
refresh) refresh ;;
|
|
*) fail 64 unsupported_forced_command ;;
|
|
esac
|