#!/bin/bash set -euo pipefail remote_folder="${REMOTE_FOLDER:-/opt/www/virtual/SkinbaseNova}" remote_server="${REMOTE_SERVER:-klevze@server3.klevze.si}" remote_release_root="${REMOTE_RELEASE_ROOT:-${REMOTE_FOLDER:-/opt/www/virtual/SkinbaseNova}.releases}" remote_shared_root="${REMOTE_SHARED_ROOT:-${REMOTE_RELEASE_ROOT:-/opt/www/virtual/SkinbaseNova.releases}/shared}" php_bin="${PHP_BIN:-php}" composer_bin="${COMPOSER_BIN:-composer}" ssh_bin="${SSH_BIN:-ssh}" dry_run=0 list_only=0 rollback_release_id="" use_previous=0 skip_maintenance=0 usage() { cat <<'EOF' Usage: bash scripts/rollback-production.sh [options] Options: --previous Switch to the previous retained release on the production server. --release-id ID Switch to a specific retained release ID. --list List retained releases and exit. --dry-run Preview the release switch without changing the active release. --no-maintenance Skip php artisan down/up during the switch. --help Show this help. Environment overrides: REMOTE_FOLDER, REMOTE_SERVER, REMOTE_RELEASE_ROOT, REMOTE_SHARED_ROOT, PHP_BIN, COMPOSER_BIN, SSH_BIN Notes: - Rollback changes the active server release; it does not re-upload files from local. - Database migrations are not reversed automatically. EOF } log_step() { printf '\n==> %s\n' "$1" } log_info() { printf ' -> %s\n' "$1" } die() { printf 'ERROR: %s\n' "$1" >&2 exit 1 } require_command() { local command_name="$1" local description="$2" command -v "$command_name" >/dev/null 2>&1 || die "$description is required but was not found in PATH ($command_name)." } sanitize_release_fragment() { local value="$1" value="${value//[^A-Za-z0-9._-]/-}" value="${value#-}" value="${value%-}" printf '%s' "$value" } while [[ $# -gt 0 ]]; do case "$1" in --previous) use_previous=1 ;; --release-id) shift rollback_release_id="$(sanitize_release_fragment "${1:?Missing value for --release-id}")" ;; --release-id=*) rollback_release_id="$(sanitize_release_fragment "${1#*=}")" ;; --list) list_only=1 ;; --dry-run) dry_run=1 ;; --no-maintenance) skip_maintenance=1 ;; --help|-h) usage exit 0 ;; *) die "Unknown option: $1" ;; esac shift done require_command "$ssh_bin" "SSH" if [[ "$list_only" -eq 0 && "$use_previous" -eq 0 && -z "$rollback_release_id" ]]; then die "Choose either --previous or --release-id, or use --list to inspect retained releases." fi if [[ "$use_previous" -eq 1 && -n "$rollback_release_id" ]]; then die "Use either --previous or --release-id, not both." fi if [[ "$list_only" -eq 1 ]]; then log_step "Retained releases on $remote_server" "$ssh_bin" "$remote_server" \ REMOTE_RELEASE_ROOT="$(printf '%q' "$remote_release_root")" \ 'bash -s' <<'EOF' set -euo pipefail current_release="unknown" if [[ -f "${REMOTE_RELEASE_ROOT}/current-release.txt" ]]; then current_release="$(cat "${REMOTE_RELEASE_ROOT}/current-release.txt")" fi printf 'Current release: %s\n' "$current_release" if [[ ! -d "${REMOTE_RELEASE_ROOT}/releases" ]]; then printf 'No retained releases found in %s\n' "$REMOTE_RELEASE_ROOT" exit 0 fi find "${REMOTE_RELEASE_ROOT}/releases" -mindepth 1 -maxdepth 1 -type d | sort -r | while read -r path; do release_id="$(basename "$path")" marker=" " if [[ "$release_id" == "$current_release" ]]; then marker="*" fi printf '%s %s\n' "$marker" "$release_id" done EOF exit 0 fi log_step "Switching production release on $remote_server" if [[ "$dry_run" -eq 1 ]]; then log_info "Dry-run mode enabled; remote current release will not be changed" fi "$ssh_bin" "$remote_server" \ REMOTE_FOLDER="$(printf '%q' "$remote_folder")" \ REMOTE_RELEASE_ROOT="$(printf '%q' "$remote_release_root")" \ REMOTE_SHARED_ROOT="$(printf '%q' "$remote_shared_root")" \ PHP_BIN="$(printf '%q' "$php_bin")" \ COMPOSER_BIN="$(printf '%q' "$composer_bin")" \ ROLLBACK_RELEASE_ID="$(printf '%q' "$rollback_release_id")" \ USE_PREVIOUS="$use_previous" \ DRY_RUN="$dry_run" \ SKIP_MAINTENANCE="$skip_maintenance" \ 'bash -s' <<'EOF' set -euo pipefail current_link="${REMOTE_RELEASE_ROOT}/current" log_step() { printf '\n==> %s\n' "$1" } log_info() { printf ' -> %s\n' "$1" } log_warn() { printf 'WARN: %s\n' "$1" >&2 } die() { printf 'ERROR: %s\n' "$1" >&2 exit 1 } current_release_id() { if [[ -L "$current_link" ]]; then basename "$(readlink "$current_link")" return 0 fi printf '%s\n' '' } resolve_target_release() { local current_release local target_release local -a releases=() [[ -d "${REMOTE_RELEASE_ROOT}/releases" ]] || die "No retained releases exist under ${REMOTE_RELEASE_ROOT}/releases" current_release="$(current_release_id)" if [[ "$USE_PREVIOUS" -eq 1 ]]; then mapfile -t releases < <(find "${REMOTE_RELEASE_ROOT}/releases" -mindepth 1 -maxdepth 1 -type d | sort -r) for release_path in "${releases[@]}"; do target_release="$(basename "$release_path")" if [[ "$target_release" != "$current_release" ]]; then printf '%s\n' "$target_release" return 0 fi done die "No previous retained release is available." fi printf '%s\n' "$ROLLBACK_RELEASE_ID" } ensure_dir() { mkdir -p "$1" } ensure_php_runtime_dir() { local target_dir="$1" local -a privileged_cmd=() if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then privileged_cmd=(sudo -n) elif [[ "$(id -u)" -eq 0 ]]; then privileged_cmd=() fi if [[ ! -d "$target_dir" ]]; then if [[ ${#privileged_cmd[@]} -gt 0 ]]; then "${privileged_cmd[@]}" mkdir -p "$target_dir" else mkdir -p "$target_dir" fi fi if [[ ${#privileged_cmd[@]} -gt 0 || "$(id -u)" -eq 0 ]]; then "${privileged_cmd[@]}" chown -R skinbase:skinbase "$target_dir" "${privileged_cmd[@]}" chmod 770 "$target_dir" return fi chmod 770 "$target_dir" >/dev/null 2>&1 || true } link_shared_paths() { local target_release="$1" ensure_dir "$target_release/public" "$target_release/var" rm -f "$target_release/.env" ln -sfn "${REMOTE_SHARED_ROOT}/.env" "$target_release/.env" rm -rf "$target_release/storage" ln -sfn "${REMOTE_SHARED_ROOT}/storage" "$target_release/storage" rm -rf "$target_release/public/files" ln -sfn "${REMOTE_SHARED_ROOT}/public/files" "$target_release/public/files" rm -rf "$target_release/public/sitemaps" ln -sfn "${REMOTE_SHARED_ROOT}/public/sitemaps" "$target_release/public/sitemaps" rm -rf "$target_release/var/php-tmp" ln -sfn "${REMOTE_SHARED_ROOT}/var/php-tmp" "$target_release/var/php-tmp" rm -rf "$target_release/var/php-sessions" ln -sfn "${REMOTE_SHARED_ROOT}/var/php-sessions" "$target_release/var/php-sessions" } bring_app_up() { if [[ "$SKIP_MAINTENANCE" -eq 0 && -f "$REMOTE_FOLDER/artisan" ]]; then "$PHP_BIN" "$REMOTE_FOLDER/artisan" up >/dev/null 2>&1 || true fi } target_release="$(resolve_target_release)" [[ -n "$target_release" ]] || die "Unable to resolve a target release." target_release_path="${REMOTE_RELEASE_ROOT}/releases/${target_release}" [[ -d "$target_release_path" ]] || die "Retained release not found: ${target_release_path}" current_release="$(current_release_id)" [[ -n "$current_release" ]] || die "No active current release is configured under ${current_link}" if [[ "$target_release" == "$current_release" ]]; then die "Target release is already active: ${target_release}" fi ensure_php_runtime_dir "${REMOTE_SHARED_ROOT}/var/php-tmp" ensure_php_runtime_dir "${REMOTE_SHARED_ROOT}/var/php-sessions" link_shared_paths "$target_release_path" [[ -f "${REMOTE_SHARED_ROOT}/.env" ]] || die "Shared production .env is missing at ${REMOTE_SHARED_ROOT}/.env" [[ -f "${target_release_path}/artisan" ]] || die "Target release is missing artisan: ${target_release_path}/artisan" log_info "Current release: ${current_release}" log_info "Target release: ${target_release}" log_info "Target path: ${target_release_path}" if [[ "$DRY_RUN" -eq 1 ]]; then exit 0 fi trap bring_app_up EXIT if [[ "$SKIP_MAINTENANCE" -eq 0 && -f "$REMOTE_FOLDER/artisan" ]]; then log_step "Enabling maintenance mode" "$PHP_BIN" "$REMOTE_FOLDER/artisan" down --retry=60 || true fi if [[ ! -f "${target_release_path}/vendor/autoload.php" ]]; then log_step "Installing Composer dependencies in target release" ( cd "$target_release_path" "$COMPOSER_BIN" install --no-dev --prefer-dist --optimize-autoloader --no-interaction ) fi log_step "Switching current release to ${target_release}" ln -sfn "$target_release_path" "$current_link" ln -sfn "$current_link" "$REMOTE_FOLDER" cd "$REMOTE_FOLDER" log_step "Refreshing caches" "$PHP_BIN" artisan view:clear "$PHP_BIN" artisan optimize:clear "$PHP_BIN" artisan optimize "$PHP_BIN" artisan view:cache if [[ "$SKIP_MAINTENANCE" -eq 0 ]]; then log_step "Bringing application back online" "$PHP_BIN" artisan up trap - EXIT fi if ! "$PHP_BIN" artisan homepage:warm-guest-cache; then log_warn "Homepage guest cache warm failed during rollback." fi if ! "$PHP_BIN" artisan posts:warm-trending; then log_warn "Post trending cache warm failed during rollback." fi log_step "Restarting queue workers" "$PHP_BIN" artisan queue:restart || true cat > "${REMOTE_RELEASE_ROOT}/current-release.json" < "${REMOTE_RELEASE_ROOT}/current-release.txt" log_step "Release switch complete" EOF