Files
kaidi/scripts/test-update-fixture.sh
T
2026-08-18 22:51:35 +08:00

733 lines
29 KiB
Bash
Executable File

#!/usr/bin/env bash
set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
REAL_OPENSSL=$(command -v openssl)
fail() {
printf 'Update fixture failed: %s\n' "$1" >&2
exit 1
}
mode_of() {
stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1"
}
sed -n '/^is_semver()/,/^}/p' "$ROOT/deploy/update.sh" > "$WORK/update-semver.sh"
# shellcheck disable=SC1090,SC1091
source "$WORK/update-semver.sh"
for version in 0.0.0 1.2.3-alpha- 1.2.3--alpha 1.2.3-alpha+build.07; do
is_semver "$version" || fail "updater rejected valid SemVer $version"
done
for version in 01.2.3 1.02.3 1.2.03 1.2.3-01 1.2.3-alpha..1; do
! is_semver "$version" || fail "updater accepted invalid SemVer $version"
done
missing_service_user="kaidi-fixture-missing-$$"
mkdir -p "$WORK/bootstrap/app" "$WORK/bootstrap/state/inbox" \
"$WORK/bootstrap/state/processing" "$WORK/bootstrap/state/failed" "$WORK/bootstrap/log" \
"$WORK/bootstrap/bin"
cat > "$WORK/bootstrap/bin/flock" <<'SH'
#!/bin/sh
exit 0
SH
chmod 0755 "$WORK/bootstrap/bin/flock"
jq -n \
'{action:"DOWNLOAD",version:"1.0.0-preview.2",reason:"bootstrap fixture"}' \
> "$WORK/bootstrap/state/inbox/request.json"
if KAIDI_APP_ROOT="$WORK/bootstrap/app" \
KAIDI_UPDATE_STATE_ROOT="$WORK/bootstrap/state" \
KAIDI_LOG_ROOT="$WORK/bootstrap/log" \
KAIDI_SERVICE_USER="$missing_service_user" \
KAIDI_SERVICE_GROUP="$missing_service_user" \
PATH="$WORK/bootstrap/bin:$PATH" \
sh "$ROOT/deploy/update.sh" > "$WORK/bootstrap.log" 2>&1; then
fail 'updater accepted a missing service identity during bootstrap'
fi
grep -Fq '缺少服务用户' "$WORK/bootstrap.log" \
|| fail 'updater bootstrap failure did not preserve its diagnostic'
[ "$(jq -r '.state' "$WORK/bootstrap/state/status.json")" = FAILED ] \
|| fail 'updater bootstrap failure did not persist FAILED'
[ ! -e "$WORK/bootstrap/state/inbox/request.json" ] \
&& [ ! -e "$WORK/bootstrap/state/processing/request.json" ] \
|| fail 'updater bootstrap failure left a request permanently queued'
find "$WORK/bootstrap/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
|| fail 'updater bootstrap failure did not archive the claimed request'
write_mock_commands() {
local mock_bin=$1
mkdir -p "$mock_bin"
cat > "$mock_bin/curl" <<'SH'
#!/bin/sh
output=
url=
header_file=
write_out=
http_status=200
printf '%s\n' "$*" >> "${MOCK_CURL_LOG:-/dev/null}"
while [ "$#" -gt 0 ]; do
case "$1" in
-o|--output) shift; output=$1 ;;
--write-out) shift; write_out=$1 ;;
--connect-timeout|--max-time) shift ;;
--header|-H)
shift
case "${1:-}" in @*) header_file=${1#@} ;; esac
;;
-*) ;;
*) url=$1 ;;
esac
shift
done
case "$url" in
"${MOCK_RELEASE_ORIGIN:-https://release.fixture.invalid}"/*)
if [ -n "${MOCK_EXPECT_RELEASE_TOKEN:-}" ]; then
[ -r "$header_file" ] || exit 90
grep -Fqx "Authorization: token $MOCK_EXPECT_RELEASE_TOKEN" "$header_file" || exit 91
printf 'AUTH_FILE_OK\n' >> "${MOCK_CURL_LOG:-/dev/null}"
fi
;;
esac
if [ -n "$output" ]; then
if [ -n "${MOCK_RELEASE_API_URL:-}" ] && [ "$url" = "$MOCK_RELEASE_API_URL" ]; then
cp "$FIXTURE_RELEASE_ROOT/release-api.json" "$output"
elif [ "$url" = "${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health}" ]; then
health_ok=false
case "${MOCK_HEALTH:-success}" in
success) health_ok=true ;;
fail-new)
[ "$(cat "$MOCK_APP_ROOT/current/VERSION" 2>/dev/null)" = '1.0.0-preview.1' ] && health_ok=true
;;
fail-after-first)
health_count=$(cat "$MOCK_APP_ROOT/health-count" 2>/dev/null || printf 0)
health_count=$((health_count + 1))
printf '%s\n' "$health_count" > "$MOCK_APP_ROOT/health-count"
[ "$health_count" -eq 1 ] && health_ok=true
;;
esac
if [ "$health_ok" = true ]; then
printf '{"status":"UP"}\n' > "$output"
else
printf '{"status":"DOWN"}\n' > "$output"
http_status=503
fi
elif [ "$url" = "${KAIDI_APP_INDEX_URL:-http://127.0.0.1:18080/}" ]; then
cp "$MOCK_APP_ROOT/current/public/index.html" "$output"
else
[ "${MOCK_DOWNLOAD_FAILURE:-}" != "${url##*/}" ] || exit 22
cp "$FIXTURE_RELEASE_ROOT/${url##*/}" "$output"
fi
elif [ "${MOCK_HEALTH:-success}" = success ]; then
printf '{"status":"UP"}\n'
elif [ "${MOCK_HEALTH:-success}" = fail-new ] \
&& [ "$(cat "$MOCK_APP_ROOT/current/VERSION" 2>/dev/null)" = '1.0.0-preview.1' ]; then
printf '{"status":"UP"}\n'
else
exit 22
fi
[ -z "$write_out" ] || printf '%s' "$http_status"
SH
cat > "$mock_bin/systemctl" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >> "$MOCK_SYSTEMCTL_LOG"
[ "${MOCK_UPDATE_PATH_START:-success}:$*" != 'fail:start kaidi-update.path' ] || exit 1
exit 0
SH
cat > "$mock_bin/flock" <<'SH'
#!/bin/sh
exit 0
SH
cat > "$mock_bin/setsid" <<'SH'
#!/bin/sh
exec "$@"
SH
cat > "$mock_bin/systemd-analyze" <<'SH'
#!/bin/sh
exit 0
SH
cat > "$mock_bin/mysqldump" <<'SH'
#!/bin/sh
if [ "${MOCK_MYSQLDUMP:-success}" = fail ]; then
printf 'partial dump\n'
exit 9
fi
printf '%s\n' '-- valid fixture dump' 'CREATE TABLE fixture (id INT);'
SH
cat > "$mock_bin/chown" <<'SH'
#!/bin/sh
exit 0
SH
cat > "$mock_bin/runuser" <<'SH'
#!/bin/sh
[ "${1:-}" = -u ] || exit 2
shift 2
[ "${1:-}" = -- ] && shift
exec "$@"
SH
cat > "$mock_bin/install" <<'SH'
#!/usr/bin/env bash
if [[ " $* " != *" -d "* ]]; then
exec /usr/bin/install "$@"
fi
mode=0755
paths=()
while [ "$#" -gt 0 ]; do
case "$1" in
-d) shift ;;
-o|-g) shift 2 ;;
-m) mode=$2; shift 2 ;;
*) paths+=("$1"); shift ;;
esac
done
mkdir -p "${paths[@]}"
chmod "$mode" "${paths[@]}"
SH
cat > "$mock_bin/mv" <<'SH'
#!/bin/sh
case "${1:-}" in
-Tf | -fT)
source=$2
destination=$3
/bin/rm -f "$destination"
/bin/mv "$source" "$destination"
;;
*) /bin/mv "$@" ;;
esac
SH
cat > "$mock_bin/sha256sum" <<'SH'
#!/bin/sh
hash=$($REAL_OPENSSL dgst -sha256 "$1" | awk '{print $NF}')
printf '%s %s\n' "$hash" "$1"
SH
chmod 0755 "$mock_bin"/*
}
build_release() {
local fixture=$1
local version=$2
local unit_prefix=${3:-new}
local stage="$fixture/stage"
mkdir -p "$fixture/release" "$stage/public" "$stage/ops"
if [ ! -s "$WORK/fixture-private.pem" ]; then
"$REAL_OPENSSL" genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
-out "$WORK/fixture-private.pem" >/dev/null 2>&1
"$REAL_OPENSSL" pkey -in "$WORK/fixture-private.pem" -pubout \
-out "$WORK/fixture-public.pem" >/dev/null 2>&1
fi
cp "$WORK/fixture-private.pem" "$fixture/private.pem"
cp "$WORK/fixture-public.pem" "$fixture/release-public.pem"
printf 'new application\n' > "$stage/app.jar"
printf '<!doctype html><title>new</title>\n' > "$stage/public/index.html"
printf '%s\n' "$version" > "$stage/VERSION"
printf '#!/bin/sh\nprintf "new updater\\n"\n' > "$stage/ops/update.sh"
printf '#!/usr/bin/env bash\nexit 0\n' > "$stage/ops/baota-start.sh"
printf '#!/usr/bin/env bash\nexit 0\n' > "$stage/ops/baota-init.sh"
cp "$fixture/release-public.pem" "$stage/ops/release-public.pem"
chmod 0755 "$stage/ops/update.sh"
chmod 0755 "$stage/ops/baota-start.sh"
chmod 0755 "$stage/ops/baota-init.sh"
for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do
printf '%s %s\n' "$unit_prefix" "$name" > "$stage/ops/$name"
done
local artifact="kaidi-finance-$version.tar.gz"
COPYFILE_DISABLE=1 tar -czf "$fixture/release/$artifact" -C "$stage" .
local sha size
sha=$($REAL_OPENSSL dgst -sha256 "$fixture/release/$artifact" | awk '{print $NF}')
size=$(wc -c < "$fixture/release/$artifact" | tr -d '[:space:]')
jq -n --arg version "$version" --arg artifact "$artifact" --arg sha "$sha" --argjson size "$size" \
'{version:$version,artifact:$artifact,sha256:$sha,artifactSizeBytes:$size,
publishedAt:"2026-08-16T00:00:00Z",releaseNotes:"fixture"}' \
> "$fixture/release/release-manifest.json"
"$REAL_OPENSSL" dgst -sha256 -sign "$fixture/private.pem" \
-out "$fixture/release/release-manifest.sig" "$fixture/release/release-manifest.json"
}
build_gitea_release_index() {
local fixture=$1
local origin=${2:-https://gitea.fixture.invalid}
local artifact version tag
artifact=$(jq -er '.artifact' "$fixture/release/release-manifest.json")
version=$(jq -er '.version' "$fixture/release/release-manifest.json")
tag="v$version"
jq -n --arg origin "$origin" --arg artifact "$artifact" --arg tag "$tag" \
'{tag_name:$tag,assets:[
{name:"release-manifest.json",browser_download_url:($origin + "/assets/release-manifest.json")},
{name:"release-manifest.sig",browser_download_url:($origin + "/assets/release-manifest.sig")},
{name:$artifact,browser_download_url:($origin + "/assets/" + $artifact)}
]}' > "$fixture/release/release-api.json"
}
prepare_installation() {
local fixture=$1
local version=$2
mkdir -p "$fixture/app/releases/1.0.0-preview.1/public" "$fixture/app/bin" \
"$fixture/app/runtime/java/bin" "$fixture/state/inbox" "$fixture/systemd" "$fixture/log"
printf 'old application\n' > "$fixture/app/releases/1.0.0-preview.1/app.jar"
printf '<!doctype html><title>old</title>\n' > "$fixture/app/releases/1.0.0-preview.1/public/index.html"
printf '1.0.0-preview.1\n' > "$fixture/app/releases/1.0.0-preview.1/VERSION"
ln -s "$fixture/app/releases/1.0.0-preview.1" "$fixture/app/current"
printf 'old update.sh\n' > "$fixture/app/bin/update.sh"
chmod 0755 "$fixture/app/bin/update.sh"
cat > "$fixture/app/runtime/java/bin/java" <<'SH'
#!/bin/sh
exit 0
SH
chmod 0755 "$fixture/app/runtime/java/bin/java"
for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do
printf 'old %s\n' "$name" > "$fixture/systemd/$name"
done
write_request "$fixture" "$version" DOWNLOAD
}
write_request() {
local fixture=$1
local version=$2
local action=$3
mkdir -p "$fixture/state/inbox"
jq -n --arg action "$action" --arg version "$version" \
'{action:$action,version:$version,reason:"fixture"}' > "$fixture/state/inbox/request.json"
}
download_and_prepare_install() {
local fixture=$1
local version=$2
truncate -s 0 "$fixture/systemctl.log"
run_update "$fixture" success
[ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \
|| fail 'download phase did not persist READY'
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|| fail 'download phase changed the active application'
[ -s "$fixture/state/cache/$version/release.tar.gz" ] \
|| fail 'download phase did not persist the verified artifact cache'
[ "$(jq -r '.downloadPercent' "$fixture/state/status.json")" -eq 100 ] \
|| fail 'download phase did not persist 100 percent progress'
[ "$(jq -r '.totalBytes' "$fixture/state/status.json")" -gt 0 ] \
|| fail 'download phase did not persist artifact bytes'
[ "$(jq -r '.bytesPerSecond' "$fixture/state/status.json")" -gt 0 ] \
|| fail 'download phase did not persist a measured transfer speed'
grep -Fq '"stage":"DOWNLOADING"' "$fixture/state/events.jsonl" \
|| fail 'download phase did not persist structured runtime events'
! grep -Eq '^(start|restart|stop) kaidi-finance.service$' "$fixture/systemctl.log" \
|| fail 'download phase changed the application service'
write_request "$fixture" "$version" INSTALL
}
run_update() {
local fixture=$1
local health=$2
local skip_backup=${3:-true}
local backup_mode=${4:-}
env \
PATH="$fixture/mock-bin:$PATH" \
REAL_OPENSSL="$REAL_OPENSSL" \
FIXTURE_RELEASE_ROOT="$fixture/release" \
MOCK_APP_ROOT="$fixture/app" \
MOCK_HEALTH="$health" \
MOCK_DOWNLOAD_FAILURE="${MOCK_DOWNLOAD_FAILURE:-}" \
MOCK_MYSQLDUMP="${MOCK_MYSQLDUMP:-success}" \
MOCK_CURL_LOG="${MOCK_CURL_LOG:-$fixture/curl.log}" \
MOCK_RELEASE_API_URL="${FIXTURE_RELEASE_API_URL-}" \
MOCK_RELEASE_ORIGIN="${FIXTURE_RELEASE_ORIGIN:-https://release.fixture.invalid}" \
MOCK_EXPECT_RELEASE_TOKEN="${FIXTURE_RELEASE_TOKEN-}" \
MOCK_SYSTEMCTL_LOG="$fixture/systemctl.log" \
MOCK_UPDATE_PATH_START="${MOCK_UPDATE_PATH_START:-success}" \
KAIDI_SERVICE_USER="$(id -un)" \
KAIDI_SERVICE_GROUP="$(id -gn)" \
KAIDI_APP_ROOT="$fixture/app" \
KAIDI_UPDATE_STATE_ROOT="$fixture/state" \
KAIDI_LOG_ROOT="$fixture/log" \
KAIDI_SYSTEMD_ROOT="$fixture/systemd" \
KAIDI_UPDATER_PATH="$fixture/app/bin/update.sh" \
KAIDI_RUNTIME_ENV_FILE="$fixture/runtime.env" \
KAIDI_SKIP_DB_BACKUP="$skip_backup" \
KAIDI_DB_BACKUP_MODE="$backup_mode" \
KAIDI_UPDATE_HEALTH_ATTEMPTS=1 \
KAIDI_UPDATE_HEALTH_INTERVAL_SECONDS=0 \
UPDATE_RELEASE_BASE_URL="${FIXTURE_RELEASE_BASE_URL-https://release.fixture.invalid}" \
UPDATE_RELEASE_API_URL="${FIXTURE_RELEASE_API_URL-}" \
UPDATE_RELEASE_TOKEN="${FIXTURE_RELEASE_TOKEN-}" \
UPDATE_PUBLIC_KEY="$fixture/release-public.pem" \
UPDATE_REQUEST_FILE="$fixture/state/inbox/request.json" \
UPDATE_STATUS_FILE="$fixture/state/status.json" \
KAIDI_HEALTH_URL=http://127.0.0.1:19090/actuator/health \
KAIDI_APP_INDEX_URL=http://127.0.0.1:19090/ \
"$ROOT/deploy/update.sh"
}
assert_database_backup_opt_in_case() {
local fixture="$WORK/database-backup-opt-in"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
run_update "$fixture" success false mysqldump
find "$fixture/state/backups" -type f -name 'mysql-*.sql.gz' -print -quit | grep -q . \
|| fail 'explicit mysqldump mode did not create a database backup'
}
assert_private_gitea_release_case() {
local fixture="$WORK/private-gitea"
local version='1.0.0-preview.2'
local api_url='https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest'
local token='fixture-read-only-token'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
build_gitea_release_index "$fixture"
prepare_installation "$fixture" "$version"
FIXTURE_RELEASE_BASE_URL='' \
FIXTURE_RELEASE_API_URL="$api_url" \
FIXTURE_RELEASE_ORIGIN=https://gitea.fixture.invalid \
FIXTURE_RELEASE_TOKEN="$token" \
MOCK_CURL_LOG="$fixture/curl.log" \
run_update "$fixture" success
[ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \
|| fail 'private Gitea download did not persist READY'
! grep -Fq "$token" "$fixture/curl.log" \
|| fail 'private Gitea token leaked into curl process arguments'
[ "$(grep -c '^AUTH_FILE_OK$' "$fixture/curl.log")" -eq 4 ] \
|| fail 'private Gitea requests did not use the protected header file'
! grep -Fq -- '--location' "$fixture/curl.log" \
|| fail 'authenticated private Gitea requests unexpectedly enabled redirects'
}
assert_cross_origin_gitea_browser_url_ignored() {
local fixture="$WORK/cross-origin-gitea"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
build_gitea_release_index "$fixture" https://assets.fixture.invalid
prepare_installation "$fixture" "$version"
FIXTURE_RELEASE_BASE_URL='' \
FIXTURE_RELEASE_API_URL=https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest \
FIXTURE_RELEASE_ORIGIN=https://gitea.fixture.invalid \
FIXTURE_RELEASE_TOKEN=fixture-read-only-token \
MOCK_CURL_LOG="$fixture/curl.log" \
run_update "$fixture" success
[ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \
|| fail 'misconfigured browser download URL prevented trusted same-origin download'
! grep -Fq 'assets.fixture.invalid' "$fixture/curl.log" \
|| fail 'cross-origin browser download URL was requested'
}
assert_success_case() {
local fixture="$WORK/success"
local version='1.0.0-preview.2+build.7'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
run_update "$fixture" success
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \
|| fail 'success case did not activate the new application'
cmp -s "$fixture/app/bin/update.sh" "$fixture/app/current/ops/update.sh" \
|| fail 'success case did not activate the signed updater'
[ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \
|| fail 'success case did not persist SUCCEEDED'
[ ! -e "$fixture/state/processing/request.json" ] \
|| fail 'success case left a claimed request behind'
grep -qx 'daemon-reload' "$fixture/systemctl.log" || fail 'systemd units were not reloaded'
grep -qx 'restart kaidi-finance.service' "$fixture/systemctl.log" \
|| fail 'success case did not restart the application after the atomic switch'
! grep -qx 'stop kaidi-finance.service' "$fixture/systemctl.log" \
|| fail 'success case stopped the application before switching releases'
! grep -qi nginx "$fixture/systemctl.log" || fail 'updater unexpectedly managed Nginx'
[ "$(mode_of "$fixture/app")" = 750 ] || fail 'application root is not traversable by the service group'
[ "$(mode_of "$fixture/app/releases/$version")" = 750 ] || fail 'release root mode is not 0750'
[ "$(mode_of "$fixture/app/current/app.jar")" = 640 ] || fail 'release file mode is not 0640'
[ "$(mode_of "$fixture/app/current/ops/update.sh")" = 750 ] || fail 'release updater mode is not 0750'
}
assert_identical_systemd_operations_case() {
local fixture="$WORK/identical-systemd"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version" old
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
truncate -s 0 "$fixture/systemctl.log"
run_update "$fixture" success
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \
|| fail 'identical systemd case did not activate the new application'
! grep -qx 'daemon-reload' "$fixture/systemctl.log" \
|| fail 'identical systemd units triggered an unnecessary daemon reload'
}
assert_update_path_failure_keeps_application_case() {
local fixture="$WORK/path-watcher-failure"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
MOCK_UPDATE_PATH_START=fail run_update "$fixture" success
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \
|| fail 'path watcher failure rolled back a healthy application'
[ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \
|| fail 'path watcher failure did not preserve successful application state'
grep -Fq '自动更新监听器未能启动' "$fixture/state/status.json" \
|| fail 'path watcher failure did not preserve its diagnostic'
}
assert_rollback_case() {
local fixture="$WORK/rollback"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
if run_update "$fixture" fail-new > "$fixture/update.log" 2>&1; then
fail 'rollback case unexpectedly succeeded'
fi
grep -q '已恢复并验证旧版本' "$fixture/update.log" \
|| fail 'rollback case did not report a complete restoration'
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|| fail 'rollback case did not restore the previous application'
grep -qx 'old update.sh' "$fixture/app/bin/update.sh" \
|| fail 'rollback case did not restore the previous updater'
grep -qx 'old kaidi-update.path' "$fixture/systemd/kaidi-update.path" \
|| fail 'rollback case did not restore the previous path unit'
! grep -qx 'stop kaidi-finance.service' "$fixture/systemctl.log" \
|| fail 'rollback case stopped the application before restoring the previous release'
[ "$(grep -c '^restart kaidi-finance.service$' "$fixture/systemctl.log")" -ge 2 ] \
|| fail 'rollback case did not restart both the candidate and restored releases'
[ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \
|| fail 'rollback case did not persist FAILED'
[ ! -e "$fixture/app/releases/$version" ] \
|| fail 'rollback case left the failed release installed'
find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
|| fail 'rollback case did not archive the failed request'
}
assert_incomplete_rollback_requires_manual_recovery_case() {
local fixture="$WORK/incomplete-rollback"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
if run_update "$fixture" fail-after-first > "$fixture/update.log" 2>&1; then
fail 'incomplete rollback case unexpectedly succeeded'
fi
grep -Fq '需要人工恢复' "$fixture/update.log" \
|| fail 'incomplete rollback case did not require explicit recovery'
[ ! -e "$fixture/state/processing/request.json" ] \
|| fail 'incomplete rollback case left an automatically retriggered processing request'
[ ! -e "$fixture/state/transactions/active" ] \
|| fail 'incomplete rollback case left transaction evidence on the automatic recovery path'
[ -d "$fixture/state/transactions/recovery-required" ] \
|| fail 'incomplete rollback case did not quarantine transaction evidence'
[ "$(jq -r '.state' "$fixture/state/status.json")" = RECOVERY_REQUIRED ] \
|| fail 'incomplete rollback case did not lock the updater for recovery'
find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
|| fail 'incomplete rollback case did not archive its claimed request'
truncate -s 0 "$fixture/systemctl.log"
write_request "$fixture" "$version" DOWNLOAD
if run_update "$fixture" success > "$fixture/retry.log" 2>&1; then
fail 'recovery-locked updater accepted a new download request'
fi
[ "$(jq -r '.state' "$fixture/state/status.json")" = RECOVERY_REQUIRED ] \
|| fail 'recovery-locked updater replaced the manual recovery status'
! grep -Eq '^(start|restart|stop) kaidi-finance.service$' "$fixture/systemctl.log" \
|| fail 'recovery-locked download request changed the application service'
[ ! -e "$fixture/state/inbox/request.json" ] && [ ! -e "$fixture/state/processing/request.json" ] \
|| fail 'recovery-locked updater left a request on an automatic trigger path'
}
assert_download_failure_case() {
local fixture="$WORK/download-failure"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
if MOCK_DOWNLOAD_FAILURE=release-manifest.json run_update "$fixture" success > "$fixture/update.log" 2>&1; then
fail 'download failure case unexpectedly succeeded'
fi
[ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \
|| fail 'download failure did not persist FAILED'
[ ! -e "$fixture/state/processing/request.json" ] \
|| fail 'download failure left a busy processing request'
find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
|| fail 'download failure did not archive the request'
}
assert_database_failure_case() {
local fixture="$WORK/database-failure"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
if MOCK_MYSQLDUMP=fail run_update "$fixture" success false > "$fixture/update.log" 2>&1; then
fail 'database failure case unexpectedly succeeded'
fi
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|| fail 'database failure changed the active application'
find "$fixture/state/backups" -type f -name 'mysql-*.sql.gz' -print -quit | grep -q . \
&& fail 'database failure produced a trusted backup artifact'
[ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \
|| fail 'database failure did not persist FAILED'
}
assert_unhealthy_baseline_blocks_restart_case() {
local fixture="$WORK/unhealthy-baseline"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
truncate -s 0 "$fixture/systemctl.log"
if run_update "$fixture" fail > "$fixture/update.log" 2>&1; then
fail 'unhealthy baseline unexpectedly reached installation'
fi
grep -Fq '当前版本预检查失败' "$fixture/update.log" \
|| fail 'unhealthy baseline did not preserve its preflight diagnostic'
! grep -Eq '^(start|restart|stop) kaidi-finance.service$' "$fixture/systemctl.log" \
|| fail 'unhealthy baseline restarted the application'
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|| fail 'unhealthy baseline changed the active release'
[ ! -e "$fixture/state/transactions/active" ] \
|| fail 'unhealthy baseline created a switching transaction'
}
assert_invalid_database_url_blocks_restart_case() {
local fixture="$WORK/invalid-database-url"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
cat > "$fixture/runtime.env" <<'EOF'
DB_URL="jdbc:mysql://"
KAIDI_DB_HOST="127.0.0.1"
KAIDI_DB_PORT="3306"
KAIDI_DB_NAME="kaidi_finance"
KAIDI_DB_USERNAME="kaidi"
KAIDI_DB_PASSWORD="fixture"
EOF
truncate -s 0 "$fixture/systemctl.log"
if run_update "$fixture" success > "$fixture/update.log" 2>&1; then
fail 'invalid database URL unexpectedly reached installation'
fi
grep -Fq 'DB_URL 与主机、端口、库名不一致' "$fixture/update.log" \
|| fail 'structurally invalid JDBC URL did not preserve its diagnostic'
! grep -q '^restart kaidi-finance.service$' "$fixture/systemctl.log" \
|| fail 'invalid database URL restarted the application'
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|| fail 'invalid database URL changed the active release'
}
assert_symlink_request_rejected() {
local fixture="$WORK/symlink-request"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
printf '%s\n' 'operator-owned sentinel' > "$fixture/sentinel"
rm "$fixture/state/inbox/request.json"
ln -s "$fixture/sentinel" "$fixture/state/inbox/request.json"
if run_update "$fixture" success > "$fixture/update.log" 2>&1; then
fail 'symlink request case unexpectedly succeeded'
fi
grep -q '更新请求必须是普通文件' "$fixture/update.log" \
|| fail 'symlink request case did not report the unsafe request'
[ "$(cat "$fixture/sentinel")" = 'operator-owned sentinel' ] \
|| fail 'symlink request case changed the link target'
[ ! -e "$fixture/state/inbox/request.json" ] && [ ! -L "$fixture/state/inbox/request.json" ] \
|| fail 'symlink request case left the unsafe request in the inbox'
local actual_state
actual_state=$(jq -r '.state // "MISSING"' "$fixture/state/status.json")
[ "$actual_state" = FAILED ] \
|| fail "symlink request case persisted state $actual_state instead of FAILED"
}
assert_install_without_verified_cache_rejected() {
local fixture="$WORK/install-without-cache"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
write_request "$fixture" "$version" INSTALL
if run_update "$fixture" success > "$fixture/update.log" 2>&1; then
fail 'install without cache unexpectedly succeeded'
fi
grep -q '缺少已校验的发布缓存' "$fixture/update.log" \
|| fail 'install without cache did not report the missing verified cache'
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|| fail 'install without cache changed the active application'
}
printf '[update-fixture] success\n'
assert_success_case
printf '[update-fixture] identical-systemd-operations\n'
assert_identical_systemd_operations_case
printf '[update-fixture] path-watcher-failure\n'
assert_update_path_failure_keeps_application_case
printf '[update-fixture] rollback\n'
assert_rollback_case
printf '[update-fixture] incomplete-rollback-recovery-lock\n'
assert_incomplete_rollback_requires_manual_recovery_case
printf '[update-fixture] download-failure\n'
assert_download_failure_case
printf '[update-fixture] database-backup-failure\n'
assert_database_failure_case
printf '[update-fixture] database-backup-opt-in\n'
assert_database_backup_opt_in_case
printf '[update-fixture] unhealthy-baseline\n'
assert_unhealthy_baseline_blocks_restart_case
printf '[update-fixture] invalid-database-url\n'
assert_invalid_database_url_blocks_restart_case
printf '[update-fixture] symlink-request\n'
assert_symlink_request_rejected
printf '[update-fixture] missing-verified-cache\n'
assert_install_without_verified_cache_rejected
printf '[update-fixture] private-gitea\n'
assert_private_gitea_release_case
printf '[update-fixture] cross-origin-gitea\n'
assert_cross_origin_gitea_browser_url_ignored
printf 'Online update download, confirmation, success, rollback, failure, and unsafe-request fixtures passed\n'