forked from wangziqi/gongxue-base
687 lines
20 KiB
Bash
Executable File
687 lines
20 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -Eeuo pipefail
|
|
|
|
# Safe release-style deploy script for tiku-supabase.
|
|
#
|
|
# Server layout:
|
|
# DEPLOY_ROOT/
|
|
# current -> releases/<timestamp>-<commit>
|
|
# releases/
|
|
# shared/
|
|
# deploy.env # deploy settings, not committed
|
|
# .env # API/worker production env, not committed
|
|
# h5-student.runtime-config.json
|
|
# h5-tenant-admin.runtime-config.json
|
|
# h5-platform-admin.runtime-config.json
|
|
#
|
|
# Recommended first server run:
|
|
# mkdir -p /opt/tiku-saas/shared
|
|
# cp deploy.env.example /opt/tiku-saas/shared/deploy.env
|
|
# vim /opt/tiku-saas/shared/deploy.env
|
|
# vim /opt/tiku-saas/shared/.env
|
|
# bash deploy.sh
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
log() {
|
|
printf '[deploy] %s\n' "$*"
|
|
}
|
|
|
|
warn() {
|
|
printf '[deploy][warn] %s\n' "$*" >&2
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
run() {
|
|
log "+ $*"
|
|
"$@"
|
|
}
|
|
|
|
run_shell() {
|
|
log "+ $*"
|
|
bash -lc "$*"
|
|
}
|
|
|
|
source_if_exists() {
|
|
local file="$1"
|
|
if [[ -f "$file" ]]; then
|
|
# shellcheck source=/dev/null
|
|
source "$file"
|
|
fi
|
|
}
|
|
|
|
export_dotenv_if_exists() {
|
|
local file="$1"
|
|
local line key value
|
|
|
|
[[ -f "$file" ]] || return 0
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
line="${line%$'\r'}"
|
|
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
|
|
line="${line#export }"
|
|
[[ "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || continue
|
|
|
|
key="${line%%=*}"
|
|
value="${line#*=}"
|
|
if [[ "$value" == \"*\" && "$value" == *\" ]]; then
|
|
value="${value:1:${#value}-2}"
|
|
elif [[ "$value" == \'*\' && "$value" == *\' ]]; then
|
|
value="${value:1:${#value}-2}"
|
|
fi
|
|
|
|
if [[ -z "${!key+x}" ]]; then
|
|
export "$key=$value"
|
|
fi
|
|
done < "$file"
|
|
}
|
|
|
|
if [[ -n "${DEPLOY_CONFIG:-}" ]]; then
|
|
source_if_exists "$DEPLOY_CONFIG"
|
|
else
|
|
source_if_exists "$SCRIPT_DIR/deploy.env"
|
|
source_if_exists "$SCRIPT_DIR/.deploy.env"
|
|
fi
|
|
|
|
: "${APP_NAME:=tiku-supabase}"
|
|
: "${REPO_URL:=https://git.gongxue100.com/chenhaogxjy/tiku-supabase.git}"
|
|
: "${BRANCH:=main}"
|
|
: "${DEPLOY_ROOT:=/opt/tiku-saas}"
|
|
|
|
source_if_exists "$DEPLOY_ROOT/shared/deploy.env"
|
|
|
|
: "${RELEASES_DIR:=$DEPLOY_ROOT/releases}"
|
|
: "${SHARED_DIR:=$DEPLOY_ROOT/shared}"
|
|
: "${CURRENT_LINK:=$DEPLOY_ROOT/current}"
|
|
: "${KEEP_RELEASES:=5}"
|
|
: "${GIT_DEPTH:=1}"
|
|
: "${NODE_ENV:=production}"
|
|
: "${NPM_INSTALL_COMMAND:=npm ci --workspaces --include-workspace-root --include=dev}"
|
|
: "${NPM_AUDIT_REGISTRY:=https://registry.npmjs.org/}"
|
|
: "${RUN_CHECKS:=true}"
|
|
: "${CHECK_COMMANDS:=npm run check:api
|
|
npm run check:worker
|
|
npm run check:taro}"
|
|
: "${RUN_API_BUILD:=true}"
|
|
: "${RUN_WORKER_BUILD:=true}"
|
|
: "${RUN_TARO_H5_BUILD:=true}"
|
|
: "${RUN_SECURITY_REPO_SCAN:=true}"
|
|
: "${RUN_RUNTIME_AUDIT:=true}"
|
|
: "${RUN_TARO_SUPPLY_CHAIN_AUDIT:=true}"
|
|
: "${RUN_PRODUCTION_READINESS:=auto}"
|
|
: "${RUN_DB_READINESS:=true}"
|
|
: "${RUN_DB_MIGRATIONS:=false}"
|
|
: "${DATABASE_MIGRATION_URL:=}"
|
|
: "${DB_MIGRATION_COMMAND:=supabase db push --db-url \"\$DATABASE_MIGRATION_URL\"}"
|
|
: "${STRICT_H5_RUNTIME_CONFIG:=true}"
|
|
: "${RUN_H5_SMOKE:=true}"
|
|
: "${RUN_LAUNCH_GATE:=true}"
|
|
: "${PRODUCTION_LAUNCH_EVIDENCE:=$SHARED_DIR/production-launch-evidence.json}"
|
|
: "${H5_STUDENT_RUNTIME_CONFIG:=$SHARED_DIR/h5-student.runtime-config.json}"
|
|
: "${H5_TENANT_RUNTIME_CONFIG:=$SHARED_DIR/h5-tenant-admin.runtime-config.json}"
|
|
: "${H5_PLATFORM_RUNTIME_CONFIG:=$SHARED_DIR/h5-platform-admin.runtime-config.json}"
|
|
: "${WWW_ROOT:=/srv/tiku-saas/www}"
|
|
: "${WWW_RELEASES_DIR:=${WWW_ROOT%/}-releases}"
|
|
: "${WWW_CURRENT_LINK:=$WWW_ROOT}"
|
|
: "${SERVICE_REPO_DIR:=/opt/tiku-saas/repo}"
|
|
: "${SYNC_SERVICE_REPO:=true}"
|
|
: "${SERVICE_MODE:=systemd}"
|
|
: "${SYSTEMD_UNITS:=tiku-api.service tiku-workers.target}"
|
|
: "${PM2_ECOSYSTEM:=}"
|
|
: "${PM2_PROCESS_NAMES:=}"
|
|
: "${COMPOSE_FILE:=}"
|
|
: "${RESTART_COMMAND:=}"
|
|
: "${HEALTHCHECK_URL:=http://127.0.0.1:8787/health}"
|
|
: "${HEALTHCHECK_TIMEOUT_SECONDS:=60}"
|
|
: "${HEALTHCHECK_INTERVAL_SECONDS:=2}"
|
|
: "${GIT_TERMINAL_PROMPT:=0}"
|
|
export GIT_TERMINAL_PROMPT
|
|
export DATABASE_MIGRATION_URL
|
|
|
|
LOCK_DIR="$DEPLOY_ROOT/.deploy.lock"
|
|
LOCK_ACQUIRED=false
|
|
PREVIOUS_RELEASE=""
|
|
NEW_RELEASE=""
|
|
PREVIOUS_WWW_RELEASE=""
|
|
NEW_WWW_RELEASE=""
|
|
PREVIOUS_SERVICE_BACKUP=""
|
|
ROLLBACK_ARMED=false
|
|
APP_SWITCHED=false
|
|
SERVICE_SYNC_STARTED=false
|
|
WWW_SWITCH_STARTED=false
|
|
ASKPASS_FILE=""
|
|
|
|
cleanup() {
|
|
local status=$?
|
|
if [[ "$status" -ne 0 && "$ROLLBACK_ARMED" == "true" ]]; then
|
|
ROLLBACK_ARMED=false
|
|
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_askpass() {
|
|
if [[ -z "${GIT_TOKEN:-}" && -z "${GITEA_TOKEN:-}" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
local token="${GIT_TOKEN:-${GITEA_TOKEN:-}}"
|
|
local username="${GIT_USERNAME:-oauth2}"
|
|
|
|
ASKPASS_FILE="$(mktemp "${TMPDIR:-/tmp}/tiku-git-askpass.XXXXXX")"
|
|
chmod 700 "$ASKPASS_FILE"
|
|
cat > "$ASKPASS_FILE" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
case "$1" in
|
|
*Username*) printf '%s\n' "$GIT_ASKPASS_USERNAME" ;;
|
|
*Password*) printf '%s\n' "$GIT_ASKPASS_TOKEN" ;;
|
|
*) printf '%s\n' "$GIT_ASKPASS_TOKEN" ;;
|
|
esac
|
|
EOF
|
|
export GIT_ASKPASS="$ASKPASS_FILE"
|
|
export GIT_ASKPASS_USERNAME="$username"
|
|
export GIT_ASKPASS_TOKEN="$token"
|
|
}
|
|
|
|
require_command() {
|
|
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
|
|
}
|
|
|
|
validate_deploy_contract() {
|
|
[[ "$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
|
|
[[ "$SERVICE_REPO_DIR" != "$RELEASES_DIR" ]] || die "SERVICE_REPO_DIR must not equal RELEASES_DIR"
|
|
|
|
if [[ "$NODE_ENV" != "production" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
truthy "$RUN_TARO_H5_BUILD" || die "Production deployment requires RUN_TARO_H5_BUILD=true"
|
|
truthy "$RUN_TARO_SUPPLY_CHAIN_AUDIT" \
|
|
|| die "Production deployment requires RUN_TARO_SUPPLY_CHAIN_AUDIT=true"
|
|
truthy "$STRICT_H5_RUNTIME_CONFIG" || die "Production deployment requires STRICT_H5_RUNTIME_CONFIG=true"
|
|
truthy "$RUN_H5_SMOKE" || die "Production deployment requires RUN_H5_SMOKE=true"
|
|
truthy "$RUN_LAUNCH_GATE" || die "Production deployment requires RUN_LAUNCH_GATE=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
|
|
[[ -n "$RESTART_COMMAND" || "$SERVICE_MODE" != "none" ]] \
|
|
|| die "Production deployment requires a service restart strategy"
|
|
[[ -n "$HEALTHCHECK_URL" ]] || die "Production deployment requires HEALTHCHECK_URL"
|
|
|
|
if [[ "$SERVICE_MODE" == "systemd" ]]; then
|
|
truthy "$SYNC_SERVICE_REPO" || die "Production systemd deployment requires SYNC_SERVICE_REPO=true"
|
|
[[ -n "$SERVICE_REPO_DIR" ]] || die "Production systemd deployment requires SERVICE_REPO_DIR"
|
|
fi
|
|
}
|
|
|
|
acquire_lock() {
|
|
mkdir -p "$DEPLOY_ROOT"
|
|
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
|
|
die "Another deployment appears to be running: $LOCK_DIR"
|
|
fi
|
|
LOCK_ACQUIRED=true
|
|
}
|
|
|
|
link_shared_env() {
|
|
local release="$1"
|
|
local env_file="$SHARED_DIR/.env"
|
|
if [[ -f "$env_file" ]]; then
|
|
ln -sfn "$env_file" "$release/.env"
|
|
else
|
|
warn "No $env_file found. Production readiness and runtime may fail until it exists."
|
|
fi
|
|
}
|
|
|
|
load_runtime_env() {
|
|
local env_file="$SHARED_DIR/.env"
|
|
[[ -r "$env_file" ]] || die "Missing API runtime config: $env_file"
|
|
export_dotenv_if_exists "$env_file"
|
|
}
|
|
|
|
link_runtime_config() {
|
|
local source_file="$1"
|
|
local target_dir="$2"
|
|
local label="$3"
|
|
|
|
if [[ ! -d "$target_dir" ]]; then
|
|
warn "H5 dist directory missing for $label: $target_dir"
|
|
return 0
|
|
fi
|
|
|
|
if [[ -f "$source_file" ]]; then
|
|
ln -sfn "$source_file" "$target_dir/runtime-config.json"
|
|
elif truthy "$STRICT_H5_RUNTIME_CONFIG"; then
|
|
die "Missing $label runtime config: $source_file"
|
|
else
|
|
warn "Missing $label runtime config: $source_file"
|
|
fi
|
|
}
|
|
|
|
link_h5_runtime_configs() {
|
|
local release="$1"
|
|
link_runtime_config "$H5_STUDENT_RUNTIME_CONFIG" "$release/apps/taro/dist/h5-student" "student"
|
|
link_runtime_config "$H5_TENANT_RUNTIME_CONFIG" "$release/apps/taro/dist/h5-tenant-admin" "tenant-admin"
|
|
link_runtime_config "$H5_PLATFORM_RUNTIME_CONFIG" "$release/apps/taro/dist/h5-platform-admin" "platform-admin"
|
|
}
|
|
|
|
should_run_production_readiness() {
|
|
case "$RUN_PRODUCTION_READINESS" in
|
|
true|TRUE|1|yes|YES|on|ON) return 0 ;;
|
|
false|FALSE|0|no|NO|off|OFF) return 1 ;;
|
|
auto)
|
|
if [[ "$NODE_ENV" == "production" ]]; then
|
|
return 0
|
|
fi
|
|
if [[ -f "$SHARED_DIR/.env" ]] && grep -Eq '^NODE_ENV=production($|[[:space:]]*)' "$SHARED_DIR/.env"; then
|
|
return 0
|
|
fi
|
|
return 1
|
|
;;
|
|
*) die "RUN_PRODUCTION_READINESS must be true, false, or auto" ;;
|
|
esac
|
|
}
|
|
|
|
run_build_and_checks() {
|
|
local release="$1"
|
|
cd "$release"
|
|
|
|
[[ "$NPM_INSTALL_COMMAND" == *"npm ci"* ]] \
|
|
|| die "NPM_INSTALL_COMMAND must use npm ci for a locked production install"
|
|
[[ "$NPM_INSTALL_COMMAND" == *"--include=dev"* ]] \
|
|
|| die "NPM_INSTALL_COMMAND must include Taro build dependencies with --include=dev"
|
|
[[ "$NPM_INSTALL_COMMAND" != *"--ignore-scripts"* ]] \
|
|
|| die "NPM_INSTALL_COMMAND must allow the reviewed Taro workspace postinstall patches"
|
|
run_shell "$NPM_INSTALL_COMMAND"
|
|
|
|
if truthy "$RUN_CHECKS"; then
|
|
while IFS= read -r command_line; do
|
|
[[ -z "$command_line" ]] && continue
|
|
run_shell "$command_line"
|
|
done <<< "$CHECK_COMMANDS"
|
|
fi
|
|
|
|
if truthy "$RUN_API_BUILD"; then
|
|
run npm run build:api
|
|
fi
|
|
|
|
if truthy "$RUN_WORKER_BUILD"; then
|
|
run npm run build:worker
|
|
fi
|
|
|
|
if truthy "$RUN_TARO_H5_BUILD"; then
|
|
if truthy "$RUN_TARO_SUPPLY_CHAIN_AUDIT"; then
|
|
run npm run audit:taro:supply-chain
|
|
fi
|
|
run npm run build:taro:h5:student
|
|
run npm run build:taro:h5:tenant
|
|
run npm run build:taro:h5:platform
|
|
link_h5_runtime_configs "$release"
|
|
if truthy "$STRICT_H5_RUNTIME_CONFIG"; then
|
|
run node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config
|
|
run npm run manifest:taro:h5 -- --require-dist --require-runtime-config
|
|
else
|
|
run node scripts/taro-h5-release-guardrails-test.js --require-dist
|
|
run npm run manifest:taro:h5 -- --require-dist
|
|
fi
|
|
if truthy "$RUN_H5_SMOKE"; then
|
|
run npm run smoke:taro:h5
|
|
run npm run smoke:taro:h5:interaction
|
|
fi
|
|
fi
|
|
|
|
if truthy "$RUN_SECURITY_REPO_SCAN"; then
|
|
run npm run security:repo
|
|
fi
|
|
|
|
if truthy "$RUN_RUNTIME_AUDIT"; then
|
|
run env NPM_AUDIT_REGISTRY="$NPM_AUDIT_REGISTRY" npm run audit:runtime
|
|
fi
|
|
|
|
link_shared_env "$release"
|
|
load_runtime_env
|
|
|
|
if should_run_production_readiness; then
|
|
run npm run readiness:production
|
|
fi
|
|
|
|
if truthy "$RUN_DB_MIGRATIONS"; then
|
|
run_shell "$DB_MIGRATION_COMMAND"
|
|
fi
|
|
|
|
if truthy "$RUN_DB_READINESS"; then
|
|
log "Running production database readiness after the optional migration step"
|
|
run npm run readiness:production:db
|
|
fi
|
|
|
|
if truthy "$RUN_LAUNCH_GATE"; then
|
|
[[ -f "$PRODUCTION_LAUNCH_EVIDENCE" ]] || die "Missing production launch evidence: $PRODUCTION_LAUNCH_EVIDENCE"
|
|
run env \
|
|
DEPLOY_COMMIT_SHA="$(git rev-parse HEAD)" \
|
|
DEPLOY_RELEASE_ROOT="$release" \
|
|
npm run launch:gate -- --evidence "$PRODUCTION_LAUNCH_EVIDENCE"
|
|
fi
|
|
}
|
|
|
|
verify_live_h5_release() {
|
|
local release="$1"
|
|
truthy "$RUN_LAUNCH_GATE" || return 0
|
|
[[ -f "$PRODUCTION_LAUNCH_EVIDENCE" ]] || die "Missing production launch evidence: $PRODUCTION_LAUNCH_EVIDENCE"
|
|
|
|
log "Verifying the activated H5 release against production URLs"
|
|
(
|
|
cd "$release"
|
|
env \
|
|
DEPLOY_COMMIT_SHA="$(git rev-parse HEAD)" \
|
|
DEPLOY_RELEASE_ROOT="$release" \
|
|
npm run launch:gate -- --evidence "$PRODUCTION_LAUNCH_EVIDENCE" --verify-live-h5
|
|
)
|
|
}
|
|
|
|
stage_h5_release() {
|
|
local release="$1"
|
|
local release_name
|
|
release_name="$(basename "$release")"
|
|
local staging="$WWW_RELEASES_DIR/.tmp-$release_name"
|
|
|
|
require_command rsync
|
|
rm -rf "$staging"
|
|
mkdir -p "$staging/student" "$staging/tenant-admin" "$staging/platform-admin"
|
|
run rsync -a --delete --copy-links "$release/apps/taro/dist/h5-student/" "$staging/student/"
|
|
run rsync -a --delete --copy-links "$release/apps/taro/dist/h5-tenant-admin/" "$staging/tenant-admin/"
|
|
run rsync -a --delete --copy-links "$release/apps/taro/dist/h5-platform-admin/" "$staging/platform-admin/"
|
|
|
|
NEW_WWW_RELEASE="$WWW_RELEASES_DIR/$release_name"
|
|
rm -rf "$NEW_WWW_RELEASE"
|
|
mv "$staging" "$NEW_WWW_RELEASE"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
switch_www_release() {
|
|
[[ -n "$NEW_WWW_RELEASE" ]] || return 0
|
|
WWW_SWITCH_STARTED=true
|
|
|
|
if [[ -L "$WWW_CURRENT_LINK" ]]; then
|
|
PREVIOUS_WWW_RELEASE="$(readlink -f "$WWW_CURRENT_LINK")"
|
|
elif [[ -d "$WWW_CURRENT_LINK" ]]; then
|
|
PREVIOUS_WWW_RELEASE="$WWW_RELEASES_DIR/bootstrap-$(date +%Y%m%d%H%M%S)"
|
|
log "Moving existing Web root to $PREVIOUS_WWW_RELEASE"
|
|
mv "$WWW_CURRENT_LINK" "$PREVIOUS_WWW_RELEASE"
|
|
elif [[ -e "$WWW_CURRENT_LINK" ]]; then
|
|
die "WWW root exists but is not a directory or symlink: $WWW_CURRENT_LINK"
|
|
fi
|
|
|
|
atomic_symlink "$NEW_WWW_RELEASE" "$WWW_CURRENT_LINK" \
|
|
|| die "$WWW_CURRENT_LINK exists and cannot be replaced by the release symlink"
|
|
}
|
|
|
|
snapshot_service_repo_for_rollback() {
|
|
if [[ -n "$PREVIOUS_RELEASE" || ! -d "$SERVICE_REPO_DIR" || -L "$SERVICE_REPO_DIR" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
require_command rsync
|
|
PREVIOUS_SERVICE_BACKUP="$RELEASES_DIR/bootstrap-service-$(date +%Y%m%d%H%M%S)"
|
|
mkdir -p "$PREVIOUS_SERVICE_BACKUP"
|
|
run rsync -a --delete --exclude .git "$SERVICE_REPO_DIR/" "$PREVIOUS_SERVICE_BACKUP/"
|
|
}
|
|
|
|
sync_service_repo() {
|
|
local release="$1"
|
|
truthy "$SYNC_SERVICE_REPO" || return 0
|
|
[[ -n "$release" && -d "$release" ]] || return 1
|
|
|
|
require_command rsync
|
|
if [[ -L "$SERVICE_REPO_DIR" ]]; then
|
|
[[ "$(readlink -f "$SERVICE_REPO_DIR")" == "$(readlink -f "$release")" ]] \
|
|
|| die "SERVICE_REPO_DIR symlink must resolve to the selected current release"
|
|
return 0
|
|
fi
|
|
mkdir -p "$SERVICE_REPO_DIR"
|
|
run rsync -a --delete --exclude .git "$release/" "$SERVICE_REPO_DIR/"
|
|
}
|
|
|
|
restart_services() {
|
|
if [[ -d "$CURRENT_LINK" ]]; then
|
|
cd "$CURRENT_LINK"
|
|
elif [[ -d "$SERVICE_REPO_DIR" ]]; then
|
|
cd "$SERVICE_REPO_DIR"
|
|
else
|
|
die "Neither CURRENT_LINK nor SERVICE_REPO_DIR is available for service restart"
|
|
fi
|
|
|
|
if [[ -n "$RESTART_COMMAND" ]]; then
|
|
run_shell "$RESTART_COMMAND" || return 1
|
|
return 0
|
|
fi
|
|
|
|
case "$SERVICE_MODE" in
|
|
none)
|
|
warn "SERVICE_MODE=none; release switched but no service was restarted."
|
|
;;
|
|
systemd)
|
|
[[ -n "$SYSTEMD_UNITS" ]] || die "SYSTEMD_UNITS is required when SERVICE_MODE=systemd"
|
|
for unit in $SYSTEMD_UNITS; do
|
|
run systemctl restart "$unit" || return 1
|
|
done
|
|
for unit in $SYSTEMD_UNITS; do
|
|
run systemctl is-active --quiet "$unit" || return 1
|
|
done
|
|
;;
|
|
pm2)
|
|
require_command pm2
|
|
if [[ -n "$PM2_ECOSYSTEM" ]]; then
|
|
run pm2 startOrReload "$PM2_ECOSYSTEM" --update-env || return 1
|
|
elif [[ -n "$PM2_PROCESS_NAMES" ]]; then
|
|
for name in $PM2_PROCESS_NAMES; do
|
|
run pm2 restart "$name" --update-env || return 1
|
|
done
|
|
else
|
|
die "PM2_ECOSYSTEM or PM2_PROCESS_NAMES is required when SERVICE_MODE=pm2"
|
|
fi
|
|
;;
|
|
compose)
|
|
require_command docker
|
|
[[ -n "$COMPOSE_FILE" ]] || die "COMPOSE_FILE is required when SERVICE_MODE=compose"
|
|
run docker compose -f "$COMPOSE_FILE" up -d --build || return 1
|
|
;;
|
|
*)
|
|
die "Unknown SERVICE_MODE: $SERVICE_MODE"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
healthcheck() {
|
|
if [[ -z "$HEALTHCHECK_URL" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
require_command curl
|
|
|
|
local deadline=$((SECONDS + HEALTHCHECK_TIMEOUT_SECONDS))
|
|
log "Waiting for healthcheck: $HEALTHCHECK_URL"
|
|
while (( SECONDS < deadline )); do
|
|
if curl -fsS --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then
|
|
log "Healthcheck passed."
|
|
return 0
|
|
fi
|
|
sleep "$HEALTHCHECK_INTERVAL_SECONDS"
|
|
done
|
|
|
|
return 1
|
|
}
|
|
|
|
switch_current() {
|
|
local release="$1"
|
|
|
|
if [[ -e "$CURRENT_LINK" && ! -L "$CURRENT_LINK" ]]; then
|
|
die "$CURRENT_LINK exists and is not a symlink; refusing to replace it"
|
|
fi
|
|
|
|
if [[ -L "$CURRENT_LINK" ]]; then
|
|
PREVIOUS_RELEASE="$(readlink -f "$CURRENT_LINK")"
|
|
fi
|
|
|
|
atomic_symlink "$release" "$CURRENT_LINK" || die "Failed to switch $CURRENT_LINK"
|
|
}
|
|
|
|
rollback() {
|
|
local rollback_failed=false
|
|
|
|
if [[ "$WWW_SWITCH_STARTED" == "true" && -n "$PREVIOUS_WWW_RELEASE" ]]; then
|
|
warn "Rolling Web root back to $PREVIOUS_WWW_RELEASE"
|
|
atomic_symlink "$PREVIOUS_WWW_RELEASE" "$WWW_CURRENT_LINK" || rollback_failed=true
|
|
elif [[ "$WWW_SWITCH_STARTED" == "true" && -n "$NEW_WWW_RELEASE" ]]; then
|
|
warn "No previous Web release recorded; restoring an absent Web root."
|
|
rm -f "$WWW_CURRENT_LINK" || rollback_failed=true
|
|
fi
|
|
|
|
local service_rollback_release="$PREVIOUS_RELEASE"
|
|
if [[ -z "$service_rollback_release" ]]; then
|
|
service_rollback_release="$PREVIOUS_SERVICE_BACKUP"
|
|
fi
|
|
|
|
if [[ "$APP_SWITCHED" == "true" && -n "$PREVIOUS_RELEASE" ]]; then
|
|
warn "Rolling application release back to $PREVIOUS_RELEASE"
|
|
atomic_symlink "$PREVIOUS_RELEASE" "$CURRENT_LINK" || rollback_failed=true
|
|
elif [[ "$APP_SWITCHED" == "true" && -n "$NEW_RELEASE" ]]; then
|
|
warn "No previous application current release recorded; restoring an absent current link."
|
|
rm -f "$CURRENT_LINK" || rollback_failed=true
|
|
fi
|
|
|
|
if [[ "$SERVICE_SYNC_STARTED" == "true" ]] && truthy "$SYNC_SERVICE_REPO" && [[ -n "$service_rollback_release" ]]; then
|
|
sync_service_repo "$service_rollback_release" || rollback_failed=true
|
|
fi
|
|
if [[ "$SERVICE_SYNC_STARTED" == "true" ]]; then
|
|
restart_services || rollback_failed=true
|
|
healthcheck || rollback_failed=true
|
|
fi
|
|
|
|
[[ "$rollback_failed" == "false" ]]
|
|
}
|
|
|
|
prune_releases() {
|
|
local keep="$1"
|
|
local directory="${2:-$RELEASES_DIR}"
|
|
[[ "$keep" =~ ^[0-9]+$ ]] || return 0
|
|
(( keep > 0 )) || return 0
|
|
|
|
find "$directory" -mindepth 1 -maxdepth 1 -type d ! -name '.*' ! -name 'bootstrap-*' -print \
|
|
| sort -r \
|
|
| tail -n +"$((keep + 1))" \
|
|
| while IFS= read -r old_release; do
|
|
if [[ "$old_release" == "$NEW_RELEASE" || "$old_release" == "$PREVIOUS_RELEASE" \
|
|
|| "$old_release" == "$NEW_WWW_RELEASE" || "$old_release" == "$PREVIOUS_WWW_RELEASE" ]]; then
|
|
continue
|
|
fi
|
|
log "Pruning old release: $old_release"
|
|
rm -rf "$old_release"
|
|
done
|
|
}
|
|
|
|
main() {
|
|
require_command git
|
|
require_command npm
|
|
|
|
acquire_lock
|
|
validate_deploy_contract
|
|
mkdir -p "$RELEASES_DIR" "$SHARED_DIR" "$WWW_RELEASES_DIR"
|
|
prepare_askpass
|
|
|
|
local timestamp
|
|
timestamp="$(date +%Y%m%d%H%M%S)"
|
|
local tmp_release="$RELEASES_DIR/.tmp-$timestamp"
|
|
|
|
log "Cloning $REPO_URL#$BRANCH"
|
|
run git clone --depth "$GIT_DEPTH" --branch "$BRANCH" "$REPO_URL" "$tmp_release"
|
|
|
|
local commit
|
|
commit="$(git -C "$tmp_release" rev-parse --short=12 HEAD)"
|
|
NEW_RELEASE="$RELEASES_DIR/$timestamp-$commit"
|
|
mv "$tmp_release" "$NEW_RELEASE"
|
|
|
|
log "Building release $NEW_RELEASE"
|
|
run_build_and_checks "$NEW_RELEASE"
|
|
|
|
log "Staging H5 release for $WWW_ROOT"
|
|
stage_h5_release "$NEW_RELEASE"
|
|
|
|
if [[ -L "$CURRENT_LINK" ]]; then
|
|
PREVIOUS_RELEASE="$(readlink -f "$CURRENT_LINK")"
|
|
fi
|
|
snapshot_service_repo_for_rollback
|
|
|
|
ROLLBACK_ARMED=true
|
|
log "Switching current release to $NEW_RELEASE"
|
|
switch_current "$NEW_RELEASE"
|
|
APP_SWITCHED=true
|
|
|
|
log "Synchronizing service runtime to $SERVICE_REPO_DIR"
|
|
SERVICE_SYNC_STARTED=true
|
|
if ! sync_service_repo "$NEW_RELEASE"; then
|
|
ROLLBACK_ARMED=false
|
|
rollback || true
|
|
die "Service runtime synchronization failed; rollback attempted."
|
|
fi
|
|
|
|
if ! restart_services; then
|
|
ROLLBACK_ARMED=false
|
|
rollback || true
|
|
die "Service restart failed; rollback attempted."
|
|
fi
|
|
|
|
if ! healthcheck; then
|
|
ROLLBACK_ARMED=false
|
|
rollback || true
|
|
die "Healthcheck failed; rollback attempted."
|
|
fi
|
|
|
|
log "Switching Web root to $NEW_WWW_RELEASE"
|
|
switch_www_release
|
|
|
|
verify_live_h5_release "$NEW_RELEASE"
|
|
|
|
ROLLBACK_ARMED=false
|
|
prune_releases "$KEEP_RELEASES" "$RELEASES_DIR"
|
|
prune_releases "$KEEP_RELEASES" "$WWW_RELEASES_DIR"
|
|
log "Deploy complete: $APP_NAME @ $commit"
|
|
}
|
|
|
|
main "$@"
|