#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$SCRIPT_DIR" REDMINE_ROOT="/usr/share/redmine" BACKUP_ROOT="/root/redmine-plugin-backups" REDMINE_URL="${REDMINE_URL:-}" REDMINE_API_KEY="${REDMINE_API_KEY:-${REDMNINE_API_KEY:-}}" HELPDESK_ISSUE_ID="${HELPDESK_ISSUE_ID:-}" NON_HELPDESK_ISSUE_ID="${NON_HELPDESK_ISSUE_ID:-}" SKIP_CONTACTS=0 SKIP_HELPDESK=0 SKIP_OUTBOX=0 ACTION="deploy" ROLLBACK_DIR="" APPLY=0 APPLY_SET=0 PRINT_CHANGE_MAP=0 PLUGINS=(redmine_contacts redmine_contacts_helpdesk redmine_event_outbox) SELECTED_PLUGINS=() BACKUP_DIR="" ROLLBACK_RUNNING=0 usage() { cat <<'EOF' Usage: ./deploy_redmine_prod_patches.sh [options] Defaults to dry-run deploy mode. Modes: --apply Execute actions (default is dry-run) --dry-run Print actions only --rollback Restore plugins from a prior backup directory Core options: --repo-root Local repo root (default: script directory) --redmine-root Production Redmine root (default: /usr/share/redmine) --backup-root Backup root for deploy mode (default: /root/redmine-plugin-backups) Verification options: --redmine-url Redmine base URL for API checks --api-key Redmine API key for API checks --helpdesk-issue-id Known Helpdesk issue id for include=helpdesk verification --non-helpdesk-issue-id Known non-Helpdesk issue id for include=helpdesk verification Selection options: --skip-contacts --skip-helpdesk --skip-outbox Informational: --print-change-map Show patch groups and key files, then exit -h, --help Show this help Examples: ./deploy_redmine_prod_patches.sh --dry-run ./deploy_redmine_prod_patches.sh --apply --redmine-url https://redmine.example.com --api-key ... --helpdesk-issue-id 39779 --non-helpdesk-issue-id 12345 ./deploy_redmine_prod_patches.sh --rollback /root/redmine-plugin-backups/prod-plugin-rollout-20260506T120000Z EOF } log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*" } die() { printf 'error: %s\n' "$*" >&2 exit 1 } print_change_map() { cat <<'EOF' Patch Groups -> Key Files 1) Helpdesk API include patch (semantic-index dependency) - plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb - plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb - plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb 2) Helpdesk search routes/controller in local helpdesk fork - plugins/redmine_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb - plugins/redmine_contacts_helpdesk/config/routes.rb - plugins/redmine_contacts_helpdesk/init.rb 3) POP3 compatibility fix in contacts fork - plugins/redmine_contacts/lib/redmine_contacts/utils/check_mail.rb 4) Event outbox + Helpdesk-related hooks - plugins/redmine_event_outbox/db/migrate/001_create_event_outbox_events.rb - plugins/redmine_event_outbox/lib/redmine_event_outbox.rb - plugins/redmine_event_outbox/lib/redmine_event_outbox/patches/helpdesk_ticket_patch.rb - plugins/redmine_event_outbox/lib/redmine_event_outbox/patches/journal_message_patch.rb Operational note: This script deploys full plugin directories to match the post-import workflow: - plugins/redmine_contacts/ - plugins/redmine_contacts_helpdesk/ - plugins/redmine_event_outbox/ EOF } cmd_string() { local out="" local arg for arg in "$@"; do out+=" $(printf '%q' "$arg")" done printf '%s' "${out# }" } run_cmd() { log "+ $(cmd_string "$@")" if [[ "$APPLY" -eq 1 ]]; then "$@" fi } require_path() { local path="$1" [[ -e "$path" ]] || die "missing required path: $path" } require_dir() { local path="$1" [[ -d "$path" ]] || die "missing required directory: $path" } build_selected_plugins() { SELECTED_PLUGINS=() if [[ "$SKIP_CONTACTS" -eq 0 ]]; then SELECTED_PLUGINS+=("redmine_contacts") fi if [[ "$SKIP_HELPDESK" -eq 0 ]]; then SELECTED_PLUGINS+=("redmine_contacts_helpdesk") fi if [[ "$SKIP_OUTBOX" -eq 0 ]]; then SELECTED_PLUGINS+=("redmine_event_outbox") fi if [[ "${#SELECTED_PLUGINS[@]}" -eq 0 ]]; then die "nothing selected; remove skip flags or pick at least one plugin" fi } backup_plugin() { local plugin="$1" local src="$REDMINE_ROOT/plugins/$plugin" local dst="$BACKUP_DIR/$plugin" local absent_marker="$BACKUP_DIR/.${plugin}.absent" if [[ -d "$src" ]]; then run_cmd mkdir -p "$dst" run_cmd rsync -a "$src/" "$dst/" else run_cmd mkdir -p "$BACKUP_DIR" run_cmd touch "$absent_marker" fi } restore_plugin() { local plugin="$1" local src="$ROLLBACK_DIR/$plugin" local dst="$REDMINE_ROOT/plugins/$plugin" local absent_marker="$ROLLBACK_DIR/.${plugin}.absent" if [[ -f "$absent_marker" ]]; then run_cmd rm -rf "$dst" return fi require_dir "$src" run_cmd mkdir -p "$dst" run_cmd rsync -a --delete "$src/" "$dst/" } deploy_plugin() { local plugin="$1" local src="$REPO_ROOT/plugins/$plugin" local dst="$REDMINE_ROOT/plugins/$plugin" require_dir "$src" run_cmd mkdir -p "$dst" run_cmd rsync -a --delete "$src/" "$dst/" } verify_contacts_syntax() { run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_contacts/lib/redmine_contacts/utils/check_mail.rb" } verify_helpdesk_syntax() { run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb" run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb" run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb" run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_contacts_helpdesk/app/models/helpdesk_mailer.rb" } verify_outbox_syntax() { run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_event_outbox/lib/redmine_event_outbox.rb" } migrate_plugins() { run_cmd bash -lc "cd $(printf '%q' "$REDMINE_ROOT") && RAILS_ENV=production bundle exec rake redmine:plugins:migrate" } restart_passenger() { run_cmd touch "$REDMINE_ROOT/tmp/restart.txt" } verify_outbox_table() { if [[ "$SKIP_OUTBOX" -eq 1 ]]; then return fi run_cmd bash -lc "cd $(printf '%q' "$REDMINE_ROOT") && RAILS_ENV=production bundle exec ruby -e \"require './config/environment'; abort('missing event_outbox_events') unless ActiveRecord::Base.connection.table_exists?(:event_outbox_events); puts 'OK event_outbox_events'\"" } verify_helpdesk_api() { if [[ "$SKIP_HELPDESK" -eq 1 ]]; then return fi if [[ -z "$REDMINE_URL" || -z "$REDMINE_API_KEY" ]]; then log "Skipping API verification (set --redmine-url and --api-key to enable)" return fi local tmp1 tmp2 tmp1="$(mktemp)" tmp2="$(mktemp)" if [[ -n "$HELPDESK_ISSUE_ID" ]]; then run_cmd curl -fsS -H "X-Redmine-API-Key: $REDMINE_API_KEY" "${REDMINE_URL%/}/issues/${HELPDESK_ISSUE_ID}.json?include=journals,helpdesk" -o "$tmp1" run_cmd grep -q '"helpdesk_ticket"' "$tmp1" else log "Skipping Helpdesk issue include check (set --helpdesk-issue-id)" fi if [[ -n "$NON_HELPDESK_ISSUE_ID" ]]; then run_cmd curl -fsS -H "X-Redmine-API-Key: $REDMINE_API_KEY" "${REDMINE_URL%/}/issues/${NON_HELPDESK_ISSUE_ID}.json?include=helpdesk" -o "$tmp2" run_cmd grep -q '"issue"' "$tmp2" else log "Skipping non-Helpdesk include check (set --non-helpdesk-issue-id)" fi if [[ "$APPLY" -eq 1 ]]; then rm -f "$tmp1" "$tmp2" else log "Temporary API response files (dry-run placeholders): $tmp1 $tmp2" fi } rollback_selected_plugins() { local plugin for plugin in "${SELECTED_PLUGINS[@]}"; do log "Restoring plugin: $plugin" restore_plugin "$plugin" done restart_passenger } on_error() { local line="$1" local rc="$2" if [[ "$ROLLBACK_RUNNING" -eq 1 ]]; then exit "$rc" fi log "Failure at line ${line} (exit ${rc})" if [[ "$ACTION" == "deploy" && "$APPLY" -eq 1 && -n "$BACKUP_DIR" ]]; then ROLLBACK_RUNNING=1 ROLLBACK_DIR="$BACKUP_DIR" log "Attempting automatic rollback from: $ROLLBACK_DIR" rollback_selected_plugins || true log "Automatic rollback finished" fi exit "$rc" } parse_args() { while [[ "$#" -gt 0 ]]; do case "$1" in --apply) APPLY=1 APPLY_SET=1 ;; --dry-run) APPLY=0 APPLY_SET=1 ;; --rollback) ACTION="rollback" shift [[ "$#" -gt 0 ]] || die "--rollback requires a backup directory" ROLLBACK_DIR="$1" ;; --repo-root) shift [[ "$#" -gt 0 ]] || die "--repo-root requires a path" REPO_ROOT="$1" ;; --redmine-root) shift [[ "$#" -gt 0 ]] || die "--redmine-root requires a path" REDMINE_ROOT="$1" ;; --backup-root) shift [[ "$#" -gt 0 ]] || die "--backup-root requires a path" BACKUP_ROOT="$1" ;; --redmine-url) shift [[ "$#" -gt 0 ]] || die "--redmine-url requires a value" REDMINE_URL="$1" ;; --api-key) shift [[ "$#" -gt 0 ]] || die "--api-key requires a value" REDMINE_API_KEY="$1" ;; --helpdesk-issue-id) shift [[ "$#" -gt 0 ]] || die "--helpdesk-issue-id requires a value" HELPDESK_ISSUE_ID="$1" ;; --non-helpdesk-issue-id) shift [[ "$#" -gt 0 ]] || die "--non-helpdesk-issue-id requires a value" NON_HELPDESK_ISSUE_ID="$1" ;; --skip-contacts) SKIP_CONTACTS=1 ;; --skip-helpdesk) SKIP_HELPDESK=1 ;; --skip-outbox) SKIP_OUTBOX=1 ;; --print-change-map) PRINT_CHANGE_MAP=1 ;; -h|--help) usage exit 0 ;; *) die "unknown argument: $1" ;; esac shift done } main() { parse_args "$@" if [[ "$PRINT_CHANGE_MAP" -eq 1 ]]; then print_change_map exit 0 fi if [[ "$ACTION" == "rollback" && "$APPLY_SET" -eq 0 ]]; then APPLY=1 fi if [[ "$APPLY" -eq 1 && "$EUID" -ne 0 ]]; then die "--apply and --rollback apply mode require root" fi require_dir "$REDMINE_ROOT" require_dir "$REDMINE_ROOT/plugins" require_path "$REDMINE_ROOT/tmp" build_selected_plugins trap 'on_error ${LINENO} $?' ERR if [[ "$ACTION" == "rollback" ]]; then require_dir "$ROLLBACK_DIR" log "Mode: rollback ($([[ "$APPLY" -eq 1 ]] && echo apply || echo dry-run))" log "Redmine root: $REDMINE_ROOT" log "Backup source: $ROLLBACK_DIR" rollback_selected_plugins trap - ERR log "Rollback completed" exit 0 fi require_dir "$REPO_ROOT" require_dir "$REPO_ROOT/plugins" BACKUP_DIR="$BACKUP_ROOT/prod-plugin-rollout-$(date -u +%Y%m%dT%H%M%SZ)" log "Mode: deploy ($([[ "$APPLY" -eq 1 ]] && echo apply || echo dry-run))" log "Repo root: $REPO_ROOT" log "Redmine root: $REDMINE_ROOT" log "Backup dir: $BACKUP_DIR" log "Selected plugins: ${SELECTED_PLUGINS[*]}" run_cmd mkdir -p "$BACKUP_DIR" local plugin for plugin in "${SELECTED_PLUGINS[@]}"; do log "Backing up plugin: $plugin" backup_plugin "$plugin" done if [[ "$SKIP_CONTACTS" -eq 0 ]]; then log "Deploying plugin: redmine_contacts" deploy_plugin "redmine_contacts" log "Verifying syntax: redmine_contacts" verify_contacts_syntax fi if [[ "$SKIP_HELPDESK" -eq 0 ]]; then log "Deploying plugin: redmine_contacts_helpdesk" deploy_plugin "redmine_contacts_helpdesk" log "Verifying syntax: redmine_contacts_helpdesk" verify_helpdesk_syntax fi if [[ "$SKIP_OUTBOX" -eq 0 ]]; then log "Deploying plugin: redmine_event_outbox" deploy_plugin "redmine_event_outbox" log "Verifying syntax: redmine_event_outbox" verify_outbox_syntax fi log "Running plugin migrations" migrate_plugins log "Restarting Passenger" restart_passenger log "Running runtime verifications" verify_outbox_table verify_helpdesk_api trap - ERR log "Deploy completed successfully" log "Rollback command: $(cmd_string "$0" --rollback "$BACKUP_DIR")" } main "$@"