Files
kaidi/deploy/update.sh
T
2026-08-19 08:27:08 +08:00

1282 lines
58 KiB
Bash
Executable File

#!/bin/sh
set -eu
umask 077
APP_ROOT=${KAIDI_APP_ROOT:-/opt/kaidi}
STATE_ROOT=${KAIDI_UPDATE_STATE_ROOT:-/var/lib/kaidi-update}
REQUEST_FILE=${UPDATE_REQUEST_FILE:-$STATE_ROOT/inbox/request.json}
PROCESSING_DIR=${KAIDI_UPDATE_PROCESSING_DIR:-$STATE_ROOT/processing}
PROCESSING_FILE=$PROCESSING_DIR/request.json
FAILED_REQUEST_ROOT=${KAIDI_UPDATE_FAILED_ROOT:-$STATE_ROOT/failed}
TRANSACTION_ROOT=${KAIDI_UPDATE_TRANSACTION_ROOT:-$STATE_ROOT/transactions}
ACTIVE_TRANSACTION=$TRANSACTION_ROOT/active
STATUS_FILE=${UPDATE_STATUS_FILE:-$STATE_ROOT/status.json}
EVENT_LOG_FILE=${UPDATE_EVENT_LOG_FILE:-$STATE_ROOT/events.jsonl}
PUBLIC_KEY=${UPDATE_PUBLIC_KEY:-/etc/kaidi/release-public.pem}
RELEASE_BASE_URL=${UPDATE_RELEASE_BASE_URL:-}
RELEASE_API_URL=${UPDATE_RELEASE_API_URL:-}
RELEASE_TOKEN=${UPDATE_RELEASE_TOKEN:-}
CACHE_ROOT=${KAIDI_UPDATE_CACHE_ROOT:-$STATE_ROOT/cache}
SERVICE_NAME=${KAIDI_SERVICE_NAME:-kaidi-finance.service}
JAVA_BIN=${KAIDI_JAVA_BIN:-$APP_ROOT/runtime/java/bin/java}
UPDATE_PATH_NAME=${KAIDI_UPDATE_PATH_NAME:-kaidi-update.path}
PROCESS_MANAGER=${KAIDI_PROCESS_MANAGER:-systemd}
PID_FILE=${KAIDI_PID_FILE:-/var/lib/kaidi/kaidi.pid}
RUNTIME_ENV_FILE=${KAIDI_RUNTIME_ENV_FILE:-/var/lib/kaidi/setup/application.env}
SERVICE_USER=${KAIDI_SERVICE_USER:-kaidi}
SERVICE_GROUP=${KAIDI_SERVICE_GROUP:-kaidi}
HEALTH_URL=${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health}
APP_INDEX_URL=${KAIDI_APP_INDEX_URL:-http://127.0.0.1:18080/}
LOCK_FILE=$STATE_ROOT/update.lock
BACKUP_ROOT=${KAIDI_BACKUP_ROOT:-$STATE_ROOT/backups}
DB_BACKUP_MODE=${KAIDI_DB_BACKUP_MODE:-skip}
UPDATER_PATH=${KAIDI_UPDATER_PATH:-$APP_ROOT/bin/update.sh}
SYSTEMD_ROOT=${KAIDI_SYSTEMD_ROOT:-/etc/systemd/system}
LOG_ROOT=${KAIDI_LOG_ROOT:-/var/log/kaidi}
HEALTH_ATTEMPTS=${KAIDI_UPDATE_HEALTH_ATTEMPTS:-60}
HEALTH_INTERVAL_SECONDS=${KAIDI_UPDATE_HEALTH_INTERVAL_SECONDS:-2}
SHUTDOWN_ATTEMPTS=${KAIDI_UPDATE_SHUTDOWN_ATTEMPTS:-45}
WORK_DIR=
CACHE_TEMP=
RELEASE_AUTH_HEADER_FILE=
TARGET_VERSION=
REQUEST_ID=
REQUEST_ACTION=
RELEASE_NOTES=
RELEASE_PUBLISHED_AT=
TERMINAL_STATUS_WRITTEN=false
DOWNLOAD_PID=
DOWNLOAD_PGID=
LAST_DOWNLOAD_SPEED=0
SURFACE_FAILURE=
LOG_LOCALE=${KAIDI_LOG_LOCALE:-zh-CN}
localize_message() {
message=$1
[ "$LOG_LOCALE" = en ] && { printf '%s' "$message"; return; }
# A rollback failure must keep the manual-recovery signal visible even when
# the original reason also contains a more specific error prefix.
case "$message" in
*"rollback is incomplete and manual recovery is required"*)
printf '新版本应用验证失败,回滚未完成,需要人工恢复'
return
;;
esac
case "$message" in
"Update health-check settings must be non-negative integers") printf '更新健康检查参数必须是非负整数' ;;
"Update health-check attempts must be at least 1") printf '更新健康检查次数至少为 1' ;;
"KAIDI_PROCESS_MANAGER must be systemd or baota") printf 'KAIDI_PROCESS_MANAGER 只能是 systemd 或 baota' ;;
"KAIDI_UPDATE_SHUTDOWN_ATTEMPTS must be a positive integer") printf 'KAIDI_UPDATE_SHUTDOWN_ATTEMPTS 必须是正整数' ;;
"KAIDI_UPDATE_SHUTDOWN_ATTEMPTS must be at least 1") printf 'KAIDI_UPDATE_SHUTDOWN_ATTEMPTS 至少为 1' ;;
"KAIDI_PID_FILE must be an absolute path") printf 'KAIDI_PID_FILE 必须是绝对路径' ;;
"KAIDI_RUNTIME_ENV_FILE must be an absolute path") printf 'KAIDI_RUNTIME_ENV_FILE 必须是绝对路径' ;;
"KAIDI_DB_BACKUP_MODE must be skip or mysqldump") printf 'KAIDI_DB_BACKUP_MODE 只能是 skip 或 mysqldump' ;;
"Setup runtime environment must be a regular file") printf '安装向导运行时配置必须是普通文件' ;;
"Setup runtime environment is not readable") printf '安装向导运行时配置不可读' ;;
"Setup runtime environment is too large") printf '安装向导运行时配置过大' ;;
"Setup runtime environment has an unexpected owner") printf '安装向导运行时配置属主不正确' ;;
"Setup runtime environment must not be writable"*) printf '安装向导运行时配置不能被组或其他用户写入' ;;
"Setup runtime environment contains an invalid line") printf '安装向导运行时配置包含无效行' ;;
"Setup runtime environment "*) printf '安装向导运行时配置错误,请检查 application.env' ;;
"Setup runtime database port is invalid") printf '安装向导数据库端口无效' ;;
"Database configuration is invalid: DB_URL is missing"*) printf '数据库配置无效:缺少 DB_URL,未重启应用' ;;
"Database configuration is invalid: database host and name are missing"*) printf '数据库配置无效:缺少数据库主机或库名,未重启应用' ;;
"Database configuration is invalid: DB_URL does not match"*) printf '数据库配置无效:DB_URL 与主机、端口、库名不一致,未重启应用' ;;
"UPDATE_RELEASE_TOKEN is invalid") printf 'UPDATE_RELEASE_TOKEN 无效' ;;
"UPDATE_RELEASE_BASE_URL or UPDATE_RELEASE_API_URL is not configured") printf '未配置更新源地址' ;;
"Service user "*" is missing") printf '缺少服务用户:%s' "$(printf '%s' "${message#Service user }" | sed 's/ is missing$//')" ;;
"Service user "*" is not a member"*) printf '服务用户不属于指定服务组' ;;
"runuser is required") printf '需要 runuser' ;;
"setsid is required") printf '需要 setsid' ;;
"Managed application or update directories could not be prepared") printf '应用或更新目录准备失败' ;;
"Writable update directories could not be prepared") printf '可写更新目录准备失败' ;;
"Private update directories could not be prepared") printf '私有更新目录准备失败' ;;
"Private update queue directories could not be prepared") printf '私有更新队列目录准备失败' ;;
"Downloading signed release "*) printf '正在下载已签名版本:%s' "${message#Downloading signed release }" ;;
"Release "*" download completed") printf '版本下载完成:%s' "${message#Release }" ;;
"Validating signed release "*) printf '正在校验签名版本:%s' "${message#Validating signed release }" ;;
"Revalidating cached release "*) printf '正在重新校验已缓存版本:%s' "${message#Revalidating cached release }" ;;
"Release "*" is downloaded and verified; confirm installation") printf '版本已下载并校验,请确认安装:%s' "${message#Release }" ;;
"Release "*" is running") printf '版本运行中:%s' "${message#Release }" ;;
*"automatic update watcher could not be started"*) printf '自动更新监听器未能启动' ;;
"Version "*" is already installed") printf '版本已安装:%s' "${message#Version }" ;;
"Starting and verifying release "*) printf '正在启动并验证版本:%s' "${message#Starting and verifying release }" ;;
"Validating current release before switching to "*) printf '切换前正在验证当前版本(目标:%s)' "${message#Validating current release before switching to }" ;;
"Backing up database before installing "*) printf '安装前正在备份数据库(目标:%s)' "${message#Backing up database before installing }" ;;
"Database backup skipped; updater does not access MySQL") printf '已跳过数据库备份;更新器不会访问 MySQL' ;;
"Installing release "*) printf '正在安装版本:%s' "${message#Installing release }" ;;
"Update process was interrupted"*) printf '更新进程被中断,系统将执行自动恢复' ;;
"Update transaction evidence could not be quarantined"*) printf '更新事务证据无法隔离,已停止自动更新' ;;
"Update request could not be archived"*) printf '更新请求无法归档,已停止自动更新' ;;
"Update service is locked for manual recovery"*) printf '更新服务已锁定,需要人工恢复;新请求已拒绝' ;;
"Processing update request must be a regular file") printf '处理中更新请求必须是普通文件' ;;
"Update request must be a regular file") printf '更新请求必须是普通文件' ;;
"Claimed update request must be a regular file") printf '已领取的更新请求必须是普通文件' ;;
"Verified release cache is missing") printf '缺少已校验的发布缓存' ;;
"Cached release manifest is missing") printf '缺少缓存的发布清单' ;;
"Cached release signature is missing") printf '缺少缓存的发布签名' ;;
"Cached release artifact is missing") printf '缺少缓存的发布包' ;;
"Update request version is invalid") printf '更新请求版本无效' ;;
"Update request action is invalid") printf '更新请求动作无效' ;;
"Release manifest "*" is missing") printf '缺少发布清单资产' ;;
"Release manifest download failed") printf '发布清单下载失败' ;;
"Release manifest signature download failed") printf '发布清单签名下载失败' ;;
"Gitea latest Release lookup failed") printf 'Gitea 最新 Release 查询失败' ;;
"Release manifest signature verification failed") printf '发布清单签名校验失败' ;;
"Requested version is no longer the latest signed release") printf '请求版本已不是最新签名版本,请重新获取版本' ;;
"Release artifact download failed") printf '发布包下载失败' ;;
"Release artifact asset is missing") printf '缺少发布包资产' ;;
"Release artifact SHA-256 verification failed") printf '发布包 SHA-256 校验失败' ;;
"Release artifact size verification failed") printf '发布包大小校验失败' ;;
"Release archive contains an unsafe path") printf '发布归档包含不安全路径,已拒绝' ;;
"Release archive must not contain symbolic links") printf '发布归档不能包含符号链接,已拒绝' ;;
"Release application restart failed") printf '新版本应用重启失败' ;;
"Release application verification failed"*) printf '新版本应用验证失败:%s' "${message#Release application verification failed }" ;;
"Current release preflight failed"*) printf '当前版本预检查失败,未重启应用:%s' "${message#Current release preflight failed }" ;;
"Current release path is invalid"*) printf '当前版本路径无效,未重启应用' ;;
"Service user cannot access the release or selected Java runtime") printf '服务用户无法访问发布目录或 Java 运行时' ;;
"Release operations files could not be backed up") printf '旧版本运维文件备份失败' ;;
"Existing operations files could not be backed up") printf '已有运维文件备份失败' ;;
"Release directory is already active") printf '目标版本目录已处于 active 状态' ;;
"Failed release staging directory could not be cleaned") printf '失败版本暂存目录清理失败' ;;
"Release directory could not be activated") printf '目标版本目录无法启用' ;;
"Release ownership or permissions could not be secured") printf '目标版本目录权限保护失败' ;;
"Release operations validation failed") printf '发布运维文件校验失败' ;;
"Release public key is missing") printf '发布公钥缺失' ;;
"Release verification public key is missing") printf '缺少发布校验公钥' ;;
"Release manifest version is invalid") printf '发布清单版本无效' ;;
"Release artifact name is invalid") printf '发布包文件名无效' ;;
"Release SHA-256 is invalid") printf '发布包 SHA-256 无效' ;;
"Release artifact size is invalid") printf '发布包大小无效' ;;
"Release archive could not be listed") printf '发布归档无法读取' ;;
"Release archive could not be extracted") printf '发布归档无法解压' ;;
"Release app.jar is missing") printf '发布包缺少 app.jar' ;;
"Release frontend is missing") printf '发布包缺少前端入口' ;;
"Release version file mismatch") printf '发布包 VERSION 与目标版本不一致' ;;
"Release updater is missing") printf '发布包缺少更新脚本' ;;
"Release Baota launcher is missing") printf '发布包缺少宝塔启动脚本' ;;
"Release Baota initializer is missing") printf '发布包缺少宝塔初始化脚本' ;;
"Release application unit is missing") printf '发布包缺少应用 systemd 单元' ;;
"Release updater unit is missing") printf '发布包缺少更新 systemd 单元' ;;
"Release updater path unit is missing") printf '发布包缺少更新监听单元' ;;
"Verified release cache path is unsafe") printf '已校验发布缓存路径不安全' ;;
"Verified release cache could not be activated") printf '已校验发布缓存无法启用' ;;
"Database backup failed") printf '数据库备份失败' ;;
"Database backup is empty") printf '数据库备份为空' ;;
"Database backup compression failed") printf '数据库备份压缩失败' ;;
"mysqldump is required"*) printf '更新前需要 mysqldump;请配置 KAIDI_MYSQLDUMP_BIN 或安装客户端' ;;
"Release operations files could not be activated") printf '发布运维文件无法切换' ;;
"Release application link could not be activated") printf '应用 current 链接无法切换' ;;
*"previous application release was restored and verified; database backup was retained"*) printf '已恢复并验证旧版本;数据库备份已保留' ;;
*"previous application release was restored and verified; no database backup was created"*) printf '已恢复并验证旧版本;本次未创建数据库备份' ;;
*"previous application release is running"*) printf '旧版本正在运行,但运维文件需要人工检查' ;;
*) printf '%s' "$message" ;;
esac
}
case "$HEALTH_ATTEMPTS:$HEALTH_INTERVAL_SECONDS" in
*[!0-9:]* | :* | *:) printf '%s\n' "$(localize_message 'Update health-check settings must be non-negative integers')" >&2; exit 1 ;;
esac
[ "$HEALTH_ATTEMPTS" -ge 1 ] || { printf '%s\n' "$(localize_message 'Update health-check attempts must be at least 1')" >&2; exit 1; }
case "$PROCESS_MANAGER" in
systemd|baota) ;;
*) printf '%s\n' "$(localize_message 'KAIDI_PROCESS_MANAGER must be systemd or baota')" >&2; exit 1 ;;
esac
case "$SHUTDOWN_ATTEMPTS" in
''|*[!0-9]*) printf '%s\n' "$(localize_message 'KAIDI_UPDATE_SHUTDOWN_ATTEMPTS must be a positive integer')" >&2; exit 1 ;;
esac
[ "$SHUTDOWN_ATTEMPTS" -ge 1 ] || { printf '%s\n' "$(localize_message 'KAIDI_UPDATE_SHUTDOWN_ATTEMPTS must be at least 1')" >&2; exit 1; }
case "$PID_FILE" in
/*) ;;
*) printf '%s\n' "$(localize_message 'KAIDI_PID_FILE must be an absolute path')" >&2; exit 1 ;;
esac
case "$RUNTIME_ENV_FILE" in
/*) ;;
*) printf '%s\n' "$(localize_message 'KAIDI_RUNTIME_ENV_FILE must be an absolute path')" >&2; exit 1 ;;
esac
case "${KAIDI_SKIP_DB_BACKUP:-}" in
true) DB_BACKUP_MODE=skip ;;
false)
[ -n "${KAIDI_DB_BACKUP_MODE:-}" ] || DB_BACKUP_MODE=mysqldump
;;
esac
case "$DB_BACKUP_MODE" in
skip|mysqldump) ;;
*) printf '%s\n' "$(localize_message 'KAIDI_DB_BACKUP_MODE must be skip or mysqldump')" >&2; exit 1 ;;
esac
bootstrap_die() {
if [ -f "$PROCESSING_FILE" ] && [ ! -L "$PROCESSING_FILE" ]; then
fail "$1"
fi
printf '%s\n' "$(localize_message "$1")" >&2
exit 1
}
decode_runtime_env_value() {
encoded_value=$1
case "$encoded_value" in
\"*\")
encoded_value=${encoded_value#\"}
encoded_value=${encoded_value%\"}
printf '%s' "$encoded_value" | sed 's/\\\"/"/g; s/\\\\/\\/g'
;;
\'*\')
encoded_value=${encoded_value#\'}
encoded_value=${encoded_value%\'}
printf '%s' "$encoded_value"
;;
*) printf '%s' "$encoded_value" ;;
esac
}
load_runtime_database_env() {
[ -e "$RUNTIME_ENV_FILE" ] || return 0
[ -f "$RUNTIME_ENV_FILE" ] && [ ! -L "$RUNTIME_ENV_FILE" ] \
|| bootstrap_die "Setup runtime environment must be a regular file"
[ -r "$RUNTIME_ENV_FILE" ] \
|| bootstrap_die "Setup runtime environment is not readable"
runtime_size=$(wc -c < "$RUNTIME_ENV_FILE" | tr -d '[:space:]')
[ "$runtime_size" -le 65536 ] \
|| bootstrap_die "Setup runtime environment is too large"
runtime_owner=$(stat -c '%u' "$RUNTIME_ENV_FILE" 2>/dev/null \
|| stat -f '%u' "$RUNTIME_ENV_FILE" 2>/dev/null || true)
service_uid=$(id -u "$SERVICE_USER" 2>/dev/null || true)
[ "$runtime_owner" = 0 ] || [ "$runtime_owner" = "$service_uid" ] \
|| bootstrap_die "Setup runtime environment has an unexpected owner"
if find "$RUNTIME_ENV_FILE" -prune -perm /022 -print -quit | grep -q .; then
bootstrap_die "Setup runtime environment must not be writable by group or other users"
fi
while IFS= read -r runtime_line || [ -n "$runtime_line" ]; do
case "$runtime_line" in ''|'#'*) continue ;; esac
runtime_name=${runtime_line%%=*}
[ "$runtime_name" != "$runtime_line" ] \
|| bootstrap_die "Setup runtime environment contains an invalid line"
runtime_raw=${runtime_line#*=}
runtime_value=$(decode_runtime_env_value "$runtime_raw")
case "$runtime_name" in
KAIDI_DB_HOST) KAIDI_DB_HOST=$runtime_value; export KAIDI_DB_HOST ;;
KAIDI_DB_PORT) KAIDI_DB_PORT=$runtime_value; export KAIDI_DB_PORT ;;
KAIDI_DB_NAME) KAIDI_DB_NAME=$runtime_value; export KAIDI_DB_NAME ;;
KAIDI_DB_USERNAME) KAIDI_DB_USERNAME=$runtime_value; export KAIDI_DB_USERNAME ;;
KAIDI_DB_PASSWORD) KAIDI_DB_PASSWORD=$runtime_value; export KAIDI_DB_PASSWORD ;;
DB_URL) KAIDI_DB_URL=$runtime_value; export KAIDI_DB_URL ;;
esac
done < "$RUNTIME_ENV_FILE"
case "${KAIDI_DB_PORT:-}" in
''|*[!0-9]*) bootstrap_die "Setup runtime database port is invalid" ;;
esac
[ "$KAIDI_DB_PORT" -ge 1 ] && [ "$KAIDI_DB_PORT" -le 65535 ] \
|| bootstrap_die "Setup runtime database port is invalid"
}
validate_runtime_database_config() {
[ -e "$RUNTIME_ENV_FILE" ] || return 0
[ -n "${KAIDI_DB_URL:-}" ] \
|| fail "Database configuration is invalid: DB_URL is missing; application was not restarted"
[ -n "${KAIDI_DB_HOST:-}" ] && [ -n "${KAIDI_DB_NAME:-}" ] \
|| fail "Database configuration is invalid: database host and name are missing; application was not restarted"
expected_url_prefix="jdbc:mysql://${KAIDI_DB_HOST}:${KAIDI_DB_PORT}/${KAIDI_DB_NAME}"
case "$KAIDI_DB_URL" in
"$expected_url_prefix"|"$expected_url_prefix"\?*) ;;
*) fail "Database configuration is invalid: DB_URL does not match the configured MySQL host, port, and database; application was not restarted" ;;
esac
}
status() {
state=$1
message=$(localize_message "$2")
version=${3:-}
downloaded_bytes=${4:-}
total_bytes=${5:-}
bytes_per_second=${6:-}
download_percent=${7:-}
restart_expected_seconds=${8:-}
previous_state=
previous_message=
previous_request_id=
previous_action=
previous_release_notes=
previous_published_at=
if [ -f "$STATUS_FILE" ] && [ ! -L "$STATUS_FILE" ]; then
previous_state=$(jq -r '.state // empty' "$STATUS_FILE" 2>/dev/null || true)
previous_message=$(jq -r '.message // empty' "$STATUS_FILE" 2>/dev/null || true)
previous_request_id=$(jq -r '.requestId // empty | strings | select(length <= 64)' \
"$STATUS_FILE" 2>/dev/null || true)
previous_action=$(jq -r '.action // empty | strings | ascii_upcase \
| select(. == "DOWNLOAD" or . == "INSTALL")' "$STATUS_FILE" 2>/dev/null || true)
previous_release_notes=$(jq -r '(.releaseNotes // "") | strings | .[0:4000]' \
"$STATUS_FILE" 2>/dev/null || true)
previous_published_at=$(jq -r '(.publishedAt // "") | strings | .[0:128]' \
"$STATUS_FILE" 2>/dev/null || true)
fi
status_request_id=
status_action=
if [ -f "$PROCESSING_FILE" ] && [ ! -L "$PROCESSING_FILE" ]; then
status_request_id=$(jq -r '.requestId // empty | strings | select(length > 0 and length <= 64)' \
"$PROCESSING_FILE" 2>/dev/null || true)
status_action=$(jq -r '.action // empty | strings | ascii_upcase \
| select(. == "DOWNLOAD" or . == "INSTALL")' "$PROCESSING_FILE" 2>/dev/null || true)
fi
[ -n "$status_request_id" ] || status_request_id=${REQUEST_ID:-$previous_request_id}
[ -n "$status_action" ] || status_action=${REQUEST_ACTION:-$previous_action}
status_release_notes=${RELEASE_NOTES:-$previous_release_notes}
status_published_at=${RELEASE_PUBLISHED_AT:-$previous_published_at}
tmp="$STATUS_FILE.tmp.$$"
jq -n \
--arg state "$state" \
--arg message "$message" \
--arg version "$version" \
--arg downloadedBytes "$downloaded_bytes" \
--arg totalBytes "$total_bytes" \
--arg bytesPerSecond "$bytes_per_second" \
--arg downloadPercent "$download_percent" \
--arg restartExpectedSeconds "$restart_expected_seconds" \
--arg requestId "$status_request_id" \
--arg action "$status_action" \
--arg releaseNotes "$status_release_notes" \
--arg publishedAt "$status_published_at" \
--arg updatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{state:$state,message:$message,updatedAt:$updatedAt}
+ (if ($version | length) > 0 then {targetVersion:$version} else {} end)
+ (if ($requestId | length) > 0 then {requestId:$requestId} else {} end)
+ (if ($action == "DOWNLOAD" or $action == "INSTALL") then {action:$action} else {} end)
+ (if ($releaseNotes | length) > 0 then {releaseNotes:$releaseNotes} else {} end)
+ (if ($publishedAt | length) > 0 then {publishedAt:$publishedAt} else {} end)
+ (if ($downloadedBytes | test("^[0-9]+$")) then {downloadedBytes:($downloadedBytes | tonumber)} else {} end)
+ (if ($totalBytes | test("^[0-9]+$")) then {totalBytes:($totalBytes | tonumber)} else {} end)
+ (if ($bytesPerSecond | test("^[0-9]+$")) then {bytesPerSecond:($bytesPerSecond | tonumber)} else {} end)
+ (if ($downloadPercent | test("^[0-9]+$")) then {downloadPercent:($downloadPercent | tonumber)} else {} end)
+ (if ($restartExpectedSeconds | test("^[0-9]+$"))
then {restartExpectedSeconds:($restartExpectedSeconds | tonumber)} else {} end)' > "$tmp"
chmod 0644 "$tmp"
mv -f "$tmp" "$STATUS_FILE"
if [ "$state" != "$previous_state" ] || [ "$message" != "$previous_message" ]; then
case "$state" in
FAILED|RECOVERY_REQUIRED) event ERROR "$state" "$message" || true ;;
*) event INFO "$state" "$message" || true ;;
esac
fi
}
event() {
level=$1
stage=$2
message=$3
event_tmp="$EVENT_LOG_FILE.tmp.$$"
jq -cn --arg occurredAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg level "$level" --arg stage "$stage" --arg message "$message" \
'{occurredAt:$occurredAt,level:$level,stage:$stage,message:$message}' >> "$EVENT_LOG_FILE"
chmod 0644 "$EVENT_LOG_FILE"
event_size=$(wc -c < "$EVENT_LOG_FILE" | tr -d '[:space:]')
if [ "$event_size" -gt 262144 ]; then
tail -n 160 "$EVENT_LOG_FILE" > "$event_tmp"
chmod 0644 "$event_tmp"
mv -f "$event_tmp" "$EVENT_LOG_FILE"
fi
}
reset_event_log() {
rm -f "$EVENT_LOG_FILE"
touch "$EVENT_LOG_FILE"
chmod 0644 "$EVENT_LOG_FILE"
}
archive_processing_request() {
[ -f "$PROCESSING_FILE" ] || return 0
archived="$FAILED_REQUEST_ROOT/request-$(date -u +%Y%m%dT%H%M%SZ)-$$.json"
mv "$PROCESSING_FILE" "$archived"
chmod 0600 "$archived"
}
complete_request() {
rm -f "$PROCESSING_FILE"
}
fail() {
TERMINAL_STATUS_WRITTEN=true
message=$(localize_message "$1")
status FAILED "$message" "${TARGET_VERSION:-}"
archive_processing_request || true
printf '%s\n' "$message" >&2
exit 1
}
prepare_update_layout() {
id "$SERVICE_USER" >/dev/null 2>&1 || bootstrap_die "Service user $SERVICE_USER is missing"
id -nG "$SERVICE_USER" | tr ' ' '\n' | grep -Fxq "$SERVICE_GROUP" \
|| bootstrap_die "Service user $SERVICE_USER is not a member of group $SERVICE_GROUP"
command -v runuser >/dev/null 2>&1 || bootstrap_die "runuser is required"
command -v setsid >/dev/null 2>&1 || bootstrap_die "setsid is required"
install -d -o root -g "$SERVICE_GROUP" -m 0750 \
"$APP_ROOT" "$APP_ROOT/releases" "$APP_ROOT/runtime" "$APP_ROOT/bin" "$STATE_ROOT" \
|| bootstrap_die "Managed application or update directories could not be prepared"
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 "$STATE_ROOT/inbox" "$LOG_ROOT" \
|| bootstrap_die "Writable update directories could not be prepared"
install -d -o root -g root -m 0700 \
"$PROCESSING_DIR" "$FAILED_REQUEST_ROOT" "$TRANSACTION_ROOT" "$STATE_ROOT/work" \
"$BACKUP_ROOT" "$CACHE_ROOT" \
|| bootstrap_die "Private update directories could not be prepared"
}
prepare_queue_layout() {
if [ -d "$PROCESSING_DIR" ] && [ ! -L "$PROCESSING_DIR" ] \
&& [ -d "$FAILED_REQUEST_ROOT" ] && [ ! -L "$FAILED_REQUEST_ROOT" ]; then
return 0
fi
install -d -o root -g root -m 0700 "$PROCESSING_DIR" "$FAILED_REQUEST_ROOT" \
|| bootstrap_die "Private update queue directories could not be prepared"
}
secure_release_tree() {
release_dir=$1
chown -R root:"$SERVICE_GROUP" "$release_dir" || return 1
find "$release_dir" -type d -exec chmod 0750 {} + || return 1
find "$release_dir" -type f -exec chmod 0640 {} + || return 1
chmod 0750 "$release_dir/ops/update.sh" "$release_dir/ops/baota-start.sh"
[ ! -f "$release_dir/ops/baota-init.sh" ] || chmod 0750 "$release_dir/ops/baota-init.sh"
}
verify_release_access() {
release_dir=$1
# shellcheck disable=SC2016 # Positional parameters are expanded by the child shell.
runuser -u "$SERVICE_USER" -- sh -c \
'cd "$1" && test -r app.jar && test -r public/index.html' sh "$release_dir" \
|| return 1
if [ "$PROCESS_MANAGER" = baota ]; then
runuser -u "$SERVICE_USER" -- test -x "$release_dir/ops/baota-start.sh"
else
runuser -u "$SERVICE_USER" -- test -x "$release_dir/ops/baota-start.sh" \
&& runuser -u "$SERVICE_USER" -- test -x "$JAVA_BIN" \
&& runuser -u "$SERVICE_USER" -- "$JAVA_BIN" -version >/dev/null 2>&1
fi
}
# shellcheck disable=SC2329 # Invoked by the EXIT trap below.
cleanup() {
rc=$?
trap - EXIT HUP INT TERM
if [ -n "$DOWNLOAD_PID" ]; then
if [ -n "$DOWNLOAD_PGID" ]; then
kill -TERM -- "-$DOWNLOAD_PGID" 2>/dev/null || true
else
kill -TERM "$DOWNLOAD_PID" 2>/dev/null || true
fi
wait "$DOWNLOAD_PID" 2>/dev/null || true
fi
[ -z "$WORK_DIR" ] || rm -rf "$WORK_DIR"
[ -z "$CACHE_TEMP" ] || rm -rf "$CACHE_TEMP"
if [ "$rc" -ne 0 ] && [ "$TERMINAL_STATUS_WRITTEN" != true ]; then
status FAILED "Update process was interrupted; automatic recovery will run" "${TARGET_VERSION:-}" || true
fi
exit "$rc"
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
download() {
url=$1
output=$2
case "$url" in
https://*)
if [ -n "$RELEASE_TOKEN" ]; then
[ -s "$RELEASE_AUTH_HEADER_FILE" ] || return 2
curl --fail --silent --show-error --proto '=https' --tlsv1.2 \
--header "@$RELEASE_AUTH_HEADER_FILE" "$url" -o "$output"
else
curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \
--tlsv1.2 "$url" -o "$output"
fi
;;
*)
[ "${KAIDI_ALLOW_INSECURE_UPDATE:-false}" = "true" ] || return 2
if [ -n "$RELEASE_TOKEN" ]; then
[ -s "$RELEASE_AUTH_HEADER_FILE" ] || return 2
curl --fail --silent --show-error --proto '=http,https' \
--header "@$RELEASE_AUTH_HEADER_FILE" "$url" -o "$output"
else
curl --fail --silent --show-error --location --proto '=http,https' \
--proto-redir '=http,https' "$url" -o "$output"
fi
;;
esac
}
download_with_progress() {
progress_url=$1
progress_output=$2
progress_total=${3:-0}
progress_started=$(date +%s)
progress_previous_time=$progress_started
progress_previous_bytes=0
rm -f "$progress_output"
touch "$progress_output"
chmod 0600 "$progress_output"
case "$progress_url" in
https://*)
if [ -n "$RELEASE_TOKEN" ]; then
[ -s "$RELEASE_AUTH_HEADER_FILE" ] || return 2
setsid curl --fail --silent --show-error --proto '=https' --tlsv1.2 \
--header "@$RELEASE_AUTH_HEADER_FILE" "$progress_url" -o "$progress_output" &
else
setsid curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \
--tlsv1.2 "$progress_url" -o "$progress_output" &
fi
;;
*)
[ "${KAIDI_ALLOW_INSECURE_UPDATE:-false}" = "true" ] || return 2
if [ -n "$RELEASE_TOKEN" ]; then
[ -s "$RELEASE_AUTH_HEADER_FILE" ] || return 2
setsid curl --fail --silent --show-error --proto '=http,https' \
--header "@$RELEASE_AUTH_HEADER_FILE" "$progress_url" -o "$progress_output" &
else
setsid curl --fail --silent --show-error --location --proto '=http,https' \
--proto-redir '=http,https' "$progress_url" -o "$progress_output" &
fi
;;
esac
DOWNLOAD_PID=$!
DOWNLOAD_PGID=$DOWNLOAD_PID
while kill -0 "$DOWNLOAD_PID" 2>/dev/null; do
progress_now=$(date +%s)
progress_bytes=$(wc -c < "$progress_output" 2>/dev/null | tr -d '[:space:]' || printf 0)
case "$progress_bytes:$progress_total" in *[!0-9:]*) progress_bytes=0; progress_total=0 ;; esac
progress_delta_seconds=$((progress_now - progress_previous_time))
[ "$progress_delta_seconds" -ge 1 ] || progress_delta_seconds=1
progress_delta_bytes=$((progress_bytes - progress_previous_bytes))
[ "$progress_delta_bytes" -ge 0 ] || progress_delta_bytes=0
progress_speed=$((progress_delta_bytes / progress_delta_seconds))
if [ "$progress_total" -gt 0 ]; then
progress_percent=$((progress_bytes * 100 / progress_total))
[ "$progress_percent" -le 99 ] || progress_percent=99
else
progress_percent=0
fi
status DOWNLOADING "Downloading signed release $TARGET_VERSION" "$TARGET_VERSION" \
"$progress_bytes" "$progress_total" "$progress_speed" "$progress_percent"
progress_previous_time=$progress_now
progress_previous_bytes=$progress_bytes
sleep 1
done
if wait "$DOWNLOAD_PID"; then
DOWNLOAD_PID=
DOWNLOAD_PGID=
else
DOWNLOAD_PID=
DOWNLOAD_PGID=
return 1
fi
progress_bytes=$(wc -c < "$progress_output" | tr -d '[:space:]')
[ "$progress_total" -gt 0 ] || progress_total=$progress_bytes
progress_finished=$(date +%s)
progress_elapsed=$((progress_finished - progress_started))
[ "$progress_elapsed" -ge 1 ] || progress_elapsed=1
progress_speed=$((progress_bytes / progress_elapsed))
LAST_DOWNLOAD_SPEED=$progress_speed
status DOWNLOADING "Release $TARGET_VERSION download completed" "$TARGET_VERSION" \
"$progress_bytes" "$progress_total" "$progress_speed" 100
}
release_asset_url() {
asset_name=$1
if [ -n "$RELEASE_API_URL" ]; then
jq -e --arg name "$asset_name" 'any(.assets[]?; .name == $name)' \
"$WORK_DIR/release-api.json" >/dev/null || return 1
release_tag=$(jq -er '.tag_name | strings
| select(test("^v[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$"))' \
"$WORK_DIR/release-api.json") || return 1
api_origin=$(printf '%s' "$RELEASE_API_URL" | sed -E 's#^(https?://[^/]+).*$#\1#')
repo_path=$(printf '%s' "$RELEASE_API_URL" | sed -nE \
's#^https?://[^/]+/api/v1/repos/([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)/releases/(latest|tags/[^/?#]+)$#\1#p')
[ -n "$repo_path" ] || return 1
encoded_tag=$(jq -rn --arg value "$release_tag" '$value | @uri')
encoded_asset=$(jq -rn --arg value "$asset_name" '$value | @uri')
printf '%s/%s/releases/download/%s/%s\n' "$api_origin" "$repo_path" "$encoded_tag" "$encoded_asset"
else
printf '%s/%s\n' "${RELEASE_BASE_URL%/}" "$asset_name"
fi
}
is_semver() {
[ "${#1}" -le 128 ] \
&& LC_ALL=C grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' \
<<EOF
$1
EOF
}
wait_for_health() {
health_url=$1
attempts=0
health_file=$(mktemp "$STATE_ROOT/work/health-response.XXXXXX")
while [ "$attempts" -lt "$HEALTH_ATTEMPTS" ]; do
health_http=$(curl --silent --show-error --connect-timeout 2 --max-time 4 \
--output "$health_file" --write-out '%{http_code}' "$health_url" 2>/dev/null || true)
if [ "$health_http" = 200 ] && jq -e '.status == "UP"' "$health_file" >/dev/null 2>&1; then
rm -f "$health_file"
return 0
fi
if [ "$health_http" = 200 ]; then
SURFACE_FAILURE=HEALTH_DOWN
elif [ -n "$health_http" ] && [ "$health_http" != 000 ]; then
SURFACE_FAILURE="HEALTH_HTTP_$health_http"
else
SURFACE_FAILURE=HEALTH_UNREACHABLE
fi
attempts=$((attempts + 1))
sleep "$HEALTH_INTERVAL_SECONDS"
done
rm -f "$health_file"
return 1
}
verify_app_surface() {
expected_release=${1:-}
SURFACE_FAILURE=
wait_for_health "$HEALTH_URL" || return 1
index_file=$(mktemp "$STATE_ROOT/work/public-index.XXXXXX")
index_http=$(curl --silent --show-error --connect-timeout 2 --max-time 5 \
--output "$index_file" --write-out '%{http_code}' "$APP_INDEX_URL" 2>/dev/null || true)
if [ "$index_http" = 200 ] \
&& grep -Eiq '<!doctype|<html' "$index_file"; then
rm -f "$index_file"
if [ "$PROCESS_MANAGER" = baota ] && [ -n "$expected_release" ]; then
running_release=$(baota_process_release) || {
SURFACE_FAILURE=PROCESS_IDENTITY_INVALID
return 1
}
if [ "$running_release" != "$expected_release" ]; then
SURFACE_FAILURE=RELEASE_MISMATCH
return 1
fi
fi
return 0
fi
rm -f "$index_file"
if [ "$index_http" != 200 ]; then
SURFACE_FAILURE="INDEX_HTTP_${index_http:-000}"
else
SURFACE_FAILURE=INDEX_CONTENT_INVALID
fi
return 1
}
baota_process_record() {
[ -f "$PID_FILE" ] && [ ! -L "$PID_FILE" ] || return 1
pid_record=$(cat "$PID_FILE" 2>/dev/null || true)
pid=${pid_record%% *}
start_time=${pid_record#* }
[ "$pid" != "$pid_record" ] || return 1
case "$pid:$start_time" in
*[!0-9:]*) return 1 ;;
esac
[ "$pid" -ne "$$" ] && [ -r "/proc/$pid/cmdline" ] || return 1
actual_start_time=$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true)
[ -n "$actual_start_time" ] && [ "$actual_start_time" = "$start_time" ] || return 1
owner_uid=$(stat -c '%u' "/proc/$pid" 2>/dev/null || true)
service_uid=$(id -u "$SERVICE_USER" 2>/dev/null || true)
[ -n "$owner_uid" ] && [ "$owner_uid" = "$service_uid" ] || return 1
process_args=$(mktemp "$STATE_ROOT/work/process-args.XXXXXX") || return 1
if ! tr '\000' '\n' < "/proc/$pid/cmdline" > "$process_args" 2>/dev/null; then
rm -f "$process_args"
return 1
fi
release_path=$(sed -n 's/^-Dkaidi\.release\.path=//p' "$process_args")
release_path_count=$(sed -n '/^-Dkaidi\.release\.path=/p' "$process_args" | wc -l | tr -d '[:space:]')
jar_path=$(awk 'previous == "-jar" { print; exit } { previous = $0 }' "$process_args")
rm -f "$process_args"
[ "$release_path_count" -eq 1 ] || return 1
case "$release_path" in
"$APP_ROOT"/releases/*) ;;
*) return 1 ;;
esac
[ -d "$release_path" ] && [ ! -L "$release_path" ] \
&& [ "$jar_path" = "$release_path/app.jar" ] && [ -f "$jar_path" ] \
|| return 1
printf '%s\t%s\n' "$pid" "$release_path"
}
baota_process_pid() {
baota_process_record | cut -f1
}
baota_process_release() {
baota_process_record | cut -f2-
}
restart_application() {
allow_missing=${1:-false}
if [ "$PROCESS_MANAGER" = systemd ]; then
systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true
systemctl restart "$SERVICE_NAME" || systemctl start "$SERVICE_NAME"
return
fi
old_pid=$(baota_process_pid) || {
[ "$allow_missing" = true ] && return 0
return 1
}
kill -TERM "$old_pid" 2>/dev/null || return 1
attempts=0
while [ "$attempts" -lt "$SHUTDOWN_ATTEMPTS" ]; do
if ! kill -0 "$old_pid" 2>/dev/null; then
current_pid_record=$(cat "$PID_FILE" 2>/dev/null || true)
case "$current_pid_record" in "$old_pid "*) rm -f "$PID_FILE" ;; esac
return 0
fi
attempts=$((attempts + 1))
sleep 1
done
return 1
}
application_state() {
if [ "$PROCESS_MANAGER" = systemd ]; then
systemctl is-active "$SERVICE_NAME" 2>/dev/null || printf unknown
elif baota_process_pid >/dev/null 2>&1; then
printf active
else
printf stopped
fi
}
mysqldump_bin() {
if [ -n "${KAIDI_MYSQLDUMP_BIN:-}" ]; then
case "$KAIDI_MYSQLDUMP_BIN" in
*/*) [ -x "$KAIDI_MYSQLDUMP_BIN" ] && printf '%s\n' "$KAIDI_MYSQLDUMP_BIN" && return 0 ;;
*) candidate=$(command -v "$KAIDI_MYSQLDUMP_BIN" 2>/dev/null || true); [ -n "$candidate" ] && printf '%s\n' "$candidate" && return 0 ;;
esac
return 1
fi
candidate=$(command -v mysqldump 2>/dev/null || true)
if [ -n "$candidate" ]; then
printf '%s\n' "$candidate"
return 0
fi
for candidate in \
/www/server/mysql/bin/mysqldump /usr/bin/mysqldump \
/usr/local/mysql/bin/mysqldump /opt/mysql/bin/mysqldump; do
if [ -x "$candidate" ]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
backup_database() {
DATABASE_BACKUP=
if [ "$DB_BACKUP_MODE" = skip ]; then
event INFO BACKING_UP '数据库备份已跳过;更新器不会访问 MySQL'
return 0
fi
mkdir -p "$BACKUP_ROOT"
chmod 0700 "$BACKUP_ROOT"
dump_file=$(mktemp "$BACKUP_ROOT/.mysql-dump.XXXXXX.sql")
backup="$BACKUP_ROOT/mysql-$(date -u +%Y%m%dT%H%M%SZ)-${TARGET_VERSION}.sql.gz"
backup_tmp="$backup.tmp.$$"
chmod 0600 "$dump_file"
dump_bin=$(mysqldump_bin || true)
[ -n "$dump_bin" ] || {
rm -f "$dump_file" "$backup_tmp"
fail "mysqldump is required before installing an update; set KAIDI_MYSQLDUMP_BIN or install it in a standard MySQL bin directory"
}
if ! MYSQL_PWD=${KAIDI_DB_PASSWORD:-} "$dump_bin" --single-transaction --routines --triggers \
-h "${KAIDI_DB_HOST:-127.0.0.1}" -P "${KAIDI_DB_PORT:-3306}" \
-u "${KAIDI_DB_USERNAME:-kaidi}" "${KAIDI_DB_NAME:-kaidi_finance}" > "$dump_file"; then
rm -f "$dump_file" "$backup_tmp"
fail "Database backup failed"
fi
[ -s "$dump_file" ] || {
rm -f "$dump_file" "$backup_tmp"
fail "Database backup is empty"
}
if ! gzip -9 -c "$dump_file" > "$backup_tmp" || ! gzip -t "$backup_tmp"; then
rm -f "$dump_file" "$backup_tmp"
fail "Database backup compression failed"
fi
chmod 0600 "$backup_tmp"
mv -f "$backup_tmp" "$backup"
DATABASE_BACKUP=$backup
rm -f "$dump_file"
# Keep the newest five backups without relying on GNU find's -printf; the
# updater is also exercised on BSD/macOS fixtures during release checks.
# shellcheck disable=SC2012 # Generated backup names contain no whitespace.
LC_ALL=C ls -1t "$BACKUP_ROOT"/mysql-*.sql.gz 2>/dev/null \
| awk 'NR > 5' \
| while IFS= read -r old_backup; do rm -f -- "$old_backup"; done
}
atomic_install() {
install_source=$1
install_destination=$2
install_mode=$3
install_temporary="${install_destination}.next.$$"
rm -f "$install_temporary"
install -m "$install_mode" "$install_source" "$install_temporary" || {
rm -f "$install_temporary"
return 1
}
mv -f "$install_temporary" "$install_destination"
}
install_operation() {
operation_source=$1
operation_destination=$2
operation_mode=$3
operation_is_systemd=$4
if [ -f "$operation_destination" ] && [ ! -L "$operation_destination" ] \
&& cmp -s "$operation_source" "$operation_destination"; then
chmod "$operation_mode" "$operation_destination"
return
fi
atomic_install "$operation_source" "$operation_destination" "$operation_mode" || return 1
if [ "$operation_is_systemd" = true ]; then
SYSTEMD_OPERATIONS_CHANGED=true
fi
}
backup_managed_file() {
backup_source=$1
backup_name=$2
if [ -f "$backup_source" ] && [ ! -L "$backup_source" ]; then
cp -p "$backup_source" "$OPS_BACKUP/$backup_name"
elif [ ! -e "$backup_source" ]; then
: > "$OPS_BACKUP/$backup_name.absent"
else
return 1
fi
}
restore_managed_file() {
restore_destination=$1
restore_name=$2
if [ -f "$OPS_BACKUP/$restore_name.absent" ]; then
rm -f "$restore_destination"
return
fi
[ -f "$OPS_BACKUP/$restore_name" ] || return 1
restore_temporary="${restore_destination}.rollback.$$"
rm -f "$restore_temporary"
cp -p "$OPS_BACKUP/$restore_name" "$restore_temporary" || {
rm -f "$restore_temporary"
return 1
}
mv -f "$restore_temporary" "$restore_destination"
}
backup_operations() {
OPS_BACKUP="$ACTIVE_TRANSACTION/operations-backup"
mkdir -p "$OPS_BACKUP"
backup_managed_file "$UPDATER_PATH" update.sh || return 1
if [ "$PROCESS_MANAGER" = systemd ]; then
backup_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service || return 1
fi
backup_managed_file "$SYSTEMD_ROOT/kaidi-update.service" kaidi-update.service \
&& backup_managed_file "$SYSTEMD_ROOT/kaidi-update.path" kaidi-update.path
}
apply_operations() {
SYSTEMD_OPERATIONS_CHANGED=false
install_operation "$RELEASE_DIR/ops/update.sh" "$UPDATER_PATH" 0755 false || return 1
if [ "$PROCESS_MANAGER" = systemd ]; then
install_operation "$RELEASE_DIR/ops/kaidi-finance.service" \
"$SYSTEMD_ROOT/kaidi-finance.service" 0644 true || return 1
fi
install_operation "$RELEASE_DIR/ops/kaidi-update.service" \
"$SYSTEMD_ROOT/kaidi-update.service" 0644 true \
&& install_operation "$RELEASE_DIR/ops/kaidi-update.path" \
"$SYSTEMD_ROOT/kaidi-update.path" 0644 true \
|| return 1
[ "$SYSTEMD_OPERATIONS_CHANGED" = false ] || systemctl daemon-reload
}
restore_operations() {
restore_failed=0
restore_managed_file "$UPDATER_PATH" update.sh || restore_failed=1
if [ "$PROCESS_MANAGER" = systemd ]; then
restore_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service || restore_failed=1
fi
restore_managed_file "$SYSTEMD_ROOT/kaidi-update.service" kaidi-update.service || restore_failed=1
restore_managed_file "$SYSTEMD_ROOT/kaidi-update.path" kaidi-update.path || restore_failed=1
systemctl daemon-reload || restore_failed=1
[ "$restore_failed" -eq 0 ]
}
write_transaction_value() {
name=$1
value=$2
tmp="$ACTIVE_TRANSACTION/$name.tmp.$$"
printf '%s\n' "$value" > "$tmp"
mv -f "$tmp" "$ACTIVE_TRANSACTION/$name"
}
transaction_value() {
cat "$ACTIVE_TRANSACTION/$1" 2>/dev/null || true
}
remove_failed_release() {
failed_release=$1
case "$failed_release" in
"$APP_ROOT"/releases/*)
[ "$(readlink "$APP_ROOT/current" 2>/dev/null || true)" = "$failed_release" ] || rm -rf "$failed_release"
;;
esac
}
quarantine_active_transaction() {
[ -d "$ACTIVE_TRANSACTION" ] || return 0
recovery_transaction="$TRANSACTION_ROOT/recovery-required"
if [ -e "$recovery_transaction" ] || [ -L "$recovery_transaction" ]; then
recovery_transaction="$TRANSACTION_ROOT/recovery-required-$(date -u +%Y%m%dT%H%M%SZ)-$$"
fi
mv "$ACTIVE_TRANSACTION" "$recovery_transaction"
}
rollback_active_transaction() {
reason=$1
previous_target=$(transaction_value previous-target)
failed_release=$(transaction_value release-dir)
OPS_BACKUP="$ACTIVE_TRANSACTION/operations-backup"
link_restored=true
operations_restored=true
application_restarted=true
if [ -n "$previous_target" ] && [ -d "$previous_target" ]; then
if ! ln -sfn "$previous_target" "$APP_ROOT/current.next" \
|| ! mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current"; then
link_restored=false
fi
else
link_restored=false
fi
restore_operations || operations_restored=false
restart_application true || application_restarted=false
if [ "$link_restored" = true ] && [ "$application_restarted" = true ] \
&& verify_app_surface "$previous_target"; then
remove_failed_release "$failed_release"
if [ "$operations_restored" = true ]; then
database_backup_record=$(transaction_value database-backup)
rm -rf "$ACTIVE_TRANSACTION"
if [ -n "$database_backup_record" ] || [ -n "${DATABASE_BACKUP:-}" ]; then
fail "$reason; previous application release was restored and verified; database backup was retained"
fi
fail "$reason; previous application release was restored and verified; no database backup was created"
fi
fail "$reason; previous application release is running, but operations restoration requires manual review; transaction evidence was retained"
fi
TERMINAL_STATUS_WRITTEN=true
recovery_guard_failed=false
if ! archive_processing_request; then
printf '%s\n' "$(localize_message 'Update request could not be archived after an incomplete rollback')" >&2
recovery_guard_failed=true
fi
if ! quarantine_active_transaction; then
printf '%s\n' "$(localize_message 'Update transaction evidence could not be quarantined after an incomplete rollback')" >&2
recovery_guard_failed=true
fi
if [ "$recovery_guard_failed" = true ]; then
systemctl stop "$UPDATE_PATH_NAME" >/dev/null 2>&1 || true
fi
status RECOVERY_REQUIRED "$reason; rollback is incomplete and manual recovery is required" "${TARGET_VERSION:-}"
printf '%s\n' "$(localize_message "$reason; rollback is incomplete and manual recovery is required")" >&2
exit 1
}
recover_interrupted_transaction() {
[ -d "$ACTIVE_TRANSACTION" ] || return 0
TARGET_VERSION=$(transaction_value target-version)
phase=$(transaction_value phase)
case "$phase" in
COMMITTED)
complete_request
rm -rf "$ACTIVE_TRANSACTION"
status SUCCEEDED "Release $TARGET_VERSION is running" "$TARGET_VERSION"
return 0
;;
PREPARED|'')
remove_failed_release "$(transaction_value release-dir)"
rm -rf "$ACTIVE_TRANSACTION"
return 0
;;
*) rollback_active_transaction "Interrupted update transaction detected" ;;
esac
}
validate_release_operations() {
sh -n "$WORK_DIR/extracted/ops/update.sh" || return 1
bash -n "$WORK_DIR/extracted/ops/baota-start.sh" || return 1
bash -n "$WORK_DIR/extracted/ops/baota-init.sh" || return 1
[ -s "$WORK_DIR/extracted/ops/release-public.pem" ] \
&& cmp -s "$WORK_DIR/extracted/ops/release-public.pem" "$PUBLIC_KEY" \
|| return 1
command -v systemd-analyze >/dev/null 2>&1 || return 1
systemd-analyze verify \
"$WORK_DIR/extracted/ops/kaidi-finance.service" \
"$WORK_DIR/extracted/ops/kaidi-update.service" \
"$WORK_DIR/extracted/ops/kaidi-update.path" >/dev/null || return 1
}
prepare_queue_layout
exec 9>"$LOCK_FILE"
flock -n 9 || exit 0
recover_interrupted_transaction
if [ -e "$PROCESSING_FILE" ] || [ -L "$PROCESSING_FILE" ]; then
if [ -L "$PROCESSING_FILE" ] || [ ! -f "$PROCESSING_FILE" ]; then
rm -f "$PROCESSING_FILE" 2>/dev/null || true
fail "Processing update request must be a regular file"
fi
else
if [ ! -e "$REQUEST_FILE" ] && [ ! -L "$REQUEST_FILE" ]; then
exit 0
fi
if [ -L "$REQUEST_FILE" ] || [ ! -f "$REQUEST_FILE" ]; then
rm -f "$REQUEST_FILE" 2>/dev/null || true
fail "Update request must be a regular file"
fi
mv "$REQUEST_FILE" "$PROCESSING_FILE" || exit 1
if [ -L "$PROCESSING_FILE" ] || [ ! -f "$PROCESSING_FILE" ]; then
rm -f "$PROCESSING_FILE" 2>/dev/null || true
fail "Claimed update request must be a regular file"
fi
fi
chmod 0600 "$PROCESSING_FILE"
if [ -f "$STATUS_FILE" ] && [ ! -L "$STATUS_FILE" ] \
&& jq -e '.state == "RECOVERY_REQUIRED"' "$STATUS_FILE" >/dev/null 2>&1; then
TERMINAL_STATUS_WRITTEN=true
if ! archive_processing_request; then
systemctl stop "$UPDATE_PATH_NAME" >/dev/null 2>&1 || true
fi
printf '%s\n' "$(localize_message 'Update service is locked for manual recovery; new requests are rejected')" >&2
exit 1
fi
if ! REQUESTED_VERSION=$(jq -er '.version | strings | select(length > 0 and length <= 128)' \
"$PROCESSING_FILE"); then
fail "Update request version is invalid"
fi
is_semver "$REQUESTED_VERSION" || fail "Update request version is invalid"
TARGET_VERSION=$REQUESTED_VERSION
REQUEST_ACTION=$(jq -er '(.action // "INSTALL") | strings | ascii_upcase
| select(. == "DOWNLOAD" or . == "INSTALL")' "$PROCESSING_FILE") \
|| fail "Update request action is invalid"
REQUEST_ID=$(jq -r '.requestId // empty | strings | select(length > 0 and length <= 64)' \
"$PROCESSING_FILE" 2>/dev/null || true)
prepare_update_layout
reset_event_log
load_runtime_database_env
if [ "$REQUEST_ACTION" = INSTALL ]; then
validate_runtime_database_config
fi
[ -z "$RELEASE_TOKEN" ] || { [ "${#RELEASE_TOKEN}" -le 512 ] \
&& ! printf '%s' "$RELEASE_TOKEN" | grep -q '[[:cntrl:]]'; } \
|| fail "UPDATE_RELEASE_TOKEN is invalid"
[ -s "$PUBLIC_KEY" ] || fail "Release verification public key is missing"
status VERIFYING "Validating signed release $TARGET_VERSION" "$TARGET_VERSION"
WORK_DIR=$(mktemp -d "$STATE_ROOT/work/update.XXXXXX")
if [ -n "$RELEASE_TOKEN" ]; then
RELEASE_AUTH_HEADER_FILE="$WORK_DIR/release-auth-header"
printf 'Authorization: token %s\n' "$RELEASE_TOKEN" > "$RELEASE_AUTH_HEADER_FILE"
chmod 0600 "$RELEASE_AUTH_HEADER_FILE"
fi
if [ "$REQUEST_ACTION" = DOWNLOAD ]; then
[ -n "$RELEASE_BASE_URL" ] || [ -n "$RELEASE_API_URL" ] \
|| fail "UPDATE_RELEASE_BASE_URL or UPDATE_RELEASE_API_URL is not configured"
if [ -n "$RELEASE_API_URL" ]; then
download "$RELEASE_API_URL" "$WORK_DIR/release-api.json" \
|| fail "Gitea latest Release lookup failed"
fi
MANIFEST_URL=$(release_asset_url release-manifest.json) \
|| fail "Release manifest asset is missing"
SIGNATURE_URL=$(release_asset_url release-manifest.sig) \
|| fail "Release manifest signature asset is missing"
download "$MANIFEST_URL" "$WORK_DIR/release-manifest.json" \
|| fail "Release manifest download failed"
download "$SIGNATURE_URL" "$WORK_DIR/release-manifest.sig" \
|| fail "Release manifest signature download failed"
else
CACHE_DIR="$CACHE_ROOT/$TARGET_VERSION"
[ -d "$CACHE_DIR" ] && [ ! -L "$CACHE_DIR" ] \
|| fail "Verified release cache is missing"
[ -f "$CACHE_DIR/release-manifest.json" ] && [ ! -L "$CACHE_DIR/release-manifest.json" ] \
|| fail "Cached release manifest is missing"
[ -f "$CACHE_DIR/release-manifest.sig" ] && [ ! -L "$CACHE_DIR/release-manifest.sig" ] \
|| fail "Cached release signature is missing"
[ -f "$CACHE_DIR/release.tar.gz" ] && [ ! -L "$CACHE_DIR/release.tar.gz" ] \
|| fail "Cached release artifact is missing"
cp "$CACHE_DIR/release-manifest.json" "$WORK_DIR/release-manifest.json"
cp "$CACHE_DIR/release-manifest.sig" "$WORK_DIR/release-manifest.sig"
cp "$CACHE_DIR/release.tar.gz" "$WORK_DIR/release.tar.gz"
fi
openssl dgst -sha256 -verify "$PUBLIC_KEY" -signature "$WORK_DIR/release-manifest.sig" \
"$WORK_DIR/release-manifest.json" >/dev/null 2>&1 || fail "Release manifest signature verification failed"
TARGET_VERSION=$(jq -er '.version | strings | select(length > 0 and length <= 128)' \
"$WORK_DIR/release-manifest.json") || fail "Release manifest version is invalid"
is_semver "$TARGET_VERSION" || fail "Release manifest version is invalid"
[ "$TARGET_VERSION" = "$REQUESTED_VERSION" ] || fail "Requested version is no longer the latest signed release"
ARTIFACT=$(jq -er '.artifact | strings | select(test("^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}\\.tar\\.gz$"))' \
"$WORK_DIR/release-manifest.json") || fail "Release artifact name is invalid"
EXPECTED_SHA=$(jq -er '.sha256 | strings | ascii_downcase | select(test("^[0-9a-f]{64}$"))' \
"$WORK_DIR/release-manifest.json") || fail "Release SHA-256 is invalid"
EXPECTED_SIZE=$(jq -er '(.artifactSizeBytes // 0) | numbers | floor | select(. >= 0)' \
"$WORK_DIR/release-manifest.json") || fail "Release artifact size is invalid"
RELEASE_NOTES=$(jq -r '(.releaseNotes // "") | strings | .[0:4000]' \
"$WORK_DIR/release-manifest.json") || fail "Release notes are invalid"
RELEASE_PUBLISHED_AT=$(jq -r '(.publishedAt // "") | strings | .[0:128]' \
"$WORK_DIR/release-manifest.json") || fail "Release publish time is invalid"
CURRENT_VERSION=$(cat "$APP_ROOT/current/VERSION" 2>/dev/null || true)
if [ "$CURRENT_VERSION" = "$TARGET_VERSION" ]; then
status CURRENT "Version $TARGET_VERSION is already installed" "$TARGET_VERSION"
TERMINAL_STATUS_WRITTEN=true
complete_request
exit 0
fi
if [ "$REQUEST_ACTION" = DOWNLOAD ]; then
status DOWNLOADING "Downloading signed release $TARGET_VERSION" "$TARGET_VERSION"
ARTIFACT_URL=$(release_asset_url "$ARTIFACT") || fail "Release artifact asset is missing"
download_with_progress "$ARTIFACT_URL" "$WORK_DIR/release.tar.gz" "$EXPECTED_SIZE" \
|| fail "Release artifact download failed"
else
status VERIFYING "Revalidating cached release $TARGET_VERSION" "$TARGET_VERSION"
fi
ACTUAL_SHA=$(sha256sum "$WORK_DIR/release.tar.gz" | awk '{print $1}')
[ "$ACTUAL_SHA" = "$EXPECTED_SHA" ] || fail "Release artifact SHA-256 verification failed"
ACTUAL_SIZE=$(wc -c < "$WORK_DIR/release.tar.gz" | tr -d '[:space:]')
[ "$EXPECTED_SIZE" -eq 0 ] || [ "$ACTUAL_SIZE" -eq "$EXPECTED_SIZE" ] \
|| fail "Release artifact size verification failed"
tar -tzf "$WORK_DIR/release.tar.gz" > "$WORK_DIR/archive.list" || fail "Release archive could not be listed"
if grep -Eq '(^/|(^|/)\.\.(/|$))' "$WORK_DIR/archive.list"; then
fail "Release archive contains an unsafe path"
fi
mkdir "$WORK_DIR/extracted"
tar -xzf "$WORK_DIR/release.tar.gz" -C "$WORK_DIR/extracted" || fail "Release archive could not be extracted"
if find "$WORK_DIR/extracted" -type l -print -quit | grep -q .; then
fail "Release archive must not contain symbolic links"
fi
[ -s "$WORK_DIR/extracted/app.jar" ] || fail "Release app.jar is missing"
[ -s "$WORK_DIR/extracted/public/index.html" ] || fail "Release frontend is missing"
[ "$(cat "$WORK_DIR/extracted/VERSION" 2>/dev/null)" = "$TARGET_VERSION" ] || fail "Release version file mismatch"
[ -x "$WORK_DIR/extracted/ops/update.sh" ] || fail "Release updater is missing"
[ -x "$WORK_DIR/extracted/ops/baota-start.sh" ] || fail "Release Baota launcher is missing"
[ -x "$WORK_DIR/extracted/ops/baota-init.sh" ] || fail "Release Baota initializer is missing"
[ -s "$WORK_DIR/extracted/ops/release-public.pem" ] || fail "Release public key is missing"
[ -s "$WORK_DIR/extracted/ops/kaidi-finance.service" ] || fail "Release application unit is missing"
[ -s "$WORK_DIR/extracted/ops/kaidi-update.service" ] || fail "Release updater unit is missing"
[ -s "$WORK_DIR/extracted/ops/kaidi-update.path" ] || fail "Release updater path unit is missing"
validate_release_operations || fail "Release operations validation failed"
if [ "$REQUEST_ACTION" = DOWNLOAD ]; then
CACHE_DIR="$CACHE_ROOT/$TARGET_VERSION"
CACHE_TEMP=$(mktemp -d "$CACHE_ROOT/.release.XXXXXX")
cp "$WORK_DIR/release-manifest.json" "$CACHE_TEMP/release-manifest.json"
cp "$WORK_DIR/release-manifest.sig" "$CACHE_TEMP/release-manifest.sig"
cp "$WORK_DIR/release.tar.gz" "$CACHE_TEMP/release.tar.gz"
chmod 0600 "$CACHE_TEMP/release-manifest.json" "$CACHE_TEMP/release-manifest.sig" \
"$CACHE_TEMP/release.tar.gz"
if [ -e "$CACHE_DIR" ] || [ -L "$CACHE_DIR" ]; then
[ -d "$CACHE_DIR" ] && [ ! -L "$CACHE_DIR" ] || fail "Verified release cache path is unsafe"
rm -rf "$CACHE_DIR"
fi
mv "$CACHE_TEMP" "$CACHE_DIR" || fail "Verified release cache could not be activated"
CACHE_TEMP=
status READY "Release $TARGET_VERSION is downloaded and verified; confirm installation" "$TARGET_VERSION" \
"$ACTUAL_SIZE" "$ACTUAL_SIZE" "$LAST_DOWNLOAD_SPEED" 100
TERMINAL_STATUS_WRITTEN=true
complete_request
exit 0
fi
RELEASE_DIR="$APP_ROOT/releases/$TARGET_VERSION"
if [ -e "$RELEASE_DIR" ]; then
[ "$(readlink "$APP_ROOT/current" 2>/dev/null || true)" != "$RELEASE_DIR" ] \
|| fail "Release directory is already active"
rm -rf "$RELEASE_DIR" || fail "Failed release staging directory could not be cleaned"
fi
mv "$WORK_DIR/extracted" "$RELEASE_DIR" || fail "Release directory could not be activated"
secure_release_tree "$RELEASE_DIR" || fail "Release ownership or permissions could not be secured"
if command -v restorecon >/dev/null 2>&1; then
restorecon -RF "$APP_ROOT" >/dev/null 2>&1 || true
fi
if ! verify_release_access "$RELEASE_DIR"; then
rm -rf "$RELEASE_DIR"
fail "Service user cannot access the release or selected Java runtime"
fi
PREVIOUS_TARGET=$(readlink "$APP_ROOT/current" 2>/dev/null || true)
status PRECHECKING "Validating current release before switching to $TARGET_VERSION" "$TARGET_VERSION"
if [ -z "$PREVIOUS_TARGET" ] || [ ! -d "$PREVIOUS_TARGET" ]; then
fail "Current release path is invalid; application was not restarted"
fi
if ! verify_app_surface "$PREVIOUS_TARGET"; then
fail "Current release preflight failed (${SURFACE_FAILURE:-UNKNOWN}); application was not restarted"
fi
mkdir "$ACTIVE_TRANSACTION"
write_transaction_value target-version "$TARGET_VERSION"
write_transaction_value previous-target "$PREVIOUS_TARGET"
write_transaction_value release-dir "$RELEASE_DIR"
write_transaction_value phase PREPARED
if [ "$DB_BACKUP_MODE" = mysqldump ]; then
status BACKING_UP "Backing up database before installing $TARGET_VERSION" "$TARGET_VERSION"
else
status BACKING_UP "Database backup skipped; updater does not access MySQL" "$TARGET_VERSION"
fi
backup_database
[ -z "${DATABASE_BACKUP:-}" ] || write_transaction_value database-backup "$DATABASE_BACKUP"
if ! backup_operations; then
remove_failed_release "$RELEASE_DIR"
rm -rf "$ACTIVE_TRANSACTION"
fail "Existing operations files could not be backed up"
fi
status INSTALLING "Installing release $TARGET_VERSION" "$TARGET_VERSION"
write_transaction_value phase OPS_APPLYING
apply_operations || rollback_active_transaction "Release operations files could not be activated"
write_transaction_value phase OPS_APPLIED
write_transaction_value phase APP_SWITCHING
if ! ln -sfn "$RELEASE_DIR" "$APP_ROOT/current.next" \
|| ! mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current"; then
rollback_active_transaction "Release application link could not be activated"
fi
write_transaction_value phase APP_SWITCHED
status RUNNING "Starting and verifying release $TARGET_VERSION" "$TARGET_VERSION" "" "" "" "" 10
write_transaction_value phase HEALTH_CHECKING
if ! restart_application; then
rollback_active_transaction "Release application restart failed"
fi
if verify_app_surface "$RELEASE_DIR"; then
success_message="Release $TARGET_VERSION is running"
if ! systemctl start "$UPDATE_PATH_NAME" >/dev/null 2>&1; then
success_message="$success_message; automatic update watcher could not be started"
fi
write_transaction_value phase COMMITTED
status SUCCEEDED "$success_message" "$TARGET_VERSION"
TERMINAL_STATUS_WRITTEN=true
complete_request
rm -rf "$ACTIVE_TRANSACTION"
exit 0
fi
service_state=$(application_state 2>/dev/null || true)
[ -n "$service_state" ] || service_state=unknown
surface_failure=${SURFACE_FAILURE:-UNKNOWN}
rollback_active_transaction \
"Release application verification failed ($surface_failure; service state: $service_state)"