748 lines
30 KiB
Bash
Executable File
748 lines
30 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
|
|
local request_id=01M00000000000000000000091
|
|
[ "$action" = INSTALL ] && request_id=01M00000000000000000000092
|
|
mkdir -p "$fixture/state/inbox"
|
|
jq -n --arg action "$action" --arg version "$version" --arg requestId "$request_id" \
|
|
'{action:$action,version:$version,reason:"fixture",requestId:$requestId,
|
|
requestedAt:"2026-08-19T00:00:00Z"}' > "$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'
|
|
[ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000091 ] \
|
|
&& [ "$(jq -r '.action' "$fixture/state/status.json")" = DOWNLOAD ] \
|
|
|| fail 'download phase did not preserve request correlation'
|
|
[ "$(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'
|
|
[ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000092 ] \
|
|
&& [ "$(jq -r '.action' "$fixture/state/status.json")" = INSTALL ] \
|
|
|| fail 'success case did not preserve install request correlation'
|
|
[ ! -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'
|
|
[ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000092 ] \
|
|
&& [ "$(jq -r '.action' "$fixture/state/status.json")" = INSTALL ] \
|
|
|| fail 'rollback case did not preserve install request correlation'
|
|
[ ! -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'
|
|
[ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000092 ] \
|
|
&& [ "$(jq -r '.action' "$fixture/state/status.json")" = INSTALL ] \
|
|
|| fail 'incomplete rollback case did not preserve install request correlation'
|
|
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'
|