Files
ERP/distribution/bin/erp-update
T

475 lines
16 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <version>\n' >&2; exit 2 ;;
esac
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi