forked from wangziqi/gongxue-base
443 lines
14 KiB
Bash
443 lines
14 KiB
Bash
#!/usr/bin/env bash
|
|
set -Eeuo pipefail
|
|
|
|
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
|
|
export GIT_TERMINAL_PROMPT=0
|
|
|
|
CONFIG_FILE="${CONFIG_FILE:-/etc/tiku-saas/deploy.env}"
|
|
|
|
if [[ ! -r "$CONFIG_FILE" ]]; then
|
|
echo "Missing deploy config: $CONFIG_FILE" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# shellcheck disable=SC1090
|
|
source "$CONFIG_FILE"
|
|
|
|
: "${GIT_REPO:?GIT_REPO is required}"
|
|
: "${GIT_BRANCH:=main}"
|
|
: "${APP_ROOT:=/opt/tiku-saas}"
|
|
: "${SOURCE_REPO_DIR:=$APP_ROOT/source}"
|
|
: "${REPO_DIR:=$APP_ROOT/repo}"
|
|
: "${RELEASES_DIR:=$APP_ROOT/releases}"
|
|
: "${CURRENT_LINK:=$APP_ROOT/current}"
|
|
: "${WWW_ROOT:=/srv/tiku-saas/www}"
|
|
: "${WWW_RELEASES_DIR:=${WWW_ROOT%/}-releases}"
|
|
: "${RUNTIME_CONFIG_DIR:=/etc/tiku-saas/runtime-config}"
|
|
: "${KEEP_RELEASES:=5}"
|
|
: "${RUN_SECURITY_CHECKS:=true}"
|
|
: "${RUN_RUNTIME_AUDIT:=true}"
|
|
: "${RUN_TARO_SUPPLY_CHAIN_AUDIT:=true}"
|
|
: "${RUN_LAUNCH_GATE:=true}"
|
|
: "${RUN_DB_MIGRATIONS:=false}"
|
|
: "${RUN_DB_READINESS:=true}"
|
|
: "${DATABASE_MIGRATION_URL:=}"
|
|
: "${DB_MIGRATION_COMMAND:=supabase db push --db-url \"\$DATABASE_MIGRATION_URL\"}"
|
|
: "${API_ENV_FILE:=/etc/tiku-saas/api.env}"
|
|
: "${RESTART_SERVICES:=true}"
|
|
: "${SYSTEMD_UNITS:=tiku-api.service tiku-workers.target}"
|
|
: "${HEALTHCHECK_URL:=http://127.0.0.1:8787/health}"
|
|
: "${HEALTHCHECK_TIMEOUT_SECONDS:=60}"
|
|
: "${HEALTHCHECK_INTERVAL_SECONDS:=2}"
|
|
: "${NPM_REGISTRY:=https://registry.npmjs.org/}"
|
|
: "${NPM_AUDIT_REGISTRY:=https://registry.npmjs.org/}"
|
|
: "${NPM_FETCH_RETRIES:=5}"
|
|
: "${NPM_FETCH_RETRY_MINTIMEOUT:=20000}"
|
|
: "${NPM_FETCH_RETRY_MAXTIMEOUT:=120000}"
|
|
: "${NPM_FETCH_TIMEOUT:=300000}"
|
|
|
|
export DATABASE_MIGRATION_URL
|
|
|
|
LOCK_DIR="${LOCK_DIR:-$APP_ROOT/.deploy.lock}"
|
|
LOCK_ACQUIRED=false
|
|
ASKPASS_FILE=""
|
|
CANDIDATE_RELEASE=""
|
|
PREVIOUS_APP_RELEASE=""
|
|
PREVIOUS_WWW_RELEASE=""
|
|
BOOTSTRAP_SERVICE_BACKUP=""
|
|
ROLLBACK_ARMED=false
|
|
APP_SWITCHED=false
|
|
SERVICE_SYNC_STARTED=false
|
|
WWW_SWITCH_STARTED=false
|
|
|
|
log() {
|
|
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"
|
|
}
|
|
|
|
die() {
|
|
printf '[deploy][error] %s\n' "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
truthy() {
|
|
case "${1:-}" in
|
|
1|true|TRUE|yes|YES|y|Y|on|ON) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
require_command() {
|
|
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
|
|
}
|
|
|
|
run_shell() {
|
|
log "+ $*"
|
|
bash -lc "$*"
|
|
}
|
|
|
|
load_runtime_env() {
|
|
[[ -r "$API_ENV_FILE" ]] || die "Missing API runtime config: $API_ENV_FILE"
|
|
set -a
|
|
# shellcheck disable=SC1090
|
|
source "$API_ENV_FILE"
|
|
set +a
|
|
}
|
|
|
|
atomic_symlink() {
|
|
local target="$1"
|
|
local link="$2"
|
|
local next_link="${link}.next.$$"
|
|
|
|
rm -f "$next_link"
|
|
ln -s "$target" "$next_link"
|
|
if [[ -L "$link" || ! -e "$link" ]]; then
|
|
mv -Tf "$next_link" "$link"
|
|
return 0
|
|
fi
|
|
rm -f "$next_link"
|
|
return 1
|
|
}
|
|
|
|
restart_services() {
|
|
truthy "$RESTART_SERVICES" || die "Production deployment requires RESTART_SERVICES=true"
|
|
[[ -n "$SYSTEMD_UNITS" ]] || die "SYSTEMD_UNITS is required"
|
|
|
|
local unit
|
|
for unit in $SYSTEMD_UNITS; do
|
|
systemctl restart "$unit" || return 1
|
|
done
|
|
for unit in $SYSTEMD_UNITS; do
|
|
systemctl is-active --quiet "$unit" || return 1
|
|
done
|
|
}
|
|
|
|
healthcheck() {
|
|
[[ -n "$HEALTHCHECK_URL" ]] || die "Production deployment requires HEALTHCHECK_URL"
|
|
local deadline=$((SECONDS + HEALTHCHECK_TIMEOUT_SECONDS))
|
|
|
|
log "Waiting for API healthcheck: $HEALTHCHECK_URL"
|
|
while (( SECONDS < deadline )); do
|
|
if curl -fsS --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then
|
|
log "API healthcheck passed."
|
|
return 0
|
|
fi
|
|
sleep "$HEALTHCHECK_INTERVAL_SECONDS"
|
|
done
|
|
return 1
|
|
}
|
|
|
|
sync_service_repo() {
|
|
local release="$1"
|
|
[[ -d "$release" ]] || return 1
|
|
if [[ -L "$REPO_DIR" ]]; then
|
|
[[ "$(readlink -f "$REPO_DIR")" == "$(readlink -f "$release")" ]] \
|
|
|| die "REPO_DIR symlink must resolve to the selected current release"
|
|
return 0
|
|
fi
|
|
mkdir -p "$REPO_DIR"
|
|
rsync -a --delete --exclude .git "$release/" "$REPO_DIR/"
|
|
}
|
|
|
|
rollback() {
|
|
local failed=false
|
|
ROLLBACK_ARMED=false
|
|
|
|
if [[ "$WWW_SWITCH_STARTED" == "true" && -n "$PREVIOUS_WWW_RELEASE" ]]; then
|
|
log "Rolling Web root back to $PREVIOUS_WWW_RELEASE"
|
|
atomic_symlink "$PREVIOUS_WWW_RELEASE" "$WWW_ROOT" || failed=true
|
|
elif [[ "$WWW_SWITCH_STARTED" == "true" ]]; then
|
|
rm -f "$WWW_ROOT" || failed=true
|
|
fi
|
|
|
|
if [[ "$APP_SWITCHED" == "true" && -n "$PREVIOUS_APP_RELEASE" ]]; then
|
|
log "Rolling application current link back to $PREVIOUS_APP_RELEASE"
|
|
atomic_symlink "$PREVIOUS_APP_RELEASE" "$CURRENT_LINK" || failed=true
|
|
elif [[ "$APP_SWITCHED" == "true" ]]; then
|
|
rm -f "$CURRENT_LINK" || failed=true
|
|
fi
|
|
|
|
if [[ "$SERVICE_SYNC_STARTED" == "true" && -n "$PREVIOUS_APP_RELEASE" ]]; then
|
|
sync_service_repo "$PREVIOUS_APP_RELEASE" || failed=true
|
|
elif [[ "$SERVICE_SYNC_STARTED" == "true" && -n "$BOOTSTRAP_SERVICE_BACKUP" ]]; then
|
|
log "Restoring pre-release service runtime"
|
|
sync_service_repo "$BOOTSTRAP_SERVICE_BACKUP" || failed=true
|
|
fi
|
|
|
|
if [[ "$SERVICE_SYNC_STARTED" == "true" ]]; then
|
|
restart_services || failed=true
|
|
healthcheck || failed=true
|
|
fi
|
|
[[ "$failed" == "false" ]]
|
|
}
|
|
|
|
cleanup() {
|
|
local status=$?
|
|
if [[ "$status" -ne 0 && "$ROLLBACK_ARMED" == "true" ]]; then
|
|
rollback || true
|
|
fi
|
|
if [[ -n "$ASKPASS_FILE" && -f "$ASKPASS_FILE" ]]; then
|
|
rm -f "$ASKPASS_FILE"
|
|
fi
|
|
if [[ "$LOCK_ACQUIRED" == "true" && -d "$LOCK_DIR" ]]; then
|
|
rmdir "$LOCK_DIR" 2>/dev/null || true
|
|
fi
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
prepare_git_auth() {
|
|
if [[ -n "${GIT_SSH_COMMAND:-}" ]]; then
|
|
export GIT_SSH_COMMAND
|
|
return
|
|
fi
|
|
if [[ -z "${GIT_USERNAME:-}" || -z "${GITEA_TOKEN:-}" ]]; then
|
|
return
|
|
fi
|
|
|
|
export GIT_USERNAME GITEA_TOKEN
|
|
ASKPASS_FILE="$(mktemp)"
|
|
chmod 700 "$ASKPASS_FILE"
|
|
cat > "$ASKPASS_FILE" <<'ASKPASS'
|
|
#!/usr/bin/env bash
|
|
case "$1" in
|
|
*Username*) printf '%s\n' "$GIT_USERNAME" ;;
|
|
*Password*) printf '%s\n' "$GITEA_TOKEN" ;;
|
|
*) printf '\n' ;;
|
|
esac
|
|
ASKPASS
|
|
export GIT_ASKPASS="$ASKPASS_FILE"
|
|
export GIT_TERMINAL_PROMPT=0
|
|
}
|
|
|
|
install_runtime_config() {
|
|
local source_file="$1"
|
|
local target_dir="$2"
|
|
[[ -r "$source_file" ]] || die "Missing runtime config: $source_file"
|
|
install -m 0644 "$source_file" "$target_dir/runtime-config.json"
|
|
}
|
|
|
|
stage_candidate() {
|
|
local release_name="$1"
|
|
local staging="$RELEASES_DIR/.tmp-$release_name"
|
|
|
|
rm -rf "$staging"
|
|
mkdir -p "$staging"
|
|
rsync -a --delete --exclude .git --exclude node_modules "$SOURCE_REPO_DIR/" "$staging/"
|
|
if ! cp -al "$SOURCE_REPO_DIR/node_modules" "$staging/node_modules"; then
|
|
rm -rf "$staging/node_modules"
|
|
rsync -a "$SOURCE_REPO_DIR/node_modules/" "$staging/node_modules/"
|
|
fi
|
|
CANDIDATE_RELEASE="$RELEASES_DIR/$release_name"
|
|
rm -rf "$CANDIDATE_RELEASE"
|
|
mv "$staging" "$CANDIDATE_RELEASE"
|
|
}
|
|
|
|
build_and_validate_candidate() {
|
|
cd "$CANDIDATE_RELEASE"
|
|
|
|
if truthy "$RUN_SECURITY_CHECKS"; then
|
|
log "Running repository security scan against the candidate..."
|
|
GIT_DIR="$SOURCE_REPO_DIR/.git" GIT_WORK_TREE="$CANDIDATE_RELEASE" npm run security:repo
|
|
fi
|
|
|
|
log "Building API, worker and H5 portals..."
|
|
npm run build:api
|
|
npm run build:worker
|
|
npm run check:taro
|
|
if truthy "$RUN_TARO_SUPPLY_CHAIN_AUDIT"; then
|
|
npm run audit:taro:supply-chain
|
|
fi
|
|
npm run build:taro:h5:student
|
|
npm run build:taro:h5:tenant
|
|
npm run build:taro:h5:platform
|
|
|
|
log "Installing candidate H5 runtime configs..."
|
|
install_runtime_config "$RUNTIME_CONFIG_DIR/h5-student.runtime-config.json" "apps/taro/dist/h5-student"
|
|
install_runtime_config "$RUNTIME_CONFIG_DIR/h5-tenant-admin.runtime-config.json" "apps/taro/dist/h5-tenant-admin"
|
|
install_runtime_config "$RUNTIME_CONFIG_DIR/h5-platform-admin.runtime-config.json" "apps/taro/dist/h5-platform-admin"
|
|
|
|
log "Validating candidate H5 artifacts before touching $WWW_ROOT..."
|
|
node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config
|
|
npm run manifest:taro:h5 -- --require-dist --require-runtime-config
|
|
npm run smoke:taro:h5
|
|
TARO_H5_INTERACTION_OUTPUT_DIR="${TARO_H5_INTERACTION_OUTPUT_DIR:-/tmp/tiku-h5-smoke}" npm run smoke:taro:h5:interaction
|
|
|
|
if truthy "$RUN_RUNTIME_AUDIT"; then
|
|
NPM_AUDIT_REGISTRY="$NPM_AUDIT_REGISTRY" npm run audit:runtime
|
|
fi
|
|
|
|
load_runtime_env
|
|
log "Running production environment readiness before the database migration step..."
|
|
npm run readiness:production
|
|
|
|
if truthy "$RUN_DB_MIGRATIONS"; then
|
|
log "Applying Supabase database migrations..."
|
|
run_shell "$DB_MIGRATION_COMMAND"
|
|
fi
|
|
|
|
if truthy "$RUN_DB_READINESS"; then
|
|
log "Running production database readiness after the optional migration step..."
|
|
npm run readiness:production:db
|
|
fi
|
|
|
|
if truthy "$RUN_LAUNCH_GATE"; then
|
|
: "${PRODUCTION_LAUNCH_EVIDENCE:?PRODUCTION_LAUNCH_EVIDENCE is required when RUN_LAUNCH_GATE=true}"
|
|
log "Running production launch gate against the candidate..."
|
|
DEPLOY_COMMIT_SHA="$(git -C "$SOURCE_REPO_DIR" rev-parse HEAD)" \
|
|
DEPLOY_RELEASE_ROOT="$CANDIDATE_RELEASE" \
|
|
npm run launch:gate -- --evidence "$PRODUCTION_LAUNCH_EVIDENCE"
|
|
fi
|
|
}
|
|
|
|
verify_live_h5_release() {
|
|
truthy "$RUN_LAUNCH_GATE" || return 0
|
|
: "${PRODUCTION_LAUNCH_EVIDENCE:?PRODUCTION_LAUNCH_EVIDENCE is required when RUN_LAUNCH_GATE=true}"
|
|
|
|
log "Verifying the activated H5 release against production URLs..."
|
|
(
|
|
cd "$CANDIDATE_RELEASE"
|
|
DEPLOY_COMMIT_SHA="$(git -C "$SOURCE_REPO_DIR" rev-parse HEAD)" \
|
|
DEPLOY_RELEASE_ROOT="$CANDIDATE_RELEASE" \
|
|
npm run launch:gate -- --evidence "$PRODUCTION_LAUNCH_EVIDENCE" --verify-live-h5
|
|
)
|
|
}
|
|
|
|
stage_www_candidate() {
|
|
local release_name="$1"
|
|
local staging="$WWW_RELEASES_DIR/.tmp-$release_name"
|
|
local final="$WWW_RELEASES_DIR/$release_name"
|
|
|
|
rm -rf "$staging"
|
|
mkdir -p "$staging/student" "$staging/tenant-admin" "$staging/platform-admin"
|
|
rsync -a --delete --copy-links "$CANDIDATE_RELEASE/apps/taro/dist/h5-student/" "$staging/student/"
|
|
rsync -a --delete --copy-links "$CANDIDATE_RELEASE/apps/taro/dist/h5-tenant-admin/" "$staging/tenant-admin/"
|
|
rsync -a --delete --copy-links "$CANDIDATE_RELEASE/apps/taro/dist/h5-platform-admin/" "$staging/platform-admin/"
|
|
rm -rf "$final"
|
|
mv "$staging" "$final"
|
|
printf '%s\n' "$final"
|
|
}
|
|
|
|
switch_www_release() {
|
|
local candidate_www="$1"
|
|
WWW_SWITCH_STARTED=true
|
|
|
|
if [[ -L "$WWW_ROOT" ]]; then
|
|
PREVIOUS_WWW_RELEASE="$(readlink -f "$WWW_ROOT")"
|
|
elif [[ -d "$WWW_ROOT" ]]; then
|
|
PREVIOUS_WWW_RELEASE="$WWW_RELEASES_DIR/bootstrap-www-$(date +%Y%m%d%H%M%S)"
|
|
mv "$WWW_ROOT" "$PREVIOUS_WWW_RELEASE"
|
|
elif [[ -e "$WWW_ROOT" ]]; then
|
|
die "WWW_ROOT exists but is not a directory or symlink: $WWW_ROOT"
|
|
fi
|
|
atomic_symlink "$candidate_www" "$WWW_ROOT"
|
|
}
|
|
|
|
prune_releases() {
|
|
local directory="$1"
|
|
[[ "$KEEP_RELEASES" =~ ^[0-9]+$ ]] || return 0
|
|
(( KEEP_RELEASES > 0 )) || return 0
|
|
|
|
find "$directory" -mindepth 1 -maxdepth 1 -type d ! -name '.*' ! -name 'bootstrap-*' -print \
|
|
| sort -r \
|
|
| tail -n +"$((KEEP_RELEASES + 1))" \
|
|
| while IFS= read -r old_release; do
|
|
[[ "$old_release" == "$CANDIDATE_RELEASE" || "$old_release" == "$PREVIOUS_APP_RELEASE" || "$old_release" == "$PREVIOUS_WWW_RELEASE" ]] && continue
|
|
rm -rf "$old_release"
|
|
done
|
|
}
|
|
|
|
main() {
|
|
require_command git
|
|
require_command npm
|
|
require_command rsync
|
|
require_command curl
|
|
[[ "$WWW_RELEASES_DIR" != "$WWW_ROOT" ]] || die "WWW_RELEASES_DIR must be outside WWW_ROOT"
|
|
case "${WWW_RELEASES_DIR%/}/" in
|
|
"${WWW_ROOT%/}/"*) die "WWW_RELEASES_DIR must not be nested under WWW_ROOT" ;;
|
|
esac
|
|
[[ "$SOURCE_REPO_DIR" != "$REPO_DIR" ]] || die "SOURCE_REPO_DIR must be separate from the systemd REPO_DIR"
|
|
truthy "$RESTART_SERVICES" || die "Production deployment requires RESTART_SERVICES=true"
|
|
[[ -n "$HEALTHCHECK_URL" ]] || die "Production deployment requires HEALTHCHECK_URL"
|
|
truthy "$RUN_LAUNCH_GATE" || die "Production deployment requires RUN_LAUNCH_GATE=true"
|
|
truthy "$RUN_TARO_SUPPLY_CHAIN_AUDIT" \
|
|
|| die "Production deployment requires RUN_TARO_SUPPLY_CHAIN_AUDIT=true"
|
|
truthy "$RUN_DB_READINESS" \
|
|
|| die "Production deployment requires RUN_DB_READINESS=true before launch gate"
|
|
if truthy "$RUN_DB_MIGRATIONS"; then
|
|
[[ -n "$DATABASE_MIGRATION_URL" ]] \
|
|
|| die "Production database migrations require a separate DATABASE_MIGRATION_URL"
|
|
fi
|
|
|
|
mkdir -p "$APP_ROOT" "$RELEASES_DIR" "$WWW_RELEASES_DIR"
|
|
mkdir "$LOCK_DIR" 2>/dev/null || die "Another deployment is already running: $LOCK_DIR"
|
|
LOCK_ACQUIRED=true
|
|
prepare_git_auth
|
|
|
|
if [[ ! -d "$SOURCE_REPO_DIR/.git" ]]; then
|
|
log "Cloning source repository..."
|
|
git clone --branch "$GIT_BRANCH" "$GIT_REPO" "$SOURCE_REPO_DIR"
|
|
fi
|
|
|
|
log "Fetching $GIT_BRANCH..."
|
|
if [[ -n "$(git -C "$SOURCE_REPO_DIR" status --porcelain --untracked-files=normal)" ]]; then
|
|
die "Source repository has local changes; refusing to deploy."
|
|
fi
|
|
git -C "$SOURCE_REPO_DIR" fetch origin "$GIT_BRANCH" --prune
|
|
git -C "$SOURCE_REPO_DIR" checkout "$GIT_BRANCH"
|
|
git -C "$SOURCE_REPO_DIR" merge --ff-only "origin/$GIT_BRANCH"
|
|
|
|
local commit release_name
|
|
commit="$(git -C "$SOURCE_REPO_DIR" rev-parse --short=12 HEAD)"
|
|
release_name="$(date +%Y%m%d%H%M%S)-$commit"
|
|
log "Preparing candidate commit $commit"
|
|
|
|
log "Installing locked dependencies in the source checkout..."
|
|
npm --prefix "$SOURCE_REPO_DIR" ci \
|
|
--workspaces \
|
|
--include-workspace-root \
|
|
--include=dev \
|
|
--registry="$NPM_REGISTRY" \
|
|
--fetch-retries="$NPM_FETCH_RETRIES" \
|
|
--fetch-retry-mintimeout="$NPM_FETCH_RETRY_MINTIMEOUT" \
|
|
--fetch-retry-maxtimeout="$NPM_FETCH_RETRY_MAXTIMEOUT" \
|
|
--fetch-timeout="$NPM_FETCH_TIMEOUT"
|
|
|
|
stage_candidate "$release_name"
|
|
build_and_validate_candidate
|
|
local candidate_www
|
|
candidate_www="$(stage_www_candidate "$release_name")"
|
|
|
|
if [[ -L "$CURRENT_LINK" ]]; then
|
|
PREVIOUS_APP_RELEASE="$(readlink -f "$CURRENT_LINK")"
|
|
elif [[ -d "$REPO_DIR" ]]; then
|
|
BOOTSTRAP_SERVICE_BACKUP="$RELEASES_DIR/bootstrap-service-$(date +%Y%m%d%H%M%S)"
|
|
mkdir -p "$BOOTSTRAP_SERVICE_BACKUP"
|
|
rsync -a --delete --exclude .git "$REPO_DIR/" "$BOOTSTRAP_SERVICE_BACKUP/"
|
|
fi
|
|
ROLLBACK_ARMED=true
|
|
atomic_symlink "$CANDIDATE_RELEASE" "$CURRENT_LINK" || die "Failed to switch application current release"
|
|
APP_SWITCHED=true
|
|
SERVICE_SYNC_STARTED=true
|
|
sync_service_repo "$CANDIDATE_RELEASE"
|
|
restart_services
|
|
healthcheck
|
|
switch_www_release "$candidate_www" || die "Failed to switch Web release"
|
|
verify_live_h5_release
|
|
ROLLBACK_ARMED=false
|
|
|
|
prune_releases "$RELEASES_DIR"
|
|
prune_releases "$WWW_RELEASES_DIR"
|
|
log "Deployment finished: $commit"
|
|
}
|
|
|
|
main "$@"
|