Files
redmine/deploy_redmine_prod_patches.sh
Jason Thistlethwaite bd26c8894f Add production rollout tooling and semantic index ops docs
Capture the production plugin rollout workflow and Qdrant validation steps so operations stay repeatable. Also harden redMCP stdio/schema compatibility to keep diverse MCP clients and validators working.
2026-05-06 22:18:02 -04:00

450 lines
12 KiB
Bash
Executable File

#!/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 <backup_dir> Restore plugins from a prior backup directory
Core options:
--repo-root <path> Local repo root (default: script directory)
--redmine-root <path> Production Redmine root (default: /usr/share/redmine)
--backup-root <path> Backup root for deploy mode (default: /root/redmine-plugin-backups)
Verification options:
--redmine-url <url> Redmine base URL for API checks
--api-key <key> Redmine API key for API checks
--helpdesk-issue-id <id> Known Helpdesk issue id for include=helpdesk verification
--non-helpdesk-issue-id <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 "$@"