#!/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} HEALTH_URL=${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health} PUBLIC_HEALTH_URL=${KAIDI_PUBLIC_HEALTH_URL:-http://127.0.0.1/actuator/health} PUBLIC_INDEX_URL=${KAIDI_PUBLIC_INDEX_URL:-http://127.0.0.1/} 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} NGINX_CONFIG=${KAIDI_NGINX_CONFIG:-/etc/nginx/conf.d/kaidi-finance.conf} 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; } mkdir -p "$STATE_ROOT/inbox" "$PROCESSING_DIR" "$FAILED_REQUEST_ROOT" "$TRANSACTION_ROOT" \ "$STATE_ROOT/work" "$APP_ROOT/releases" "$LOG_ROOT" "$BACKUP_ROOT" "$CACHE_ROOT" chmod 0700 "$PROCESSING_DIR" "$FAILED_REQUEST_ROOT" "$TRANSACTION_ROOT" "$BACKUP_ROOT" "$CACHE_ROOT" 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 } # 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 asset_url=$(jq -er --arg name "$asset_name" \ '.assets[] | select(.name == $name) | .browser_download_url | strings | select(startswith("https://") or startswith("http://"))' \ "$WORK_DIR/release-api.json" | head -n 1) || return 1 api_origin=$(printf '%s' "$RELEASE_API_URL" | sed -E 's#^(https?://[^/]+).*$#\1#') case "$asset_url" in "$api_origin"/*) printf '%s\n' "$asset_url" ;; *) return 1 ;; esac 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-]+)*)?$' \ </dev/null 2>&1; then return 0 fi attempts=$((attempts + 1)) sleep "$HEALTH_INTERVAL_SECONDS" done return 1 } verify_public_surface() { wait_for_health "$PUBLIC_HEALTH_URL" || return 1 index_file=$(mktemp "$STATE_ROOT/work/public-index.XXXXXX") if curl -fsS "$PUBLIC_INDEX_URL" -o "$index_file" \ && grep -Eiq '/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 \ && backup_managed_file "$NGINX_CONFIG" kaidi-finance.conf } 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 \ && atomic_install "$RELEASE_DIR/ops/kaidi-finance.conf" "$NGINX_CONFIG" 0644 \ && systemctl daemon-reload \ && nginx -t } 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 restore_managed_file "$NGINX_CONFIG" kaidi-finance.conf || restore_failed=1 systemctl daemon-reload || restore_failed=1 if nginx -t; then systemctl reload nginx || restore_failed=1 else restore_failed=1 fi [ "$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 start "$SERVICE_NAME" || rollback_ok=false if [ "$rollback_ok" = true ] && wait_for_health "$HEALTH_URL" && verify_public_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" [ -s "$WORK_DIR/extracted/ops/kaidi-finance.conf" ] || fail "Release Nginx configuration 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" chown -R root:root "$RELEASE_DIR" || fail "Release ownership could not be secured" chmod -R go-w "$RELEASE_DIR" || fail "Release permissions could not be secured" 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 if systemctl start "$SERVICE_NAME" \ && wait_for_health "$HEALTH_URL" \ && systemctl reload nginx \ && systemctl is-active --quiet kaidi-update.path \ && verify_public_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 public-surface verification failed"