#!/usr/bin/env bash # Apply a Nexus release tarball on the production host. # # Usage: # sudo bash deploy/apply-release-bundle.sh \ # --tarball /tmp/nexus-release-20260708.tar.gz \ # --sha256 6474d58fdccde668b4c91d5e7454fa08e01e20660e3bcbb363eeadbb7c2346a1 \ # --deploy-path /opt/nexus # # Validate only (no backup, no rsync, no rebuild): # bash deploy/apply-release-bundle.sh --validate-only \ # --tarball /tmp/nexus-release-20260708.tar.gz \ # --sha256 6474d58fdccde668b4c91d5e7454fa08e01e20660e3bcbb363eeadbb7c2346a1 # # This script intentionally preserves runtime secrets and data: # - .env # - docker/.env.prod # - web/data set -euo pipefail TARBALL="" EXPECTED_SHA256="" DEPLOY_PATH="${NEXUS_DEPLOY_PATH:-/opt/nexus}" BACKUP_DIR="${NEXUS_BACKUP_DIR:-/opt/nexus-backups}" SKIP_REBUILD=0 DRY_RUN=0 VALIDATE_ONLY=0 APPLY_TMP="" info() { echo -e "\033[0;32m[INFO]\033[0m $*"; } warn() { echo -e "\033[1;33m[WARN]\033[0m $*"; } error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; } usage() { sed -n '1,26p' "$0" } while [[ $# -gt 0 ]]; do case "$1" in --tarball) TARBALL="${2:-}" shift 2 ;; --sha256) EXPECTED_SHA256="${2:-}" shift 2 ;; --deploy-path) DEPLOY_PATH="${2:-}" shift 2 ;; --backup-dir) BACKUP_DIR="${2:-}" shift 2 ;; --skip-rebuild) SKIP_REBUILD=1 shift ;; --dry-run) DRY_RUN=1 shift ;; --validate-only) VALIDATE_ONLY=1 shift ;; -h|--help) usage exit 0 ;; *) error "Unknown argument: $1" usage exit 2 ;; esac done require_cmd() { command -v "$1" >/dev/null 2>&1 || { error "Missing required command: $1" exit 127 } } require_runtime_cmds() { require_cmd tar require_cmd sha256sum if [[ "$VALIDATE_ONLY" == 1 ]]; then return 0 fi require_cmd rsync require_cmd curl } run() { echo "+ $*" if [[ "$DRY_RUN" == 1 ]]; then return 0 fi "$@" } cleanup() { if [[ -n "${APPLY_TMP:-}" && -d "$APPLY_TMP" ]]; then rm -rf "$APPLY_TMP" fi } detect_runtime() { if [[ -f "${DEPLOY_PATH}/docker/.env.prod" ]] && command -v docker >/dev/null 2>&1; then echo docker return fi if command -v supervisorctl >/dev/null 2>&1 && supervisorctl status nexus >/dev/null 2>&1; then echo supervisor return fi echo unknown } verify_sha256() { if [[ -z "$EXPECTED_SHA256" ]]; then warn "No --sha256 provided; skipping checksum verification" return 0 fi local actual actual="$(sha256sum "$TARBALL" | awk '{print $1}')" if [[ "$actual" != "$EXPECTED_SHA256" ]]; then error "SHA256 mismatch" error "expected: $EXPECTED_SHA256" error "actual: $actual" exit 1 fi info "SHA256 OK: $actual" } validate_extracted_tree() { local src="$1" for required in server deploy Dockerfile.prod frontend-v2 web/app-v2; do if [[ ! -e "${src}/${required}" ]]; then error "Release bundle missing required path: ${required}" exit 1 fi done for forbidden in .env docker/.env.prod web/data .venv-py312-codex frontend-v2/node_modules 2025.2 =2025.2; do if [[ -e "${src}/${forbidden}" ]]; then error "Release bundle contains forbidden path: ${forbidden}" exit 1 fi done info "Extracted release tree validation OK" } backup_current() { if [[ ! -d "$DEPLOY_PATH" ]]; then warn "Deploy path does not exist yet: $DEPLOY_PATH" return 0 fi local stamp backup stamp="$(date +%Y%m%d-%H%M%S)" backup="${BACKUP_DIR}/nexus-before-${stamp}.tar.gz" run mkdir -p "$BACKUP_DIR" info "Backup current deploy path -> $backup" run tar czf "$backup" \ --exclude='./web/data' \ --exclude='./.venv*' \ --exclude='./venv' \ --exclude='./frontend/node_modules' \ --exclude='./frontend-v2/node_modules' \ -C "$(dirname "$DEPLOY_PATH")" "$(basename "$DEPLOY_PATH")" } sync_release() { local src="$1" run mkdir -p "$DEPLOY_PATH" info "Sync release tree -> $DEPLOY_PATH" run rsync -a --delete \ --exclude='.env' \ --exclude='docker/.env.prod' \ --exclude='web/data' \ "${src}/" "${DEPLOY_PATH}/" } rebuild_or_restart() { if [[ "$SKIP_REBUILD" == 1 ]]; then warn "--skip-rebuild set; not rebuilding/restarting Nexus" return 0 fi local runtime runtime="$(detect_runtime)" info "Runtime: $runtime" case "$runtime" in docker) if [[ -x "${DEPLOY_PATH}/deploy/nexus-1panel.sh" || -f "${DEPLOY_PATH}/deploy/nexus-1panel.sh" ]]; then run env NEXUS_ROOT="$DEPLOY_PATH" bash "${DEPLOY_PATH}/deploy/nexus-1panel.sh" upgrade --skip-git else error "Docker runtime detected but deploy/nexus-1panel.sh missing" exit 1 fi if [[ -f "${DEPLOY_PATH}/deploy/sync_webapp_to_container.sh" ]]; then run env NEXUS_ROOT="$DEPLOY_PATH" bash "${DEPLOY_PATH}/deploy/sync_webapp_to_container.sh" fi ;; supervisor) run supervisorctl restart nexus ;; *) error "Unknown runtime. Rebuild/restart manually." exit 1 ;; esac } verify_local_endpoints() { local port health app app_v2 port="$(grep -E '^NEXUS_PUBLISH_PORT=' "${DEPLOY_PATH}/docker/.env.prod" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r" ' || true)" port="${port:-8600}" health="$(curl -s "http://127.0.0.1:${port}/health" 2>/dev/null || true)" app="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/app/" 2>/dev/null || echo 000)" app_v2="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/app-v2/" 2>/dev/null || echo 000)" echo " /health -> ${health}" echo " /app/ -> ${app}" echo " /app-v2/ -> ${app_v2}" if [[ "$health" != "ok" || "$app" != "200" || "$app_v2" != "200" ]]; then error "Local endpoint verification failed" exit 1 fi info "Local endpoint verification OK" } main() { if [[ -z "$TARBALL" ]]; then error "--tarball is required" usage exit 2 fi [[ -f "$TARBALL" ]] || { error "Tarball not found: $TARBALL" exit 1 } require_runtime_cmds info "Tarball: $TARBALL" info "Deploy path: $DEPLOY_PATH" info "Backup dir: $BACKUP_DIR" verify_sha256 local extracted APPLY_TMP="$(mktemp -d /tmp/nexus-release-apply.XXXXXX)" trap cleanup EXIT run tar xzf "$TARBALL" -C "$APPLY_TMP" extracted="${APPLY_TMP}/nexus-release" validate_extracted_tree "$extracted" if [[ "$VALIDATE_ONLY" == 1 ]]; then info "Validate-only mode: tarball checksum and content validation passed" return 0 fi backup_current sync_release "$extracted" rebuild_or_restart if [[ "$DRY_RUN" != 1 && "$SKIP_REBUILD" != 1 ]]; then verify_local_endpoints fi info "Release applied successfully" } main "$@"