615 lines
24 KiB
Bash
Executable File
615 lines
24 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}
|
|
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}
|
|
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}
|
|
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}
|
|
WORK_DIR=
|
|
CACHE_TEMP=
|
|
RELEASE_AUTH_HEADER_FILE=
|
|
TARGET_VERSION=
|
|
TERMINAL_STATUS_WRITTEN=false
|
|
|
|
case "$HEALTH_ATTEMPTS:$HEALTH_INTERVAL_SECONDS" in
|
|
*[!0-9:]* | :* | *:) printf '%s\n' "Update health-check settings must be non-negative integers" >&2; exit 1 ;;
|
|
esac
|
|
[ "$HEALTH_ATTEMPTS" -ge 1 ] || { printf '%s\n' "Update health-check attempts must be at least 1" >&2; exit 1; }
|
|
|
|
bootstrap_die() {
|
|
printf '%s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
status() {
|
|
state=$1
|
|
message=$2
|
|
version=${3:-}
|
|
tmp="$STATUS_FILE.tmp.$$"
|
|
jq -n \
|
|
--arg state "$state" \
|
|
--arg message "$message" \
|
|
--arg version "$version" \
|
|
--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)' > "$tmp"
|
|
chmod 0644 "$tmp"
|
|
mv -f "$tmp" "$STATUS_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
|
|
status FAILED "$1" "${TARGET_VERSION:-}"
|
|
archive_processing_request || true
|
|
printf '%s\n' "$1" >&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"
|
|
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"
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
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
|
|
runuser -u "$SERVICE_USER" -- test -x "$APP_ROOT/runtime/java/bin/java" \
|
|
|| return 1
|
|
runuser -u "$SERVICE_USER" -- "$APP_ROOT/runtime/java/bin/java" -version >/dev/null 2>&1
|
|
}
|
|
|
|
prepare_update_layout
|
|
|
|
# shellcheck disable=SC2329 # Invoked by the EXIT trap below.
|
|
cleanup() {
|
|
rc=$?
|
|
trap - EXIT HUP INT TERM
|
|
[ -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
|
|
}
|
|
|
|
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
|
|
while [ "$attempts" -lt "$HEALTH_ATTEMPTS" ]; do
|
|
if curl -fsS "$health_url" | jq -e '.status == "UP"' >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
attempts=$((attempts + 1))
|
|
sleep "$HEALTH_INTERVAL_SECONDS"
|
|
done
|
|
return 1
|
|
}
|
|
|
|
verify_app_surface() {
|
|
wait_for_health "$HEALTH_URL" || return 1
|
|
index_file=$(mktemp "$STATE_ROOT/work/public-index.XXXXXX")
|
|
if curl -fsS "$APP_INDEX_URL" -o "$index_file" \
|
|
&& grep -Eiq '<!doctype|<html' "$index_file"; then
|
|
rm -f "$index_file"
|
|
return 0
|
|
fi
|
|
rm -f "$index_file"
|
|
return 1
|
|
}
|
|
|
|
backup_database() {
|
|
[ "${KAIDI_SKIP_DB_BACKUP:-false}" = "true" ] && return
|
|
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"
|
|
|
|
if [ -n "${KAIDI_DB_CONTAINER:-}" ] && command -v docker >/dev/null 2>&1 \
|
|
&& docker inspect "$KAIDI_DB_CONTAINER" >/dev/null 2>&1; then
|
|
if ! docker exec -e MYSQL_PWD="${KAIDI_DB_PASSWORD:-}" "$KAIDI_DB_CONTAINER" \
|
|
mysqldump --single-transaction --routines --triggers \
|
|
-u "${KAIDI_DB_USERNAME:-kaidi}" "${KAIDI_DB_NAME:-kaidi_finance}" > "$dump_file"; then
|
|
rm -f "$dump_file" "$backup_tmp"
|
|
fail "Database backup failed"
|
|
fi
|
|
else
|
|
command -v mysqldump >/dev/null 2>&1 || {
|
|
rm -f "$dump_file" "$backup_tmp"
|
|
fail "mysqldump is required before installing an update"
|
|
}
|
|
if ! MYSQL_PWD=${KAIDI_DB_PASSWORD:-} mysqldump --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
|
|
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"
|
|
rm -f "$dump_file"
|
|
find "$BACKUP_ROOT" -type f -name 'mysql-*.sql.gz' -printf '%T@ %p\n' \
|
|
| sort -nr | awk 'NR > 5 {sub(/^[^ ]+ /, ""); print}' | xargs -r rm -f
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
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 \
|
|
&& backup_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service \
|
|
&& backup_managed_file "$SYSTEMD_ROOT/kaidi-update.service" kaidi-update.service \
|
|
&& backup_managed_file "$SYSTEMD_ROOT/kaidi-update.path" kaidi-update.path
|
|
}
|
|
|
|
apply_operations() {
|
|
atomic_install "$RELEASE_DIR/ops/update.sh" "$UPDATER_PATH" 0755 \
|
|
&& atomic_install "$RELEASE_DIR/ops/kaidi-finance.service" \
|
|
"$SYSTEMD_ROOT/kaidi-finance.service" 0644 \
|
|
&& atomic_install "$RELEASE_DIR/ops/kaidi-update.service" \
|
|
"$SYSTEMD_ROOT/kaidi-update.service" 0644 \
|
|
&& atomic_install "$RELEASE_DIR/ops/kaidi-update.path" \
|
|
"$SYSTEMD_ROOT/kaidi-update.path" 0644 \
|
|
&& systemctl daemon-reload
|
|
}
|
|
|
|
restore_operations() {
|
|
restore_failed=0
|
|
restore_managed_file "$UPDATER_PATH" update.sh || restore_failed=1
|
|
restore_managed_file "$SYSTEMD_ROOT/kaidi-finance.service" kaidi-finance.service || restore_failed=1
|
|
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
|
|
}
|
|
|
|
rollback_active_transaction() {
|
|
reason=$1
|
|
previous_target=$(transaction_value previous-target)
|
|
failed_release=$(transaction_value release-dir)
|
|
OPS_BACKUP="$ACTIVE_TRANSACTION/operations-backup"
|
|
rollback_ok=true
|
|
|
|
systemctl stop "$SERVICE_NAME" || 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
|
|
rollback_ok=false
|
|
fi
|
|
else
|
|
rollback_ok=false
|
|
fi
|
|
restore_operations || rollback_ok=false
|
|
systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true
|
|
systemctl start "$SERVICE_NAME" || rollback_ok=false
|
|
if [ "$rollback_ok" = true ] && verify_app_surface; then
|
|
remove_failed_release "$failed_release"
|
|
rm -rf "$ACTIVE_TRANSACTION"
|
|
fail "$reason; previous release was restored and verified"
|
|
fi
|
|
TERMINAL_STATUS_WRITTEN=true
|
|
status FAILED "$reason; rollback is incomplete and will be retried" "${TARGET_VERSION:-}"
|
|
printf '%s\n' "$reason; rollback is incomplete and will be retried" >&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
|
|
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
|
|
}
|
|
|
|
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 ! 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"
|
|
[ -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"
|
|
|
|
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 "$ARTIFACT_URL" "$WORK_DIR/release.tar.gz" || 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"
|
|
|
|
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"
|
|
[ -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"
|
|
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 managed Java runtime"
|
|
fi
|
|
|
|
PREVIOUS_TARGET=$(readlink "$APP_ROOT/current" 2>/dev/null || true)
|
|
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
|
|
|
|
status BACKING_UP "Backing up database before installing $TARGET_VERSION" "$TARGET_VERSION"
|
|
backup_database
|
|
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
|
|
systemctl stop "$SERVICE_NAME" || rollback_active_transaction "Application service could not be stopped"
|
|
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"
|
|
write_transaction_value phase HEALTH_CHECKING
|
|
systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true
|
|
if systemctl start "$SERVICE_NAME" \
|
|
&& systemctl is-active --quiet kaidi-update.path \
|
|
&& verify_app_surface; then
|
|
write_transaction_value phase COMMITTED
|
|
status SUCCEEDED "Release $TARGET_VERSION is running" "$TARGET_VERSION"
|
|
TERMINAL_STATUS_WRITTEN=true
|
|
complete_request
|
|
rm -rf "$ACTIVE_TRANSACTION"
|
|
exit 0
|
|
fi
|
|
|
|
rollback_active_transaction "Release health or application-surface verification failed"
|