feat: add signed PostgreSQL release updates

This commit is contained in:
Qiufeng
2026-08-03 23:45:32 +08:00
parent 2524a37a07
commit f6e22cb670
99 changed files with 44671 additions and 284 deletions
+461
View File
@@ -0,0 +1,461 @@
#!/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}"
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 = {
"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