#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" INSTALL_ROOT="${ERP_INSTALL_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd -P)}" CONFIG_FILE="${ERP_CONFIG_FILE:-$INSTALL_ROOT/config/erp.env}" if [[ ! -r "$CONFIG_FILE" ]]; then printf '[ERP Update] configuration not readable: %s\n' "$CONFIG_FILE" >&2 exit 1 fi set -a # shellcheck disable=SC1090 source "$CONFIG_FILE" set +a STATE_FILE="${OA_UPDATE_STATE_FILE:-${ERP_STATE_FILE:-$INSTALL_ROOT/state/update-state.json}}" RUN_DIR="${ERP_RUN_DIR:-$INSTALL_ROOT/run}" RELEASES_DIR="$INSTALL_ROOT/releases" CURRENT_LINK="$INSTALL_ROOT/current" HEALTH_URL="${ERP_HEALTH_URL:-http://127.0.0.1:${SERVER_PORT:-8091}/api/oa/health}" GITEA_BASE_URL="${OA_UPDATE_GITEA_BASE_URL:-}" REPOSITORY="${OA_UPDATE_REPOSITORY:-awaioi/ERP}" CHANNEL="${OA_UPDATE_CHANNEL:-stable}" TOKEN="${OA_UPDATE_TOKEN:-}" ALLOW_INSECURE="${OA_UPDATE_ALLOW_INSECURE_HTTP:-false}" if [[ "${GITEA_BASE_URL%/}" == "http://38.76.196.225:10099" ]]; then GITEA_BASE_URL="https://git.awaioi.com" ALLOW_INSECURE=false fi PUBLIC_KEY_FILE="${ERP_UPDATE_PUBLIC_KEY_FILE:-$INSTALL_ROOT/config/release-public-key.pem}" REQUIRE_SIGNATURE="${ERP_UPDATE_REQUIRE_SIGNATURE:-true}" BACKUP_MODE="${ERP_UPDATE_BACKUP_MODE:-none}" HEALTH_TIMEOUT_SECONDS="${ERP_UPDATE_HEALTH_TIMEOUT_SECONDS:-120}" HEALTH_POLL_SECONDS="${ERP_UPDATE_HEALTH_POLL_SECONDS:-2}" TMP_DIR="" LOCK_FILE="$RUN_DIR/update.lock" FINAL_STATE=0 say() { printf '[ERP Update] %s\n' "$*"; } write_state() { local phase="$1" progress="$2" message="$3" version="${4:-}" error="${5:-}" mkdir -p "$(dirname "$STATE_FILE")" python3 - "$STATE_FILE" "$phase" "$progress" "$message" "$version" "$error" <<'PY' import json, os, sys, tempfile from datetime import datetime, timezone path, phase, progress, message, version, error = sys.argv[1:] payload = {} try: if os.path.getsize(path) <= 64 * 1024: with open(path, encoding="utf-8") as handle: existing = json.load(handle) if isinstance(existing, dict): payload = existing except (OSError, json.JSONDecodeError, TypeError, ValueError): pass payload.update({ "phase": phase, "progress": int(progress), "message": message, "version": version or None, "error": error or None, "updatedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), }) parent = os.path.dirname(path) or "." fd, tmp = tempfile.mkstemp(prefix=".update-state-", dir=parent, text=True) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: json.dump(payload, handle, ensure_ascii=False, separators=(",", ":")) handle.write("\n") os.chmod(tmp, 0o640) os.replace(tmp, path) finally: if os.path.exists(tmp): os.unlink(tmp) PY } cleanup() { if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then rm -rf "$TMP_DIR" fi } on_exit() { local code=$? if [[ "$code" != "0" && "$FINAL_STATE" != "1" ]]; then write_state FAILED 0 "更新失败" "${TARGET_VERSION:-}" "更新助手异常退出(code $code)" || true fi cleanup } trap on_exit EXIT fail() { local message="$1" write_state FAILED "${2:-0}" "更新失败" "${TARGET_VERSION:-}" "$message" || true FINAL_STATE=1 printf '[ERP Update] ERROR: %s\n' "$message" >&2 exit 1 } require_command() { command -v "$1" >/dev/null 2>&1 || fail "缺少命令:$1" } validate_https_url() { local value="$1" case "$value" in https://*) return 0 ;; http://*) [[ "$ALLOW_INSECURE" == "true" || "$ALLOW_INSECURE" == "1" ]] && return 0 ;; esac fail "更新地址必须使用 HTTPS" } acquire_lock_or_reexec() { [[ "${ERP_UPDATE_LOCK_HELD:-0}" == "1" ]] && return 0 mkdir -p "$RUN_DIR" command -v python3 >/dev/null 2>&1 || { printf '[ERP Update] ERROR: 缺少命令:python3\n' >&2 exit 1 } exec python3 - "$LOCK_FILE" "$0" "$@" <<'PY' import fcntl import os import shutil import sys import time lock_path, script, *args = sys.argv[1:] if os.path.isdir(lock_path): owner = "" try: with open(os.path.join(lock_path, "pid"), encoding="ascii") as handle: owner = handle.read().strip() except OSError: pass alive = False if owner.isdigit(): try: os.kill(int(owner), 0) alive = True except OSError: pass try: recent = time.time() - os.stat(lock_path).st_mtime < 30 except OSError: recent = False if alive or recent: raise SystemExit(f"已有更新任务正在执行(PID {owner or 'unknown'})") try: shutil.rmtree(lock_path) except FileNotFoundError: pass lock = open(lock_path, "a+", encoding="ascii") try: fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: lock.seek(0) owner = lock.read().strip() or "unknown" raise SystemExit(f"已有更新任务正在执行(PID {owner})") lock.seek(0) lock.truncate() lock.write(str(os.getpid()) + "\n") lock.flush() os.fchmod(lock.fileno(), 0o640) os.set_inheritable(lock.fileno(), True) env = os.environ.copy() env["ERP_UPDATE_LOCK_HELD"] = "1" script = os.path.abspath(script) os.execve(script, [script, *args], env) PY } create_curl_config() { CURL_CONFIG="$TMP_DIR/curl.conf" { printf 'silent\nshow-error\nfail\nlocation\n' printf 'connect-timeout = 15\nmax-time = 900\n' if [[ "$ALLOW_INSECURE" == "true" || "$ALLOW_INSECURE" == "1" ]]; then printf 'proto = "=http,https"\nproto-redir = "=http,https"\n' else printf 'proto = "=https"\nproto-redir = "=https"\n' fi if [[ -n "$TOKEN" ]]; then [[ "$TOKEN" =~ ^[A-Za-z0-9._-]+$ ]] || fail "Gitea Token 格式无效" printf 'header = "Authorization: token %s"\n' "$TOKEN" fi } > "$CURL_CONFIG" chmod 600 "$CURL_CONFIG" } download() { local url="$1" output="$2" validate_https_url "$url" curl --config "$CURL_CONFIG" --output "$output" "$url" } select_release() { local release_json="$1" requested="$2" output="$3" python3 - "$release_json" "$requested" "$CHANNEL" > "$output" <<'PY' import json, re, sys path, requested, channel = sys.argv[1:] with open(path, encoding="utf-8") as handle: release = json.load(handle) tag = str(release.get("tag_name") or "").strip() match = re.fullmatch(r"v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)", tag) if not match: raise SystemExit("invalid release tag") version = match.group(1) if requested and requested.removeprefix("v") != version: raise SystemExit("requested version is no longer latest") if release.get("draft"): raise SystemExit("latest release is a draft") if channel.lower() == "stable" and release.get("prerelease"): raise SystemExit("prerelease rejected on stable channel") if channel.lower() == "stable" and "-" in version.split("+", 1)[0]: raise SystemExit("prerelease tag rejected on stable channel") assets = {str(item.get("name")): str(item.get("browser_download_url") or "") for item in release.get("assets", [])} names = [f"kaidi-erp-{version}.tar.gz", "SHA256SUMS", "SHA256SUMS.sig"] urls = [assets.get(name, "") for name in names] if any(not value or any(ch.isspace() for ch in value) for value in urls): raise SystemExit("release assets are incomplete") print("\t".join([version, *urls])) PY } verify_signature_and_checksum() { local archive="$1" sums="$2" signature="$3" archive_name="$4" write_state VERIFYING 45 "正在校验发布签名" "$TARGET_VERSION" if [[ "$REQUIRE_SIGNATURE" == "true" || "$REQUIRE_SIGNATURE" == "1" ]]; then [[ -r "$PUBLIC_KEY_FILE" ]] || fail "找不到发布公钥" 45 require_command openssl local openssl_version algorithms openssl_version="$(openssl version 2>/dev/null || true)" [[ "$openssl_version" =~ ^OpenSSL[[:space:]]3\. ]] \ || fail "签名验证需要 OpenSSL 3" 45 algorithms="$(openssl list -public-key-algorithms 2>/dev/null || true)" grep -qi ED25519 <<< "$algorithms" \ || fail "当前 OpenSSL 不支持 Ed25519" 45 openssl pkeyutl -verify -rawin -pubin -inkey "$PUBLIC_KEY_FILE" \ -sigfile "$signature" -in "$sums" >/dev/null \ || fail "Release Ed25519 签名验证失败" 45 fi local expected actual expected="$(python3 - "$sums" "$archive_name" <<'PY' import re, sys path, wanted = sys.argv[1:] for line in open(path, encoding="utf-8"): match = re.fullmatch(r"([0-9a-fA-F]{64})\s+\*?(.+?)\s*", line) if match and match.group(2) == wanted: print(match.group(1).lower()) break PY )" [[ "$expected" =~ ^[0-9a-f]{64}$ ]] || fail "SHA256SUMS 缺少安装包校验值" 50 if command -v sha256sum >/dev/null 2>&1; then actual="$(sha256sum "$archive" | awk '{print $1}')" else actual="$(shasum -a 256 "$archive" | awk '{print $1}')" fi [[ "$actual" == "$expected" ]] || fail "安装包 SHA-256 校验失败" 50 } extract_archive() { local archive="$1" destination="$2" python3 - "$archive" <<'PY' import pathlib, sys, tarfile with tarfile.open(sys.argv[1], "r:gz") as archive: for member in archive.getmembers(): path = pathlib.PurePosixPath(member.name) if path.is_absolute() or ".." in path.parts or member.issym() or member.islnk(): raise SystemExit(f"unsafe archive member: {member.name}") PY mkdir -p "$destination" tar -xzf "$archive" -C "$destination" } atomic_switch() { local target="$1" python3 - "$CURRENT_LINK" "$target" <<'PY' import os, sys link, target = sys.argv[1:] tmp = f"{link}.new-{os.getpid()}" try: os.symlink(target, tmp) os.replace(tmp, link) finally: if os.path.lexists(tmp): os.unlink(tmp) PY } wait_for_restarted_health() { local old_pid="$1" deadline=$((SECONDS + HEALTH_TIMEOUT_SECONDS)) while (( SECONDS < deadline )); do local new_pid="" [[ -r "$RUN_DIR/app.pid" ]] && new_pid="$(<"$RUN_DIR/app.pid")" if [[ "$new_pid" =~ ^[0-9]+$ && "$new_pid" != "$old_pid" ]] \ && kill -0 "$new_pid" 2>/dev/null \ && curl -fsS --connect-timeout 2 --max-time 5 "$HEALTH_URL" >/dev/null 2>&1; then return 0 fi sleep "$HEALTH_POLL_SECONDS" done return 1 } wait_for_pid_exit() { local pid="$1" deadline=$((SECONDS + ${2:-60})) while kill -0 "$pid" 2>/dev/null; do (( SECONDS < deadline )) || return 1 sleep 1 done } clear_pid_file() { local expected="$1" current="" [[ -r "$RUN_DIR/app.pid" ]] && current="$(<"$RUN_DIR/app.pid")" if [[ "$current" == "$expected" ]] && ! kill -0 "$expected" 2>/dev/null; then rm -f "$RUN_DIR/app.pid" fi } terminate_application_pid() { local pid="$1" [[ "$pid" =~ ^[0-9]+$ && "$pid" != "$$" ]] || return 0 if kill -0 "$pid" 2>/dev/null; then kill -TERM "$pid" 2>/dev/null || return 1 wait_for_pid_exit "$pid" 60 || return 1 fi clear_pid_file "$pid" } stop_application() { local pid="" [[ -r "$RUN_DIR/app.pid" ]] && pid="$(<"$RUN_DIR/app.pid")" [[ "$pid" =~ ^[0-9]+$ ]] || pid="${ERP_APP_PID:-}" [[ "$pid" =~ ^[0-9]+$ ]] || fail "无法读取 ERP 服务 PID" 75 [[ "$pid" != "$$" ]] || fail "拒绝停止更新助手自身" 75 terminate_application_pid "$pid" || fail "ERP 服务 PID $pid 未能在 60 秒内停止" 75 printf '%s\n' "$pid" } backup_postgres_if_enabled() { [[ "$BACKUP_MODE" == "pg_dump" ]] || return 0 require_command pg_dump local backup_dir="$INSTALL_ROOT/backups" mkdir -p "$backup_dir" local output="$backup_dir/pre-${TARGET_VERSION}-$(date -u +%Y%m%dT%H%M%SZ).dump" write_state INSTALLING 60 "正在创建 PostgreSQL 逻辑备份" "$TARGET_VERSION" PGPASSWORD="${OA_DB_PASSWORD:-}" PGSSLMODE="${ERP_PGSSLMODE:-prefer}" pg_dump \ --host="${ERP_PGHOST:-127.0.0.1}" \ --port="${ERP_PGPORT:-5432}" \ --username="${OA_DB_USERNAME:-}" \ --dbname="${ERP_PGDATABASE:-oa}" \ --format=custom --file="$output" \ || fail "PostgreSQL 备份失败,已取消更新" 60 chmod 600 "$output" } install_release() { local requested="${1:-}" validate_https_url "$GITEA_BASE_URL" [[ "$HEALTH_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] \ || fail "健康检查超时时间配置无效" [[ "$HEALTH_POLL_SECONDS" =~ ^[1-9][0-9]*$ ]] \ || fail "健康检查间隔配置无效" [[ "$REPOSITORY" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || fail "更新仓库配置无效" require_command curl require_command python3 require_command tar TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kaidi-erp-update.XXXXXX")" create_curl_config write_state CHECKING 5 "正在读取 Gitea Release" "$requested" local owner="${REPOSITORY%%/*}" repo="${REPOSITORY#*/}" local api_url="${GITEA_BASE_URL%/}/api/v1/repos/$owner/$repo/releases/latest" local release_json="$TMP_DIR/release.json" download "$api_url" "$release_json" local selection="$TMP_DIR/selection" select_release "$release_json" "$requested" "$selection" || fail "Release 元数据验证失败" 10 local archive_url sums_url signature_url IFS=$'\t' read -r TARGET_VERSION archive_url sums_url signature_url < "$selection" local archive_name="kaidi-erp-${TARGET_VERSION}.tar.gz" local archive="$TMP_DIR/$archive_name" sums="$TMP_DIR/SHA256SUMS" signature="$TMP_DIR/SHA256SUMS.sig" write_state DOWNLOADING 20 "正在下载版本 ${TARGET_VERSION}" "$TARGET_VERSION" download "$archive_url" "$archive" download "$sums_url" "$sums" download "$signature_url" "$signature" verify_signature_and_checksum "$archive" "$sums" "$signature" "$archive_name" write_state INSTALLING 55 "正在准备版本 ${TARGET_VERSION}" "$TARGET_VERSION" local unpack="$TMP_DIR/unpack" extract_archive "$archive" "$unpack" || fail "安装包结构验证失败" 55 local source="$unpack/kaidi-erp-${TARGET_VERSION}" [[ -r "$source/app/kaidi-erp.jar" && -x "$source/bin/erp-run" && -x "$source/bin/erp-update" ]] \ || fail "安装包缺少运行文件" 55 [[ "$(<"$source/VERSION")" == "$TARGET_VERSION" ]] || fail "安装包版本与 Release 不一致" 55 python3 - "$source/manifest.json" "$TARGET_VERSION" <<'PY' || fail "版本清单不允许自动回滚" 55 import json, sys manifest = json.load(open(sys.argv[1], encoding="utf-8")) if manifest.get("version") != sys.argv[2] or manifest.get("database") != "postgresql": raise SystemExit(1) if manifest.get("rollbackCompatible") is not True: raise SystemExit(1) PY mkdir -p "$RELEASES_DIR" local release_dir="$RELEASES_DIR/$TARGET_VERSION" if [[ -r "$CURRENT_LINK/VERSION" && "$(<"$CURRENT_LINK/VERSION")" == "$TARGET_VERSION" ]]; then fail "目标版本已经安装" 55 fi local replaced="" if [[ -e "$release_dir" ]]; then replaced="${release_dir}.replaced-$$" mv "$release_dir" "$replaced" fi if ! mv "$source" "$release_dir"; then [[ -z "$replaced" || ! -e "$replaced" ]] || mv "$replaced" "$release_dir" fail "无法写入目标版本目录" 55 fi [[ -z "$replaced" ]] || rm -rf "$replaced" backup_postgres_if_enabled local previous="" [[ -L "$CURRENT_LINK" ]] && previous="$(readlink "$CURRENT_LINK")" [[ -n "$previous" ]] || fail "当前版本链接不存在,拒绝无回滚点更新" 70 atomic_switch "releases/$TARGET_VERSION" write_state RESTARTING 80 "正在重启 ERP 服务" "$TARGET_VERSION" local old_pid old_pid="$(stop_application)" if wait_for_restarted_health "$old_pid"; then write_state SUCCEEDED 100 "更新完成" "$TARGET_VERSION" FINAL_STATE=1 say "updated to $TARGET_VERSION" return 0 fi write_state ROLLING_BACK 90 "新版本健康检查失败,正在回滚" "$TARGET_VERSION" atomic_switch "$previous" local failed_pid="" [[ -r "$RUN_DIR/app.pid" ]] && failed_pid="$(<"$RUN_DIR/app.pid")" terminate_application_pid "$failed_pid" || true if wait_for_restarted_health "$failed_pid"; then write_state ROLLED_BACK 100 "新版本不可用,已恢复上一版本" "$TARGET_VERSION" "健康检查失败" FINAL_STATE=1 exit 1 fi fail "新版本与回滚版本均未通过健康检查,需要人工处理" 100 } main() { local command="${1:-}" version="${2:-}" case "$command" in install) acquire_lock_or_reexec "$@" install_release "$version" ;; *) printf 'usage: erp-update install \n' >&2; exit 2 ;; esac } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@" fi