├── .github └── images │ └── Banner.png ├── .gitignore ├── install.sh ├── n8n-manager.sh └── readme.md /.github/images/Banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automations-Project/n8n-data-manager/75d349c57ebdf30834ab79c097c4daa9f9d360f5/.github/images/Banner.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | n8n-manager-sveltekit 2 | .windsurfrules 3 | logs 4 | ai.md 5 | front 6 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # ========================================================= 3 | # Installer for n8n-manager.sh 4 | # ========================================================= 5 | set -euo pipefail 6 | 7 | # --- Configuration --- 8 | SCRIPT_NAME="n8n-manager.sh" 9 | # IMPORTANT: Replace this URL with the actual raw URL of the script when hosted (e.g., GitHub Raw) 10 | SCRIPT_URL="https://raw.githubusercontent.com/Automations-Project/n8n-data-manager/refs/heads/main/n8n-manager.sh" 11 | INSTALL_DIR="/usr/local/bin" 12 | INSTALL_PATH="${INSTALL_DIR}/${SCRIPT_NAME}" 13 | 14 | # ANSI Colors 15 | printf -v GREEN "\033[0;32m" 16 | printf -v RED "\033[0;31m" 17 | printf -v BLUE "\033[0;34m" 18 | printf -v NC "\033[0m" # No Color 19 | 20 | # --- Functions --- 21 | 22 | log_info() { 23 | echo -e "${BLUE}==>${NC} $1" 24 | } 25 | 26 | log_success() { 27 | echo -e "${GREEN}[SUCCESS]${NC} $1" 28 | } 29 | 30 | log_error() { 31 | echo -e "${RED}[ERROR]${NC} $1" >&2 32 | } 33 | 34 | command_exists() { 35 | command -v "$1" >/dev/null 2>&1 36 | } 37 | 38 | check_dependencies() { 39 | log_info "Checking required dependencies (curl, sudo)..." 40 | local missing="" 41 | if ! command_exists curl; then 42 | missing="$missing curl" 43 | fi 44 | if ! command_exists sudo; then 45 | # Check if running as root, if so, sudo is not needed 46 | if [[ $EUID -ne 0 ]]; then 47 | missing="$missing sudo" 48 | fi 49 | fi 50 | 51 | if [ -n "$missing" ]; then 52 | log_error "Missing required dependencies:$missing" 53 | log_info "Please install them and try again." 54 | exit 1 55 | fi 56 | log_success "Dependencies found." 57 | } 58 | 59 | # --- Main Installation Logic --- 60 | 61 | log_info "Starting n8n-manager installation..." 62 | 63 | check_dependencies 64 | 65 | # Check if script URL placeholder is still present 66 | if [[ "$SCRIPT_URL" == *"PLACEHOLDER"* ]]; then 67 | log_error "Installation script needs configuration." 68 | log_info "Please edit the SCRIPT_URL variable in the install.sh script first." 69 | exit 1 70 | fi 71 | 72 | log_info "Downloading ${SCRIPT_NAME} from ${SCRIPT_URL}..." 73 | temp_script=$(mktemp) 74 | if ! curl -fsSL --connect-timeout 10 "$SCRIPT_URL" -o "$temp_script"; then 75 | log_error "Failed to download the script. Check the URL and network connection." 76 | rm -f "$temp_script" 77 | exit 1 78 | fi 79 | log_success "Script downloaded successfully." 80 | 81 | log_info "Making the script executable..." 82 | if ! chmod +x "$temp_script"; then 83 | log_error "Failed to make the script executable." 84 | rm -f "$temp_script" 85 | exit 1 86 | fi 87 | 88 | log_info "Moving the script to ${INSTALL_PATH} using sudo..." 89 | if [[ $EUID -ne 0 ]]; then 90 | # Not root, use sudo 91 | if ! sudo mv "$temp_script" "$INSTALL_PATH"; then 92 | log_error "Failed to move the script to ${INSTALL_PATH}. Check permissions or run installer with sudo." 93 | rm -f "$temp_script" 94 | exit 1 95 | fi 96 | else 97 | # Already root, move directly 98 | if ! mv "$temp_script" "$INSTALL_PATH"; then 99 | log_error "Failed to move the script to ${INSTALL_PATH}. Check permissions." 100 | rm -f "$temp_script" 101 | exit 1 102 | fi 103 | fi 104 | 105 | # Clean up temp file if move failed and it still exists (shouldn't happen often) 106 | if [ -f "$temp_script" ]; then 107 | rm -f "$temp_script" 108 | fi 109 | 110 | log_success "${SCRIPT_NAME} installed successfully to ${INSTALL_PATH}" 111 | log_info "You can now run the script using: ${SCRIPT_NAME}" 112 | log_info "Run '${SCRIPT_NAME} --help' to see usage instructions." 113 | 114 | exit 0 115 | 116 | -------------------------------------------------------------------------------- /n8n-manager.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # ========================================================= 3 | # n8n-manager.sh - Interactive backup/restore for n8n 4 | # ========================================================= 5 | set -Eeuo pipefail 6 | IFS=$'\n\t' 7 | 8 | # --- Configuration --- 9 | CONFIG_FILE_PATH="${XDG_CONFIG_HOME:-$HOME/.config}/n8n-manager/config" 10 | 11 | # --- Global variables --- 12 | VERSION="3.0.8" 13 | DEBUG_TRACE=${DEBUG_TRACE:-false} # Set to true for trace debugging 14 | SELECTED_ACTION="" 15 | SELECTED_CONTAINER_ID="" 16 | GITHUB_TOKEN="" 17 | GITHUB_REPO="" 18 | GITHUB_BRANCH="main" 19 | DEFAULT_CONTAINER="" 20 | SELECTED_RESTORE_TYPE="all" 21 | # Flags/Options 22 | ARG_ACTION="" 23 | ARG_CONTAINER="" 24 | ARG_TOKEN="" 25 | ARG_REPO="" 26 | ARG_BRANCH="" 27 | ARG_CONFIG_FILE="" 28 | ARG_DATED_BACKUPS=false 29 | ARG_RESTORE_TYPE="all" 30 | ARG_DRY_RUN=false 31 | ARG_VERBOSE=false 32 | ARG_LOG_FILE="" 33 | CONF_DATED_BACKUPS=false 34 | CONF_VERBOSE=false 35 | CONF_LOG_FILE="" 36 | 37 | # ANSI colors for better UI (using printf for robustness) 38 | printf -v RED '\033[0;31m' 39 | printf -v GREEN '\033[0;32m' 40 | printf -v BLUE '\033[0;34m' 41 | printf -v YELLOW '\033[1;33m' 42 | printf -v NC '\033[0m' # No Color 43 | printf -v BOLD '\033[1m' 44 | printf -v DIM '\033[2m' 45 | 46 | # --- Logging Functions --- 47 | 48 | # --- Git Helper Functions --- 49 | # These functions isolate Git operations to avoid parse errors 50 | git_add() { 51 | local repo_dir="$1" 52 | local target="$2" 53 | git -C "$repo_dir" add "$target" 54 | return $? 55 | } 56 | 57 | git_commit() { 58 | local repo_dir="$1" 59 | local message="$2" 60 | git -C "$repo_dir" commit -m "$message" 61 | return $? 62 | } 63 | 64 | git_push() { 65 | local repo_dir="$1" 66 | local remote="$2" 67 | local branch="$3" 68 | git -C "$repo_dir" push -u "$remote" "$branch" 69 | return $? 70 | } 71 | 72 | # --- Debug/Trace Function --- 73 | trace_cmd() { 74 | if $DEBUG_TRACE; then 75 | echo -e "\033[0;35m[TRACE] Running command: $*\033[0m" >&2 76 | "$@" 77 | local ret=$? 78 | echo -e "\033[0;35m[TRACE] Command returned: $ret\033[0m" >&2 79 | return $ret 80 | else 81 | "$@" 82 | return $? 83 | fi 84 | } 85 | 86 | # Simplified and sanitized log function to avoid command not found errors 87 | log() { 88 | # Define parameters 89 | local level="$1" 90 | local message="$2" 91 | 92 | # Skip debug messages if verbose is not enabled 93 | if [ "$level" = "DEBUG" ] && [ "$ARG_VERBOSE" != "true" ]; then 94 | return 0; 95 | fi 96 | 97 | # Set color based on level 98 | local color="" 99 | local prefix="" 100 | local to_stderr=false 101 | 102 | if [ "$level" = "DEBUG" ]; then 103 | color="$DIM" 104 | prefix="[DEBUG]" 105 | elif [ "$level" = "INFO" ]; then 106 | color="$BLUE" 107 | prefix="==>" 108 | elif [ "$level" = "WARN" ]; then 109 | color="$YELLOW" 110 | prefix="[WARNING]" 111 | elif [ "$level" = "ERROR" ]; then 112 | color="$RED" 113 | prefix="[ERROR]" 114 | to_stderr=true 115 | elif [ "$level" = "SUCCESS" ]; then 116 | color="$GREEN" 117 | prefix="[SUCCESS]" 118 | elif [ "$level" = "HEADER" ]; then 119 | color="$BLUE$BOLD" 120 | message="\n$message\n" 121 | elif [ "$level" = "DRYRUN" ]; then 122 | color="$YELLOW" 123 | prefix="[DRY RUN]" 124 | else 125 | prefix="[$level]" 126 | fi 127 | 128 | # Format message 129 | local formatted="${color}${prefix} ${message}${NC}" 130 | local plain="$(date +'%Y-%m-%d %H:%M:%S') ${prefix} ${message}" 131 | 132 | # Output 133 | if [ "$to_stderr" = "true" ]; then 134 | echo -e "$formatted" >&2 135 | else 136 | echo -e "$formatted" 137 | fi 138 | 139 | # Log to file if specified 140 | if [ -n "$ARG_LOG_FILE" ]; then 141 | echo "$plain" >> "$ARG_LOG_FILE" 142 | fi 143 | 144 | return 0 145 | } 146 | 147 | # --- Helper Functions (using new log function) --- 148 | 149 | command_exists() { 150 | command -v "$1" >/dev/null 2>&1 151 | } 152 | 153 | check_host_dependencies() { 154 | log HEADER "Checking host dependencies..." 155 | local missing_deps="" 156 | if ! command_exists docker; then 157 | missing_deps="$missing_deps docker" 158 | fi 159 | if ! command_exists git; then 160 | missing_deps="$missing_deps git" 161 | fi 162 | if ! command_exists curl; then # Added curl check 163 | missing_deps="$missing_deps curl" 164 | fi 165 | 166 | if [ -n "$missing_deps" ]; then 167 | log ERROR "Missing required host dependencies:$missing_deps" 168 | log INFO "Please install the missing dependencies and try again." 169 | exit 1 170 | fi 171 | log SUCCESS "All required host dependencies are available!" 172 | } 173 | 174 | load_config() { 175 | local file_to_load="${ARG_CONFIG_FILE:-$CONFIG_FILE_PATH}" 176 | file_to_load="${file_to_load/#\~/$HOME}" 177 | 178 | if [ -f "$file_to_load" ]; then 179 | log INFO "Loading configuration from $file_to_load..." 180 | source <(grep -vE '^\s*(#|$)' "$file_to_load" 2>/dev/null || true) 181 | 182 | ARG_TOKEN=${ARG_TOKEN:-${CONF_GITHUB_TOKEN:-}} 183 | ARG_REPO=${ARG_REPO:-${CONF_GITHUB_REPO:-}} 184 | ARG_BRANCH=${ARG_BRANCH:-${CONF_GITHUB_BRANCH:-main}} 185 | ARG_CONTAINER=${ARG_CONTAINER:-${CONF_DEFAULT_CONTAINER:-}} 186 | DEFAULT_CONTAINER=${CONF_DEFAULT_CONTAINER:-} 187 | 188 | if ! $ARG_DATED_BACKUPS; then 189 | CONF_DATED_BACKUPS_VAL=${CONF_DATED_BACKUPS:-false} 190 | if [[ "$CONF_DATED_BACKUPS_VAL" == "true" ]]; then ARG_DATED_BACKUPS=true; fi 191 | fi 192 | 193 | ARG_RESTORE_TYPE=${ARG_RESTORE_TYPE:-${CONF_RESTORE_TYPE:-all}} 194 | 195 | if ! $ARG_VERBOSE; then 196 | CONF_VERBOSE_VAL=${CONF_VERBOSE:-false} 197 | if [[ "$CONF_VERBOSE_VAL" == "true" ]]; then ARG_VERBOSE=true; fi 198 | fi 199 | 200 | ARG_LOG_FILE=${ARG_LOG_FILE:-${CONF_LOG_FILE:-}} 201 | 202 | elif [ -n "$ARG_CONFIG_FILE" ]; then 203 | log WARN "Configuration file specified but not found: $file_to_load" 204 | fi 205 | 206 | if [ -n "$ARG_LOG_FILE" ] && [[ "$ARG_LOG_FILE" != /* ]]; then 207 | log WARN "Log file path '$ARG_LOG_FILE' is not absolute. Prepending current directory." 208 | ARG_LOG_FILE="$(pwd)/$ARG_LOG_FILE" 209 | fi 210 | 211 | if [ -n "$ARG_LOG_FILE" ]; then 212 | log DEBUG "Ensuring log file exists and is writable: $ARG_LOG_FILE" 213 | mkdir -p "$(dirname "$ARG_LOG_FILE")" || { log ERROR "Could not create directory for log file: $(dirname "$ARG_LOG_FILE")"; exit 1; } 214 | touch "$ARG_LOG_FILE" || { log ERROR "Log file is not writable: $ARG_LOG_FILE"; exit 1; } 215 | log INFO "Logging output also to: $ARG_LOG_FILE" 216 | fi 217 | } 218 | 219 | show_help() { 220 | cat << EOF 221 | Usage: $(basename "$0") [OPTIONS] 222 | 223 | Automated backup and restore tool for n8n Docker containers using GitHub. 224 | Reads configuration from ${CONFIG_FILE_PATH} if it exists. 225 | 226 | Options: 227 | --action Action to perform: 'backup' or 'restore'. 228 | --container Target Docker container ID or name. 229 | --token GitHub Personal Access Token (PAT). 230 | --repo GitHub repository (e.g., 'myuser/n8n-backup'). 231 | --branch GitHub branch to use (defaults to 'main'). 232 | --dated Create timestamped subdirectory for backups (e.g., YYYY-MM-DD_HH-MM-SS/). 233 | Overrides CONF_DATED_BACKUPS in config file. 234 | --restore-type Type of restore: 'all' (default), 'workflows', or 'credentials'. 235 | Overrides CONF_RESTORE_TYPE in config file. 236 | --dry-run Simulate the action without making any changes. 237 | --verbose Enable detailed debug logging. 238 | --log-file Path to a file to append logs to. 239 | --config Path to a custom configuration file. 240 | -h, --help Show this help message and exit. 241 | 242 | Configuration File (${CONFIG_FILE_PATH}): 243 | Define variables like: 244 | CONF_GITHUB_TOKEN="ghp_..." 245 | CONF_GITHUB_REPO="user/repo" 246 | CONF_GITHUB_BRANCH="main" 247 | CONF_DEFAULT_CONTAINER="n8n-container-name" 248 | CONF_DATED_BACKUPS=true # Optional, defaults to false 249 | CONF_RESTORE_TYPE="all" # Optional, defaults to 'all' 250 | CONF_VERBOSE=false # Optional, defaults to false 251 | CONF_LOG_FILE="/var/log/n8n-manager.log" # Optional 252 | 253 | Command-line arguments override configuration file settings. 254 | For non-interactive use, required parameters (action, container, token, repo) 255 | can be provided via arguments or the configuration file. 256 | EOF 257 | } 258 | 259 | select_container() { 260 | log HEADER "Selecting n8n container..." 261 | mapfile -t containers < <(docker ps --format "{{.ID}}\t{{.Names}}\t{{.Image}}" 2>/dev/null || true) 262 | 263 | if [ ${#containers[@]} -eq 0 ]; then 264 | log ERROR "No running Docker containers found." 265 | exit 1 266 | fi 267 | 268 | local n8n_options=() 269 | local other_options=() 270 | local all_ids=() 271 | local default_option_num=-1 272 | 273 | log INFO "${BOLD}Available running containers:${NC}" 274 | log INFO "${DIM}------------------------------------------------${NC}" 275 | log INFO "${BOLD}Num\tID (Short)\tName\tImage${NC}" 276 | log INFO "${DIM}------------------------------------------------${NC}" 277 | 278 | local i=1 279 | for container_info in "${containers[@]}"; do 280 | local id name image 281 | IFS=$'\t' read -r id name image <<< "$container_info" 282 | local short_id=${id:0:12} 283 | all_ids+=("$id") 284 | local display_name="$name" 285 | local is_default=false 286 | 287 | if [ -n "$DEFAULT_CONTAINER" ] && { [ "$id" = "$DEFAULT_CONTAINER" ] || [ "$name" = "$DEFAULT_CONTAINER" ]; }; then 288 | is_default=true 289 | default_option_num=$i 290 | display_name="${display_name} ${YELLOW}(default)${NC}" 291 | fi 292 | 293 | local line 294 | if [[ "$image" == *"n8nio/n8n"* || "$name" == *"n8n"* ]]; then 295 | line=$(printf "%s%d)%s %s\t%s\t%s %s(n8n)%s" "$GREEN" "$i" "$NC" "$short_id" "$display_name" "$image" "$YELLOW" "$NC") 296 | n8n_options+=("$line") 297 | else 298 | line=$(printf "%d) %s\t%s\t%s" "$i" "$short_id" "$display_name" "$image") 299 | other_options+=("$line") 300 | fi 301 | i=$((i+1)) 302 | done 303 | 304 | for option in "${n8n_options[@]}"; do echo -e "$option"; done 305 | for option in "${other_options[@]}"; do echo -e "$option"; done 306 | echo -e "${DIM}------------------------------------------------${NC}" 307 | 308 | local selection 309 | local prompt_text="Select container number" 310 | if [ "$default_option_num" -ne -1 ]; then 311 | prompt_text="$prompt_text [default: $default_option_num]" 312 | fi 313 | prompt_text+=": " 314 | 315 | while true; do 316 | printf "$prompt_text" 317 | read -r selection 318 | selection=${selection:-$default_option_num} 319 | 320 | if [[ "$selection" =~ ^[0-9]+$ ]] && [ "$selection" -ge 1 ] && [ "$selection" -le ${#containers[@]} ]; then 321 | local selected_full_id="${all_ids[$((selection-1))]}" 322 | log SUCCESS "Selected container: $selected_full_id" 323 | SELECTED_CONTAINER_ID="$selected_full_id" 324 | return 325 | elif [ -z "$selection" ] && [ "$default_option_num" -ne -1 ]; then 326 | local selected_full_id="${all_ids[$((default_option_num-1))]}" 327 | log SUCCESS "Selected container (default): $selected_full_id" 328 | SELECTED_CONTAINER_ID="$selected_full_id" 329 | return 330 | else 331 | log ERROR "Invalid selection. Please enter a number between 1 and ${#containers[@]}." 332 | fi 333 | done 334 | } 335 | 336 | select_action() { 337 | log HEADER "Choose Action" 338 | echo "1) Backup n8n to GitHub" 339 | echo "2) Restore n8n from GitHub" 340 | echo "3) Quit" 341 | 342 | local choice 343 | while true; do 344 | printf "\nSelect an option (1-3): " 345 | read -r choice 346 | case "$choice" in 347 | 1) SELECTED_ACTION="backup"; return ;; 348 | 2) SELECTED_ACTION="restore"; return ;; 349 | 3) log INFO "Exiting..."; exit 0 ;; 350 | *) log ERROR "Invalid option. Please select 1, 2, or 3." ;; 351 | esac 352 | done 353 | } 354 | 355 | select_restore_type() { 356 | log HEADER "Choose Restore Type" 357 | echo "1) All (Workflows & Credentials)" 358 | echo "2) Workflows Only" 359 | echo "3) Credentials Only" 360 | 361 | local choice 362 | while true; do 363 | printf "\nSelect an option (1-3) [default: 1]: " 364 | read -r choice 365 | choice=${choice:-1} 366 | case "$choice" in 367 | 1) SELECTED_RESTORE_TYPE="all"; return ;; 368 | 2) SELECTED_RESTORE_TYPE="workflows"; return ;; 369 | 3) SELECTED_RESTORE_TYPE="credentials"; return ;; 370 | *) log ERROR "Invalid option. Please select 1, 2, or 3." ;; 371 | esac 372 | done 373 | } 374 | 375 | get_github_config() { 376 | local local_token="$ARG_TOKEN" 377 | local local_repo="$ARG_REPO" 378 | local local_branch="$ARG_BRANCH" 379 | 380 | log HEADER "GitHub Configuration" 381 | 382 | while [ -z "$local_token" ]; do 383 | printf "Enter GitHub Personal Access Token (PAT): " 384 | read -s local_token 385 | echo 386 | if [ -z "$local_token" ]; then log ERROR "GitHub token is required."; fi 387 | done 388 | 389 | while [ -z "$local_repo" ]; do 390 | printf "Enter GitHub repository (format: username/repo): " 391 | read -r local_repo 392 | if [ -z "$local_repo" ] || ! echo "$local_repo" | grep -q "/"; then 393 | log ERROR "Invalid GitHub repository format. It should be 'username/repo'." 394 | local_repo="" 395 | fi 396 | done 397 | 398 | if [ -z "$local_branch" ]; then 399 | printf "Enter Branch to use [main]: " 400 | read -r local_branch 401 | local_branch=${local_branch:-main} 402 | else 403 | log INFO "Using branch: $local_branch" 404 | fi 405 | 406 | GITHUB_TOKEN="$local_token" 407 | GITHUB_REPO="$local_repo" 408 | GITHUB_BRANCH="$local_branch" 409 | } 410 | 411 | check_github_access() { 412 | local token="$1" 413 | local repo="$2" 414 | local branch="$3" 415 | local action_type="$4" # 'backup' or 'restore' 416 | local check_branch_exists=false 417 | if [[ "$action_type" == "restore" ]]; then 418 | check_branch_exists=true 419 | fi 420 | 421 | log HEADER "Checking GitHub Access & Repository Status..." 422 | 423 | # 1. Check Token and Scopes 424 | log INFO "Verifying GitHub token and permissions..." 425 | local scopes 426 | scopes=$(curl -s -I -H "Authorization: token $token" https://api.github.com/user | grep -i '^x-oauth-scopes:' | sed 's/x-oauth-scopes: //i' | tr -d '\r') 427 | local http_status 428 | http_status=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token $token" https://api.github.com/user) 429 | 430 | log DEBUG "Token check HTTP status: $http_status" 431 | log DEBUG "Detected scopes: $scopes" 432 | 433 | if [[ "$http_status" -ne 200 ]]; then 434 | log ERROR "GitHub token is invalid or expired (HTTP Status: $http_status)." 435 | return 1 436 | fi 437 | 438 | if ! echo "$scopes" | grep -qE '(^|,) *repo(,|$)'; then 439 | log ERROR "GitHub token is missing the required 'repo' scope." 440 | log INFO "Please create a new token with the 'repo' scope selected." 441 | return 1 442 | fi 443 | log SUCCESS "GitHub token is valid and has 'repo' scope." 444 | 445 | # 2. Check Repository Existence 446 | log INFO "Verifying repository existence: $repo ..." 447 | http_status=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token $token" "https://api.github.com/repos/$repo") 448 | log DEBUG "Repo check HTTP status: $http_status" 449 | 450 | if [[ "$http_status" -ne 200 ]]; then 451 | log ERROR "Repository '$repo' not found or access denied (HTTP Status: $http_status)." 452 | log INFO "Please check the repository name and ensure the token has access." 453 | return 1 454 | fi 455 | log SUCCESS "Repository '$repo' found and accessible." 456 | 457 | # 3. Check Branch Existence (only if needed) 458 | if $check_branch_exists; then 459 | log INFO "Verifying branch existence: $branch ..." 460 | http_status=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token $token" "https://api.github.com/repos/$repo/branches/$branch") 461 | log DEBUG "Branch check HTTP status: $http_status" 462 | 463 | if [[ "$http_status" -ne 200 ]]; then 464 | log ERROR "Branch '$branch' not found in repository '$repo' (HTTP Status: $http_status)." 465 | log INFO "Please check the branch name." 466 | return 1 467 | fi 468 | log SUCCESS "Branch '$branch' found in repository '$repo'." 469 | fi 470 | 471 | log SUCCESS "GitHub access checks passed." 472 | return 0 473 | } 474 | 475 | dockExec() { 476 | local container_id="$1" 477 | local cmd="$2" 478 | local is_dry_run=$3 479 | local output="" 480 | local exit_code=0 481 | 482 | if $is_dry_run; then 483 | log DRYRUN "Would execute in container $container_id: $cmd" 484 | return 0 485 | else 486 | log DEBUG "Executing in container $container_id: $cmd" 487 | output=$(docker exec "$container_id" sh -c "$cmd" 2>&1) || exit_code=$? 488 | 489 | # Use explicit string comparison to avoid empty command errors 490 | if [ "$ARG_VERBOSE" = "true" ] && [ -n "$output" ]; then 491 | log DEBUG "Container output:\n$(echo "$output" | sed 's/^/ /')" 492 | fi 493 | 494 | if [ $exit_code -ne 0 ]; then 495 | log ERROR "Command failed in container (Exit Code: $exit_code): $cmd" 496 | if [ "$ARG_VERBOSE" != "true" ] && [ -n "$output" ]; then 497 | log ERROR "Container output:\n$(echo "$output" | sed 's/^/ /')" 498 | fi 499 | return 1 500 | fi 501 | 502 | return 0 503 | fi 504 | } 505 | 506 | timestamp() { 507 | date +"%Y-%m-%d_%H-%M-%S" 508 | } 509 | 510 | rollback_restore() { 511 | local container_id="$1" 512 | local backup_dir="$2" 513 | local restore_type="$3" 514 | local is_dry_run=$4 515 | 516 | log WARN "Attempting to roll back to pre-restore state..." 517 | 518 | local backup_workflows="${backup_dir}/workflows.json" 519 | local backup_credentials="${backup_dir}/credentials.json" 520 | local container_workflows="/tmp/rollback_workflows.json" 521 | local container_credentials="/tmp/rollback_credentials.json" 522 | local rollback_success=true 523 | 524 | if [[ "$restore_type" == "all" || "$restore_type" == "workflows" ]] && [ ! -f "$backup_workflows" ]; then 525 | log ERROR "Pre-restore backup file workflows.json not found in $backup_dir. Cannot rollback workflows." 526 | rollback_success=false 527 | fi 528 | if [[ "$restore_type" == "all" || "$restore_type" == "credentials" ]] && [ ! -f "$backup_credentials" ]; then 529 | log ERROR "Pre-restore backup file credentials.json not found in $backup_dir. Cannot rollback credentials." 530 | rollback_success=false 531 | fi 532 | if ! $rollback_success; then return 1; fi 533 | 534 | log INFO "Copying pre-restore backup files back to container..." 535 | local copy_failed=false 536 | if [[ "$restore_type" == "all" || "$restore_type" == "workflows" ]]; then 537 | if $is_dry_run; then 538 | log DRYRUN "Would copy $backup_workflows to ${container_id}:${container_workflows}" 539 | elif ! docker cp "$backup_workflows" "${container_id}:${container_workflows}"; then 540 | log ERROR "Rollback failed: Could not copy workflows back to container." 541 | copy_failed=true 542 | fi 543 | fi 544 | if [[ "$restore_type" == "all" || "$restore_type" == "credentials" ]]; then 545 | if $is_dry_run; then 546 | log DRYRUN "Would copy $backup_credentials to ${container_id}:${container_credentials}" 547 | elif ! docker cp "$backup_credentials" "${container_id}:${container_credentials}"; then 548 | log ERROR "Rollback failed: Could not copy credentials back to container." 549 | copy_failed=true 550 | fi 551 | fi 552 | if $copy_failed; then 553 | dockExec "$container_id" "rm -f $container_workflows $container_credentials" "$is_dry_run" || true 554 | return 1 555 | fi 556 | 557 | log INFO "Importing pre-restore backup data into n8n..." 558 | if [[ "$restore_type" == "all" || "$restore_type" == "workflows" ]]; then 559 | if ! dockExec "$container_id" "n8n import:workflow --separate --input=$container_workflows" "$is_dry_run"; then 560 | log ERROR "Rollback failed during workflow import." 561 | rollback_success=false 562 | fi 563 | fi 564 | if [[ "$restore_type" == "all" || "$restore_type" == "credentials" ]]; then 565 | if ! dockExec "$container_id" "n8n import:credentials --separate --input=$container_credentials" "$is_dry_run"; then 566 | log ERROR "Rollback failed during credential import." 567 | rollback_success=false 568 | fi 569 | fi 570 | 571 | log INFO "Cleaning up rollback files in container..." 572 | dockExec "$container_id" "rm -f $container_workflows $container_credentials" "$is_dry_run" || log WARN "Could not clean up rollback files in container." 573 | 574 | if $rollback_success; then 575 | log SUCCESS "Rollback completed. n8n should be in the state before restore was attempted." 576 | return 0 577 | else 578 | log ERROR "Rollback failed. Manual intervention may be required." 579 | log WARN "Pre-restore backup files are kept at: $backup_dir" 580 | return 1 581 | fi 582 | } 583 | 584 | backup() { 585 | local container_id="$1" 586 | local github_token="$2" 587 | local github_repo="$3" 588 | local branch="$4" 589 | local use_dated_backup=$5 590 | local is_dry_run=$6 591 | 592 | log HEADER "Performing Backup to GitHub" 593 | if $is_dry_run; then log WARN "DRY RUN MODE ENABLED - NO CHANGES WILL BE MADE"; fi 594 | 595 | local tmp_dir 596 | tmp_dir=$(mktemp -d -t n8n-backup-XXXXXXXXXX) 597 | log DEBUG "Created temporary directory: $tmp_dir" 598 | 599 | local container_workflows="/tmp/workflows.json" 600 | local container_credentials="/tmp/credentials.json" 601 | local container_env="/tmp/.env" 602 | 603 | # --- Git Setup First --- 604 | log INFO "Preparing Git repository for backup..." 605 | local git_repo_url="https://${github_token}@github.com/${github_repo}.git" 606 | 607 | log DEBUG "Initializing Git repository in $tmp_dir" 608 | if ! git -C "$tmp_dir" init -q; then log ERROR "Git init failed."; rm -rf "$tmp_dir"; return 1; fi 609 | log DEBUG "Adding remote 'origin' with URL $git_repo_url" 610 | if ! git -C "$tmp_dir" remote add origin "$git_repo_url" 2>/dev/null; then 611 | log WARN "Git remote 'origin' already exists. Setting URL..." 612 | if ! git -C "$tmp_dir" remote set-url origin "$git_repo_url"; then log ERROR "Git set-url failed."; rm -rf "$tmp_dir"; return 1; fi 613 | fi 614 | 615 | log INFO "Configuring Git user identity for commit..." 616 | if ! git -C "$tmp_dir" config user.email "n8n-backup-script@localhost"; then log ERROR "Failed to set Git user email."; rm -rf "$tmp_dir"; return 1; fi 617 | if ! git -C "$tmp_dir" config user.name "n8n Backup Script"; then log ERROR "Failed to set Git user name."; rm -rf "$tmp_dir"; return 1; fi 618 | 619 | log INFO "Fetching remote branch '$branch'..." 620 | local branch_exists=true 621 | if ! git -C "$tmp_dir" fetch --depth 1 origin "$branch" 2>/dev/null; then 622 | log WARN "Branch '$branch' not found on remote or repo is empty. Will create branch." 623 | branch_exists=false 624 | if ! $is_dry_run; then 625 | if ! git -C "$tmp_dir" checkout -b "$branch"; then log ERROR "Git checkout -b failed."; rm -rf "$tmp_dir"; return 1; fi 626 | else 627 | log DRYRUN "Would create and checkout new branch '$branch'" 628 | fi 629 | else 630 | if ! $is_dry_run; then 631 | if ! git -C "$tmp_dir" checkout "$branch"; then log ERROR "Git checkout failed."; rm -rf "$tmp_dir"; return 1; fi 632 | else 633 | log DRYRUN "Would checkout existing branch '$branch'" 634 | fi 635 | fi 636 | log SUCCESS "Git repository initialized and branch '$branch' checked out." 637 | 638 | # --- Export Data --- 639 | log INFO "Exporting data from n8n container..." 640 | local export_failed=false 641 | local no_data_found=false 642 | 643 | # Export workflows 644 | if ! dockExec "$container_id" "n8n export:workflow --all --output=$container_workflows" false; then 645 | # Check if the error is due to no workflows existing 646 | if docker exec "$container_id" n8n list workflows 2>&1 | grep -q "No workflows found"; then 647 | log INFO "No workflows found to backup - this is a clean installation" 648 | no_data_found=true 649 | else 650 | log ERROR "Failed to export workflows" 651 | export_failed=true 652 | fi 653 | fi 654 | 655 | # Export credentials 656 | if ! dockExec "$container_id" "n8n export:credentials --all --decrypted --output=$container_credentials" false; then 657 | # Check if the error is due to no credentials existing 658 | if docker exec "$container_id" n8n list credentials 2>&1 | grep -q "No credentials found"; then 659 | log INFO "No credentials found to backup - this is a clean installation" 660 | no_data_found=true 661 | else 662 | log ERROR "Failed to export credentials" 663 | export_failed=true 664 | fi 665 | fi 666 | 667 | if $export_failed; then 668 | log ERROR "Failed to export data from n8n" 669 | rm -rf "$tmp_dir" 670 | return 1 671 | fi 672 | 673 | # Handle environment variables 674 | if ! dockExec "$container_id" "printenv | grep ^N8N_ > $container_env" false; then 675 | log WARN "Could not capture N8N_ environment variables from container." 676 | fi 677 | 678 | # If no data was found, create empty files to maintain backup structure 679 | if $no_data_found; then 680 | log INFO "Creating empty backup files for clean installation..." 681 | if ! docker exec "$container_id" test -f "$container_workflows"; then 682 | echo "[]" | docker exec -i "$container_id" sh -c "cat > $container_workflows" 683 | fi 684 | if ! docker exec "$container_id" test -f "$container_credentials"; then 685 | echo "[]" | docker exec -i "$container_id" sh -c "cat > $container_credentials" 686 | fi 687 | fi 688 | 689 | # --- Determine Target Directory and Copy --- 690 | local target_dir="$tmp_dir" 691 | local backup_timestamp="" 692 | if [ "$use_dated_backup" = "true" ]; then 693 | backup_timestamp="backup_$(timestamp)" 694 | target_dir="${tmp_dir}/${backup_timestamp}" 695 | log INFO "Using dated backup directory: $backup_timestamp" 696 | if [ "$is_dry_run" = "true" ]; then 697 | log DRYRUN "Would create directory: $target_dir" 698 | elif ! mkdir -p "$target_dir"; then 699 | log ERROR "Failed to create dated backup directory: $target_dir" 700 | rm -rf "$tmp_dir" 701 | return 1 702 | fi 703 | fi 704 | 705 | log INFO "Copying exported files from container into Git directory..." 706 | local copy_status="success" # Use string instead of boolean to avoid empty command errors 707 | for file in workflows.json credentials.json .env; do 708 | source_file="/tmp/${file}" 709 | if [ "$use_dated_backup" = "true" ]; then 710 | # Create timestamped subdirectory 711 | mkdir -p "${target_dir}" || return 1 712 | dest_file="${target_dir}/${file}" 713 | else 714 | dest_file="${tmp_dir}/${file}" 715 | fi 716 | 717 | # Check if file exists in container 718 | if ! docker exec "$container_id" test -f "$source_file"; then 719 | if [[ "$file" == ".env" ]]; then 720 | log WARN ".env file not found in container, skipping." 721 | continue 722 | else 723 | log ERROR "Required file $file not found in container" 724 | copy_status="failed" 725 | continue 726 | fi 727 | fi 728 | 729 | # Copy file from container 730 | size=$(docker exec "$container_id" du -h "$source_file" | awk '{print $1}') 731 | if ! docker cp "${container_id}:${source_file}" "${dest_file}"; then 732 | log ERROR "Failed to copy $file from container" 733 | copy_status="failed" 734 | continue 735 | fi 736 | log SUCCESS "Successfully copied $size to ${dest_file}" 737 | 738 | # Force Git to see changes by updating a separate timestamp file instead of modifying the JSON files 739 | # This preserves the integrity of the n8n files for restore operations 740 | 741 | # Create or update the timestamp file in the same directory 742 | local ts_file="${tmp_dir}/backup_timestamp.txt" 743 | echo "Backup generated at: $(date +"%Y-%m-%d %H:%M:%S.%N")" > "$ts_file" 744 | log DEBUG "Created timestamp file $ts_file to track backup uniqueness" 745 | done 746 | 747 | # Check if any copy operations failed 748 | if [ "$copy_status" = "failed" ]; then 749 | log ERROR "Copy operations failed, aborting backup" 750 | rm -rf "$tmp_dir" 751 | return 1 752 | fi 753 | 754 | log INFO "Cleaning up temporary files in container..." 755 | dockExec "$container_id" "rm -f $container_workflows $container_credentials $container_env" "$is_dry_run" || log WARN "Could not clean up temporary files in container." 756 | 757 | # --- Git Commit and Push --- 758 | log INFO "Adding files to Git..." 759 | 760 | if $is_dry_run; then 761 | if $use_dated_backup; then 762 | log DRYRUN "Would add dated backup directory '$backup_timestamp' to Git index" 763 | else 764 | log DRYRUN "Would add all files to Git index" 765 | fi 766 | else 767 | # Change to the git directory to avoid parsing issues 768 | cd "$tmp_dir" || { 769 | log ERROR "Failed to change to git directory for add operation"; 770 | rm -rf "$tmp_dir"; 771 | return 1; 772 | } 773 | 774 | if [ "$use_dated_backup" = "true" ]; then 775 | # For dated backups, explicitly add the backup subdirectory 776 | if [ -d "$backup_timestamp" ]; then 777 | log DEBUG "Adding dated backup directory: $backup_timestamp" 778 | 779 | # First list what's in the directory (for debugging) 780 | log DEBUG "Files in backup directory:" 781 | ls -la "$backup_timestamp" || true 782 | 783 | # Add specific directory 784 | if ! git add "$backup_timestamp"; then 785 | log ERROR "Git add failed for dated backup directory" 786 | cd - > /dev/null || true 787 | rm -rf "$tmp_dir" 788 | return 1 789 | fi 790 | else 791 | log ERROR "Backup directory not found: $backup_timestamp" 792 | cd - > /dev/null || true 793 | rm -rf "$tmp_dir" 794 | return 1 795 | fi 796 | else 797 | # Standard repo-root backup 798 | log DEBUG "Adding all files at repository root" 799 | # Explicitly target the timestamp file and specific JSON files to avoid unnecessary files 800 | if ! git add workflows.json credentials.json .env backup_timestamp.txt; then 801 | log ERROR "Git add failed for repository root files" 802 | cd - > /dev/null || true 803 | return 1 804 | fi 805 | fi 806 | 807 | log SUCCESS "Files added to Git successfully" 808 | 809 | # Verify that files were staged correctly 810 | log DEBUG "Staging status:" 811 | git status --short || true 812 | fi 813 | 814 | local n8n_ver 815 | n8n_ver=$(docker exec "$container_id" n8n --version 2>/dev/null || echo "unknown") 816 | log DEBUG "Detected n8n version: $n8n_ver" 817 | 818 | # --- Commit Logic --- 819 | local commit_status="pending" # Use string instead of boolean to avoid empty command errors 820 | log INFO "Committing changes..." 821 | 822 | # Create a timestamp with seconds to ensure uniqueness 823 | local backup_time=$(date +"%Y-%m-%d_%H-%M-%S") 824 | local commit_msg="🛡️ n8n Backup (v$n8n_ver) - $backup_time" 825 | if [ "$use_dated_backup" = "true" ]; then 826 | commit_msg="$commit_msg [$backup_timestamp]" 827 | fi 828 | 829 | # Ensure git identity is configured (important for non-interactive mode) 830 | # This is crucial according to developer notes about Git user identity 831 | if [[ -z "$(git config user.email 2>/dev/null)" ]]; then 832 | log WARN "No Git user.email configured, setting default" 833 | git config user.email "n8n-backup-script@localhost" || true 834 | fi 835 | if [[ -z "$(git config user.name 2>/dev/null)" ]]; then 836 | log WARN "No Git user.name configured, setting default" 837 | git config user.name "n8n Backup Script" || true 838 | fi 839 | 840 | # Force Git to commit by adding a timestamp file to make each backup unique 841 | log DEBUG "Creating timestamp file to ensure backup uniqueness" 842 | echo "Backup generated at: $backup_time" > "./backup_timestamp.txt" 843 | 844 | # Explicitly add all n8n files AND the timestamp file 845 | log DEBUG "Adding all n8n files to Git..." 846 | if [ "$use_dated_backup" = "true" ] && [ -n "$backup_timestamp" ] && [ -d "$backup_timestamp" ]; then 847 | log DEBUG "Adding dated backup directory: $backup_timestamp" 848 | if ! git add "$backup_timestamp" ./backup_timestamp.txt; then 849 | log ERROR "Failed to add dated backup directory" 850 | git status 851 | cd - > /dev/null || true 852 | return 1 853 | fi 854 | else 855 | # Add individual files explicitly to ensure nothing is missed 856 | log DEBUG "Adding individual files to Git" 857 | if ! git add ./backup_timestamp.txt workflows.json credentials.json .env 2>/dev/null; then 858 | log ERROR "Failed to add n8n files" 859 | git status 860 | cd - > /dev/null || true 861 | return 1 862 | fi 863 | fi 864 | 865 | # Skip Git's change detection and always commit 866 | log DEBUG "Committing backup with message: $commit_msg" 867 | if [ "$is_dry_run" = "true" ]; then 868 | log DRYRUN "Would commit with message: $commit_msg" 869 | commit_status="success" # Assume commit would happen in dry run 870 | else 871 | # Force the commit with --allow-empty to ensure it happens 872 | if git commit --allow-empty -m "$commit_msg" 2>/dev/null; then 873 | commit_status="success" # Set flag to indicate commit success 874 | log SUCCESS "Changes committed successfully" 875 | else 876 | log ERROR "Git commit failed" 877 | # Show detailed output in case of failure 878 | git status || true 879 | cd - > /dev/null || true 880 | rm -rf "$tmp_dir" 881 | return 1 882 | fi 883 | fi 884 | 885 | # We'll maintain the directory change until after push completes in the next section 886 | 887 | # --- Push Logic --- 888 | log INFO "Pushing backup to GitHub repository '$github_repo' branch '$branch'..." 889 | 890 | if [ "$is_dry_run" = "true" ]; then 891 | log DRYRUN "Would push branch '$branch' to origin" 892 | return 0 893 | fi 894 | 895 | # Simple approach - we just committed changes successfully 896 | # So we'll push those changes now 897 | cd "$tmp_dir" || { log ERROR "Failed to change to $tmp_dir"; rm -rf "$tmp_dir"; return 1; } 898 | 899 | # Check if git log shows recent commits 900 | last_commit=$(git log -1 --pretty=format:"%H" 2>/dev/null) 901 | if [ -z "$last_commit" ]; then 902 | log ERROR "No commits found to push" 903 | cd - > /dev/null || true 904 | rm -rf "$tmp_dir" 905 | return 1 906 | fi 907 | 908 | # Found a commit, so push it 909 | log DEBUG "Pushing commit $last_commit to origin/$branch" 910 | 911 | # Use a direct git command with full output 912 | if ! git push -u origin "$branch" --verbose; then 913 | log ERROR "Failed to push to GitHub - connectivity issue or permissions problem" 914 | 915 | # Test GitHub connectivity 916 | if ! curl -s -I "https://github.com" > /dev/null; then 917 | log ERROR "Cannot reach GitHub - network connectivity issue" 918 | elif ! curl -s -H "Authorization: token $github_token" "https://api.github.com/user" | grep -q login; then 919 | log ERROR "GitHub API authentication failed - check token permissions" 920 | else 921 | log ERROR "Unknown error pushing to GitHub" 922 | fi 923 | 924 | cd - > /dev/null || true 925 | rm -rf "$tmp_dir" 926 | return 1 927 | fi 928 | 929 | log SUCCESS "Backup successfully pushed to GitHub repository" 930 | cd - > /dev/null || true 931 | 932 | log INFO "Cleaning up host temporary directory..." 933 | if $is_dry_run; then 934 | log DRYRUN "Would remove temporary directory: $tmp_dir" 935 | else 936 | rm -rf "$tmp_dir" 937 | fi 938 | 939 | log SUCCESS "Backup successfully completed and pushed to GitHub." 940 | if $is_dry_run; then log WARN "(Dry run mode was active)"; fi 941 | return 0 942 | } 943 | 944 | restore() { 945 | local container_id="$1" 946 | local github_token="$2" 947 | local github_repo="$3" 948 | local branch="$4" 949 | local restore_type="$5" 950 | local is_dry_run=$6 951 | 952 | log HEADER "Performing Restore from GitHub (Type: $restore_type)" 953 | if $is_dry_run; then log WARN "DRY RUN MODE ENABLED - NO CHANGES WILL BE MADE"; fi 954 | 955 | if [ -t 0 ] && ! $is_dry_run; then 956 | log WARN "This will overwrite existing data (type: $restore_type)." 957 | printf "Are you sure you want to proceed? (yes/no): " 958 | local confirm 959 | read -r confirm 960 | if [[ "$confirm" != "yes" && "$confirm" != "y" ]]; then 961 | log INFO "Restore cancelled by user." 962 | return 0 963 | fi 964 | elif ! $is_dry_run; then 965 | log WARN "Running restore non-interactively (type: $restore_type). Proceeding without confirmation." 966 | fi 967 | 968 | # --- 1. Pre-restore Backup --- 969 | log HEADER "Step 1: Creating Pre-restore Backup" 970 | local pre_restore_dir="" 971 | pre_restore_dir=$(mktemp -d -t n8n-prerestore-XXXXXXXXXX) 972 | log DEBUG "Created pre-restore backup directory: $pre_restore_dir" 973 | 974 | local pre_workflows="${pre_restore_dir}/workflows.json" 975 | local pre_credentials="${pre_restore_dir}/credentials.json" 976 | local container_pre_workflows="/tmp/pre_workflows.json" 977 | local container_pre_credentials="/tmp/pre_credentials.json" 978 | 979 | local backup_failed=false 980 | local no_existing_data=false 981 | log INFO "Exporting current n8n data for backup..." 982 | 983 | # Function to check if output indicates no data 984 | check_no_data() { 985 | local output="$1" 986 | if echo "$output" | grep -q "No workflows found" || echo "$output" | grep -q "No credentials found"; then 987 | return 0 988 | fi 989 | return 1 990 | } 991 | 992 | if [[ "$restore_type" == "all" || "$restore_type" == "workflows" ]]; then 993 | local workflow_output 994 | workflow_output=$(docker exec "$container_id" n8n export:workflow --all --output=$container_pre_workflows 2>&1) || { 995 | if check_no_data "$workflow_output"; then 996 | log INFO "No existing workflows found - this is a clean installation" 997 | no_existing_data=true 998 | # Create empty workflows file 999 | echo "[]" | docker exec -i "$container_id" sh -c "cat > $container_pre_workflows" 1000 | else 1001 | log ERROR "Failed to export workflows: $workflow_output" 1002 | backup_failed=true 1003 | fi 1004 | } 1005 | fi 1006 | 1007 | if [[ "$restore_type" == "all" || "$restore_type" == "credentials" ]]; then 1008 | if ! $backup_failed; then 1009 | local cred_output 1010 | cred_output=$(docker exec "$container_id" n8n export:credentials --all --decrypted --output=$container_pre_credentials 2>&1) || { 1011 | if check_no_data "$cred_output"; then 1012 | log INFO "No existing credentials found - this is a clean installation" 1013 | no_existing_data=true 1014 | # Create empty credentials file 1015 | echo "[]" | docker exec -i "$container_id" sh -c "cat > $container_pre_credentials" 1016 | else 1017 | log ERROR "Failed to export credentials: $cred_output" 1018 | backup_failed=true 1019 | fi 1020 | } 1021 | fi 1022 | fi 1023 | 1024 | if $backup_failed; then 1025 | log WARN "Could not export current data completely. Cannot create pre-restore backup." 1026 | dockExec "$container_id" "rm -f $container_pre_workflows $container_pre_credentials" false || true 1027 | rm -rf "$pre_restore_dir" 1028 | pre_restore_dir="" 1029 | if ! $is_dry_run; then 1030 | log ERROR "Cannot proceed with restore safely without pre-restore backup." 1031 | return 1 1032 | fi 1033 | elif $no_existing_data; then 1034 | log INFO "No existing data found - proceeding with restore without pre-restore backup" 1035 | # Copy the empty files we created to the backup directory 1036 | if [[ "$restore_type" == "all" || "$restore_type" == "workflows" ]]; then 1037 | docker cp "${container_id}:${container_pre_workflows}" "$pre_workflows" || true 1038 | fi 1039 | if [[ "$restore_type" == "all" || "$restore_type" == "credentials" ]]; then 1040 | docker cp "${container_id}:${container_pre_credentials}" "$pre_credentials" || true 1041 | fi 1042 | dockExec "$container_id" "rm -f $container_pre_workflows $container_pre_credentials" false || true 1043 | else 1044 | log INFO "Copying current data to host backup directory..." 1045 | local copy_failed=false 1046 | if [[ "$restore_type" == "all" || "$restore_type" == "workflows" ]]; then 1047 | if $is_dry_run; then 1048 | log DRYRUN "Would copy ${container_id}:${container_pre_workflows} to $pre_workflows" 1049 | elif ! docker cp "${container_id}:${container_pre_workflows}" "$pre_workflows"; then copy_failed=true; fi 1050 | fi 1051 | if [[ "$restore_type" == "all" || "$restore_type" == "credentials" ]]; then 1052 | if $is_dry_run; then 1053 | log DRYRUN "Would copy ${container_id}:${container_pre_credentials} to $pre_credentials" 1054 | elif ! docker cp "${container_id}:${container_pre_credentials}" "$pre_credentials"; then copy_failed=true; fi 1055 | fi 1056 | 1057 | dockExec "$container_id" "rm -f $container_pre_workflows $container_pre_credentials" "$is_dry_run" || true 1058 | 1059 | if $copy_failed; then 1060 | log ERROR "Failed to copy backup files from container. Cannot proceed with restore safely." 1061 | rm -rf "$pre_restore_dir" 1062 | return 1 1063 | else 1064 | log SUCCESS "Pre-restore backup created successfully." 1065 | fi 1066 | fi 1067 | 1068 | # --- 2. Fetch from GitHub --- 1069 | log HEADER "Step 2: Fetching Backup from GitHub" 1070 | local download_dir 1071 | download_dir=$(mktemp -d -t n8n-download-XXXXXXXXXX) 1072 | log DEBUG "Created download directory: $download_dir" 1073 | 1074 | local git_repo_url="https://${github_token}@github.com/${github_repo}.git" 1075 | log INFO "Cloning repository $github_repo branch $branch..." 1076 | 1077 | log DEBUG "Running: git clone --depth 1 --branch $branch $git_repo_url $download_dir" 1078 | if ! git clone --depth 1 --branch "$branch" "$git_repo_url" "$download_dir"; then 1079 | log ERROR "Failed to clone repository. Check URL, token, branch, and permissions." 1080 | rm -rf "$download_dir" 1081 | if [ -n "$pre_restore_dir" ]; then log WARN "Pre-restore backup kept at: $pre_restore_dir"; fi 1082 | return 1 1083 | fi 1084 | 1085 | # Check if the restore should come from a dated backup directory 1086 | local dated_backup_found=false 1087 | local selected_backup="" 1088 | local backup_dirs=() 1089 | 1090 | # Look for dated backup directories 1091 | cd "$download_dir" || { 1092 | log ERROR "Failed to change to download directory"; 1093 | rm -rf "$download_dir"; 1094 | if [ -n "$pre_restore_dir" ]; then log WARN "Pre-restore backup kept at: $pre_restore_dir"; fi; 1095 | return 1; 1096 | } 1097 | 1098 | # Debug - show what files exist in the repository 1099 | log DEBUG "Repository root contents:" 1100 | ls -la "$download_dir" || true 1101 | find "$download_dir" -type f -name "*.json" | sort || true 1102 | 1103 | # Find all backup_* directories and sort them by date (newest first) 1104 | readarray -t backup_dirs < <(find . -type d -name "backup_*" | sort -r) 1105 | 1106 | if [ ${#backup_dirs[@]} -gt 0 ]; then 1107 | log INFO "Found ${#backup_dirs[@]} dated backup(s):" 1108 | 1109 | # If non-interactive mode, automatically select the most recent backup 1110 | if ! [ -t 0 ]; then 1111 | selected_backup="${backup_dirs[0]}" 1112 | dated_backup_found=true 1113 | log INFO "Auto-selecting most recent backup in non-interactive mode: $selected_backup" 1114 | else 1115 | # Interactive mode - show menu with newest backups first 1116 | echo "" 1117 | echo "Select a backup to restore:" 1118 | echo "------------------------------------------------" 1119 | echo "0) Use files from repository root (not a dated backup)" 1120 | 1121 | for i in "${!backup_dirs[@]}"; do 1122 | # Extract the date part from backup_YYYY-MM-DD_HH-MM-SS format 1123 | local backup_date="${backup_dirs[$i]#./backup_}" 1124 | echo "$((i+1))) ${backup_date} (${backup_dirs[$i]})" 1125 | done 1126 | echo "------------------------------------------------" 1127 | 1128 | local valid_selection=false 1129 | while ! $valid_selection; do 1130 | echo -n "Select a backup number (0-${#backup_dirs[@]}): " 1131 | local selection 1132 | read -r selection 1133 | 1134 | if [[ "$selection" =~ ^[0-9]+$ ]] && [ "$selection" -le "${#backup_dirs[@]}" ]; then 1135 | valid_selection=true 1136 | 1137 | if [ "$selection" -eq 0 ]; then 1138 | log INFO "Using repository root files (not a dated backup)" 1139 | else 1140 | selected_backup="${backup_dirs[$((selection-1))]}" 1141 | dated_backup_found=true 1142 | log INFO "Selected backup: $selected_backup" 1143 | fi 1144 | else 1145 | echo "Invalid selection. Please enter a number between 0 and ${#backup_dirs[@]}." 1146 | fi 1147 | done 1148 | fi 1149 | fi 1150 | 1151 | # EMERGENCY DIRECT APPROACH: Use the files from repository without complex validation 1152 | log INFO "Direct approach: Using files straight from repository..." 1153 | 1154 | # Set up container import paths 1155 | local container_import_workflows="/tmp/import_workflows.json" 1156 | local container_import_credentials="/tmp/import_credentials.json" 1157 | 1158 | # Find the workflow and credentials files directly 1159 | local repo_workflows="" 1160 | local repo_credentials="" 1161 | 1162 | # First try dated backup if specified 1163 | if $dated_backup_found; then 1164 | local dated_path="${selected_backup#./}" 1165 | log INFO "Looking for files in dated backup: $dated_path" 1166 | 1167 | if [ -f "${download_dir}/${dated_path}/workflows.json" ]; then 1168 | repo_workflows="${download_dir}/${dated_path}/workflows.json" 1169 | log SUCCESS "Found workflows.json in dated backup directory" 1170 | fi 1171 | 1172 | if [ -f "${download_dir}/${dated_path}/credentials.json" ]; then 1173 | repo_credentials="${download_dir}/${dated_path}/credentials.json" 1174 | log SUCCESS "Found credentials.json in dated backup directory" 1175 | fi 1176 | fi 1177 | 1178 | # Fall back to repository root if files weren't found in dated backup 1179 | if [ -z "$repo_workflows" ] && [ -f "${download_dir}/workflows.json" ]; then 1180 | repo_workflows="${download_dir}/workflows.json" 1181 | log SUCCESS "Found workflows.json in repository root" 1182 | fi 1183 | 1184 | if [ -z "$repo_credentials" ] && [ -f "${download_dir}/credentials.json" ]; then 1185 | repo_credentials="${download_dir}/credentials.json" 1186 | log SUCCESS "Found credentials.json in repository root" 1187 | fi 1188 | 1189 | # Display file sizes for debug purposes 1190 | if [ -n "$repo_workflows" ]; then 1191 | log DEBUG "Workflow file size: $(du -h "$repo_workflows" | cut -f1)" 1192 | fi 1193 | 1194 | if [ -n "$repo_credentials" ]; then 1195 | log DEBUG "Credentials file size: $(du -h "$repo_credentials" | cut -f1)" 1196 | fi 1197 | 1198 | # Proceed directly to import phase 1199 | log INFO "Validating files for import..." 1200 | local file_validation_passed=true 1201 | 1202 | # More robust file checking logic 1203 | if [[ "$restore_type" == "all" || "$restore_type" == "workflows" ]]; then 1204 | if [ ! -f "$repo_workflows" ] || [ ! -s "$repo_workflows" ]; then 1205 | log ERROR "Valid workflows.json not found for $restore_type restore" 1206 | file_validation_passed=false 1207 | else 1208 | log SUCCESS "Workflows file validated for import" 1209 | fi 1210 | fi 1211 | 1212 | if [[ "$restore_type" == "all" || "$restore_type" == "credentials" ]]; then 1213 | if [ ! -f "$repo_credentials" ] || [ ! -s "$repo_credentials" ]; then 1214 | log ERROR "Valid credentials.json not found for $restore_type restore" 1215 | file_validation_passed=false 1216 | else 1217 | log SUCCESS "Credentials file validated for import" 1218 | fi 1219 | fi 1220 | 1221 | # Always use explicit comparison for clarity and to avoid empty commands 1222 | if [ "$file_validation_passed" != "true" ]; then 1223 | log ERROR "File validation failed. Cannot proceed with restore." 1224 | log DEBUG "Repository contents (excluding .git):" 1225 | find "$download_dir" -type f -not -path "*/\.git/*" | sort || true 1226 | rm -rf "$download_dir" 1227 | if [ -n "$pre_restore_dir" ]; then log WARN "Pre-restore backup kept at: $pre_restore_dir"; fi 1228 | return 1 1229 | fi 1230 | 1231 | log SUCCESS "All required files validated successfully." 1232 | 1233 | # Skip temp directory completely and copy directly to container 1234 | log INFO "Copying downloaded files directly to container..." 1235 | 1236 | local copy_status="success" # Use string instead of boolean to avoid empty command errors 1237 | 1238 | # Copy workflow file if needed 1239 | if [[ "$restore_type" == "all" || "$restore_type" == "workflows" ]]; then 1240 | if [ "$is_dry_run" = "true" ]; then 1241 | log DRYRUN "Would copy $repo_workflows to ${container_id}:${container_import_workflows}" 1242 | else 1243 | log INFO "Copying workflows file to container..." 1244 | if docker cp "$repo_workflows" "${container_id}:${container_import_workflows}"; then 1245 | log SUCCESS "Successfully copied workflows.json to container" 1246 | else 1247 | log ERROR "Failed to copy workflows.json to container." 1248 | copy_status="failed" 1249 | fi 1250 | fi 1251 | fi 1252 | 1253 | # Copy credentials file if needed 1254 | if [[ "$restore_type" == "all" || "$restore_type" == "credentials" ]]; then 1255 | if [ "$is_dry_run" = "true" ]; then 1256 | log DRYRUN "Would copy $repo_credentials to ${container_id}:${container_import_credentials}" 1257 | else 1258 | log INFO "Copying credentials file to container..." 1259 | if docker cp "$repo_credentials" "${container_id}:${container_import_credentials}"; then 1260 | log SUCCESS "Successfully copied credentials.json to container" 1261 | else 1262 | log ERROR "Failed to copy credentials.json to container." 1263 | copy_status="failed" 1264 | fi 1265 | fi 1266 | fi 1267 | 1268 | # Check copy status with explicit string comparison 1269 | if [ "$copy_status" = "failed" ]; then 1270 | log ERROR "Failed to copy files to container - cannot proceed with restore" 1271 | rm -rf "$download_dir" 1272 | if [ -n "$pre_restore_dir" ]; then log WARN "Pre-restore backup kept at: $pre_restore_dir"; fi 1273 | return 1 1274 | fi 1275 | 1276 | log SUCCESS "All files copied to container successfully." 1277 | 1278 | # Handle import directly here to avoid another set of checks 1279 | log INFO "Importing data into n8n..." 1280 | local import_status="success" 1281 | 1282 | # Import workflows if needed 1283 | if [[ "$restore_type" == "all" || "$restore_type" == "workflows" ]]; then 1284 | if [ "$is_dry_run" = "true" ]; then 1285 | log DRYRUN "Would run: n8n import:workflow --input=$container_import_workflows" 1286 | else 1287 | log INFO "Importing workflows..." 1288 | local import_output 1289 | import_output=$(docker exec "$container_id" n8n import:workflow --input=$container_import_workflows 2>&1) || { 1290 | # Check for specific error conditions 1291 | if echo "$import_output" | grep -q "already exists"; then 1292 | log WARN "Some workflows already exist - attempting to update them..." 1293 | if ! dockExec "$container_id" "n8n import:workflow --input=$container_import_workflows --force" "$is_dry_run"; then 1294 | log ERROR "Failed to import/update workflows" 1295 | import_status="failed" 1296 | else 1297 | log SUCCESS "Workflows imported/updated successfully" 1298 | fi 1299 | else 1300 | log ERROR "Failed to import workflows: $import_output" 1301 | import_status="failed" 1302 | fi 1303 | } 1304 | fi 1305 | fi 1306 | 1307 | # Import credentials if needed 1308 | if [[ "$restore_type" == "all" || "$restore_type" == "credentials" ]]; then 1309 | if [ "$is_dry_run" = "true" ]; then 1310 | log DRYRUN "Would run: n8n import:credentials --input=$container_import_credentials" 1311 | else 1312 | log INFO "Importing credentials..." 1313 | local import_output 1314 | import_output=$(docker exec "$container_id" n8n import:credentials --input=$container_import_credentials 2>&1) || { 1315 | # Check for specific error conditions 1316 | if echo "$import_output" | grep -q "already exists"; then 1317 | log WARN "Some credentials already exist - attempting to update them..." 1318 | if ! dockExec "$container_id" "n8n import:credentials --input=$container_import_credentials --force" "$is_dry_run"; then 1319 | log ERROR "Failed to import/update credentials" 1320 | import_status="failed" 1321 | else 1322 | log SUCCESS "Credentials imported/updated successfully" 1323 | fi 1324 | else 1325 | log ERROR "Failed to import credentials: $import_output" 1326 | import_status="failed" 1327 | fi 1328 | } 1329 | fi 1330 | fi 1331 | 1332 | # Clean up temporary files in container 1333 | if [ "$is_dry_run" != "true" ]; then 1334 | log INFO "Cleaning up temporary files in container..." 1335 | # Try a more Alpine-friendly approach - first check if files exist 1336 | if dockExec "$container_id" "[ -f $container_import_workflows ] && echo 'Workflow file exists'" "$is_dry_run"; then 1337 | # Try with ash shell explicitly (common in Alpine) 1338 | dockExec "$container_id" "ash -c 'rm -f $container_import_workflows 2>/dev/null || true'" "$is_dry_run" || true 1339 | log DEBUG "Attempted cleanup of workflow import file" 1340 | fi 1341 | 1342 | if dockExec "$container_id" "[ -f $container_import_credentials ] && echo 'Credentials file exists'" "$is_dry_run"; then 1343 | # Try with ash shell explicitly (common in Alpine) 1344 | dockExec "$container_id" "ash -c 'rm -f $container_import_credentials 2>/dev/null || true'" "$is_dry_run" || true 1345 | log DEBUG "Attempted cleanup of credentials import file" 1346 | fi 1347 | 1348 | log INFO "Temporary files in container will be automatically removed when container restarts" 1349 | fi 1350 | 1351 | # Cleanup downloaded repository 1352 | rm -rf "$download_dir" 1353 | 1354 | # Handle restore result based on import status 1355 | if [ "$import_status" = "failed" ]; then 1356 | log WARN "Restore partially completed with some errors. Check logs for details." 1357 | if [ -n "$pre_restore_dir" ]; then 1358 | log WARN "Pre-restore backup kept at: $pre_restore_dir" 1359 | fi 1360 | return 1 1361 | fi 1362 | 1363 | # Success - restore completed successfully 1364 | log SUCCESS "Restore completed successfully!" 1365 | 1366 | # Clean up pre-restore backup if successful 1367 | if [ -n "$pre_restore_dir" ] && [ "$is_dry_run" != "true" ]; then 1368 | rm -rf "$pre_restore_dir" 1369 | log INFO "Pre-restore backup cleaned up." 1370 | fi 1371 | 1372 | return 0 # Explicitly return success 1373 | } 1374 | 1375 | # --- Main Function --- 1376 | main() { 1377 | # Parse command-line arguments first 1378 | while [ $# -gt 0 ]; do 1379 | case "$1" in 1380 | --action) ARG_ACTION="$2"; shift 2 ;; 1381 | --container) ARG_CONTAINER="$2"; shift 2 ;; 1382 | --token) ARG_TOKEN="$2"; shift 2 ;; 1383 | --repo) ARG_REPO="$2"; shift 2 ;; 1384 | --branch) ARG_BRANCH="$2"; shift 2 ;; 1385 | --config) ARG_CONFIG_FILE="$2"; shift 2 ;; 1386 | --dated) ARG_DATED_BACKUPS=true; shift 1 ;; 1387 | --restore-type) 1388 | if [[ "$2" == "all" || "$2" == "workflows" || "$2" == "credentials" ]]; then 1389 | ARG_RESTORE_TYPE="$2" 1390 | else 1391 | echo -e "${RED}[ERROR]${NC} Invalid --restore-type: '$2'. Must be 'all', 'workflows', or 'credentials'." >&2 1392 | exit 1 1393 | fi 1394 | shift 2 ;; 1395 | --dry-run) ARG_DRY_RUN=true; shift 1 ;; 1396 | --verbose) ARG_VERBOSE=true; shift 1 ;; 1397 | --log-file) ARG_LOG_FILE="$2"; shift 2 ;; 1398 | --trace) DEBUG_TRACE=true; shift 1;; 1399 | -h|--help) show_help; exit 0 ;; 1400 | *) echo -e "${RED}[ERROR]${NC} Invalid option: $1" >&2; show_help; exit 1 ;; 1401 | esac 1402 | done 1403 | 1404 | # Load config file (must happen after parsing args) 1405 | load_config 1406 | 1407 | log HEADER "n8n Backup/Restore Manager v$VERSION" 1408 | if [ "$ARG_DRY_RUN" = "true" ]; then log WARN "DRY RUN MODE ENABLED"; fi 1409 | if [ "$ARG_VERBOSE" = "true" ]; then log DEBUG "Verbose mode enabled."; fi 1410 | 1411 | check_host_dependencies 1412 | 1413 | # Use local variables within main 1414 | local action="$ARG_ACTION" 1415 | local container_id="$ARG_CONTAINER" 1416 | local github_token="$ARG_TOKEN" 1417 | local github_repo="$ARG_REPO" 1418 | local branch="${ARG_BRANCH:-main}" 1419 | local use_dated_backup=$ARG_DATED_BACKUPS 1420 | local restore_type="${ARG_RESTORE_TYPE:-all}" 1421 | local is_dry_run=$ARG_DRY_RUN 1422 | 1423 | log DEBUG "Initial Action: $action" 1424 | log DEBUG "Initial Container: $container_id" 1425 | log DEBUG "Initial Repo: $github_repo" 1426 | log DEBUG "Initial Branch: $branch" 1427 | log DEBUG "Initial Dated Backup: $use_dated_backup" 1428 | log DEBUG "Initial Restore Type: $restore_type" 1429 | log DEBUG "Initial Dry Run: $is_dry_run" 1430 | log DEBUG "Initial Verbose: $ARG_VERBOSE" 1431 | log DEBUG "Initial Log File: $ARG_LOG_FILE" 1432 | 1433 | # Check if running non-interactively 1434 | if ! [ -t 0 ]; then 1435 | log DEBUG "Running in non-interactive mode." 1436 | if { [ -z "$action" ] || [ -z "$container_id" ] || [ -z "$github_token" ] || [ -z "$github_repo" ]; }; then 1437 | log ERROR "Running in non-interactive mode but required parameters are missing." 1438 | log INFO "Please provide --action, --container, --token, and --repo via arguments or config file." 1439 | show_help 1440 | exit 1 1441 | fi 1442 | log DEBUG "Validating non-interactive container: $container_id" 1443 | local found_id 1444 | found_id=$(docker ps -q --filter "id=$container_id" --filter "name=$container_id" | head -n 1) 1445 | if [ -z "$found_id" ]; then 1446 | log ERROR "Container '$container_id' not found or not running." 1447 | exit 1 1448 | fi 1449 | container_id=$found_id 1450 | log SUCCESS "Using specified container: $container_id" 1451 | 1452 | else 1453 | log DEBUG "Running in interactive mode." 1454 | if [ -z "$action" ]; then 1455 | select_action 1456 | action="$SELECTED_ACTION" 1457 | fi 1458 | log DEBUG "Action selected: $action" 1459 | 1460 | if [ -z "$container_id" ]; then 1461 | select_container 1462 | container_id="$SELECTED_CONTAINER_ID" 1463 | else 1464 | log DEBUG "Validating specified container: $container_id" 1465 | local found_id 1466 | found_id=$(docker ps -q --filter "id=$container_id" --filter "name=$container_id" | head -n 1) 1467 | if [ -z "$found_id" ]; then 1468 | log ERROR "Container '$container_id' not found or not running." 1469 | log WARN "Falling back to interactive container selection..." 1470 | select_container 1471 | container_id="$SELECTED_CONTAINER_ID" 1472 | else 1473 | container_id=$found_id 1474 | log SUCCESS "Using specified container: $container_id" 1475 | fi 1476 | fi 1477 | log DEBUG "Container selected: $container_id" 1478 | 1479 | get_github_config 1480 | github_token="$GITHUB_TOKEN" 1481 | github_repo="$GITHUB_REPO" 1482 | branch="$GITHUB_BRANCH" 1483 | log DEBUG "GitHub Token: ****" 1484 | log DEBUG "GitHub Repo: $github_repo" 1485 | log DEBUG "GitHub Branch: $branch" 1486 | 1487 | if [[ "$action" == "backup" ]] && ! $use_dated_backup && ! grep -q "CONF_DATED_BACKUPS=true" "${ARG_CONFIG_FILE:-$CONFIG_FILE_PATH}" 2>/dev/null; then 1488 | printf "Create a dated backup (in a timestamped subdirectory)? (yes/no) [no]: " 1489 | local confirm_dated 1490 | read -r confirm_dated 1491 | if [[ "$confirm_dated" == "yes" || "$confirm_dated" == "y" ]]; then 1492 | use_dated_backup=true 1493 | fi 1494 | fi 1495 | log DEBUG "Use Dated Backup: $use_dated_backup" 1496 | 1497 | if [[ "$action" == "restore" ]] && [[ "$restore_type" == "all" ]] && ! grep -q "CONF_RESTORE_TYPE=" "${ARG_CONFIG_FILE:-$CONFIG_FILE_PATH}" 2>/dev/null; then 1498 | select_restore_type 1499 | restore_type="$SELECTED_RESTORE_TYPE" 1500 | elif [[ "$action" == "restore" ]]; then 1501 | log INFO "Using restore type: $restore_type" 1502 | fi 1503 | log DEBUG "Restore Type: $restore_type" 1504 | fi 1505 | 1506 | # Final validation 1507 | if [ -z "$action" ] || [ -z "$container_id" ] || [ -z "$github_token" ] || [ -z "$github_repo" ] || [ -z "$branch" ]; then 1508 | log ERROR "Missing required parameters (Action, Container, Token, Repo, Branch). Exiting." 1509 | exit 1 1510 | fi 1511 | 1512 | # Perform GitHub API pre-checks (skip in dry run? No, checks are read-only) 1513 | if ! check_github_access "$github_token" "$github_repo" "$branch" "$action"; then 1514 | log ERROR "GitHub access pre-checks failed. Aborting." 1515 | exit 1 1516 | fi 1517 | 1518 | # Execute action 1519 | log INFO "Starting action: $action" 1520 | case "$action" in 1521 | backup) 1522 | if backup "$container_id" "$github_token" "$github_repo" "$branch" "$use_dated_backup" "$is_dry_run"; then 1523 | log SUCCESS "Backup operation completed successfully." 1524 | else 1525 | log ERROR "Backup operation failed." 1526 | exit 1 1527 | fi 1528 | ;; 1529 | restore) 1530 | if restore "$container_id" "$github_token" "$github_repo" "$branch" "$restore_type" "$is_dry_run"; then 1531 | log SUCCESS "Restore operation completed successfully." 1532 | else 1533 | log ERROR "Restore operation failed." 1534 | exit 1 1535 | fi 1536 | ;; 1537 | *) 1538 | log ERROR "Invalid action specified: $action. Use 'backup' or 'restore'." 1539 | exit 1 1540 | ;; 1541 | esac 1542 | 1543 | exit 0 1544 | } 1545 | 1546 | # --- Script Execution --- 1547 | 1548 | # Trap for unexpected errors 1549 | trap 'log ERROR "An unexpected error occurred (Line: $LINENO). Aborting."; exit 1' ERR 1550 | 1551 | # Execute main function, passing all script arguments 1552 | main "$@" 1553 | 1554 | exit 0 1555 | 1556 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # n8n-manager: Backup & Restore for n8n Docker via GitHub 2 | **Current Version: 3.0.5** 3 | 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | 6 | ![Banner](.github/images/Banner.png) 7 | 8 | `n8n-manager` is a robust command-line tool designed to simplify the backup and restore process for your [n8n](https://n8n.io/) instances running in Docker containers. It leverages Git and GitHub to securely store and manage your n8n workflows, credentials, and environment variables. 9 | 10 | This script provides both interactive and non-interactive modes, making it suitable for manual use and automation/CI/CD pipelines. 11 | 12 | ## ✨ Features 13 | 14 | * **Interactive Mode:** User-friendly menus guide you through selecting containers and actions. 15 | * **Non-Interactive Mode:** Fully automatable via command-line arguments, perfect for scripting. 16 | * **GitHub Integration:** Backs up n8n data (workflows, credentials, environment variables) to a private or public GitHub repository. 17 | * **Backup Options:** 18 | * **Standard Backup:** Overwrites the latest backup files on the specified branch. 19 | * **Dated Backups (`--dated`):** Creates timestamped subdirectories (e.g., `backup_YYYY-MM-DD_HH-MM-SS/`) for each backup, preserving history. 20 | * **Restore Options:** 21 | * **Full Restore:** Restores both workflows and credentials. 22 | * **Selective Restore (`--restore-type`):** Restore only `workflows` or only `credentials`. 23 | * **Container Compatibility:** 24 | * **Alpine Support:** Fully compatible with n8n containers based on Alpine Linux. 25 | * **Ubuntu Support:** Works seamlessly with containers based on Ubuntu/Debian. 26 | * **Safety First:** 27 | * **Pre-Restore Backup:** Automatically creates a temporary local backup of current data before starting a restore. 28 | * **Automatic Rollback:** If the restore process fails, the script attempts to automatically roll back to the pre-restore state. 29 | * **GitHub Pre-Checks:** Verifies GitHub token validity, required scopes (`repo`), repository existence, and branch existence (for restore) before proceeding. 30 | * **Dry Run Mode (`--dry-run`):** Simulate backup or restore operations without making any actual changes to your n8n instance or GitHub repository. 31 | * **Robust Error Handling:** 32 | * **Shell-Safe Operations:** All operations use explicit string comparisons and proper error checks to avoid common shell pitfalls. 33 | * **Descriptive Error Messages:** Clear error messaging with specific details about what went wrong. 34 | * **Improved File Validation:** Smart checks ensure n8n files are valid before attempting import operations. 35 | * **Configuration File:** Store default settings (token, repo, container, etc.) in `~/.config/n8n-manager/config` for convenience. 36 | * **Enhanced Logging:** 37 | * Clear, colored output for interactive use. 38 | * Verbose/Debug mode (`--verbose`) for detailed troubleshooting. 39 | * Option to log all output to a file (`--log-file`). 40 | * Trace mode (`--trace`) for in-depth debugging. 41 | * **Dependency Checks:** Verifies required tools (Docker, Git, curl) are installed on the host. 42 | * **Container Detection:** Automatically detects running n8n containers. 43 | 44 | ## 📋 Prerequisites 45 | 46 | * **Host Machine:** 47 | * Linux environment (tested on Ubuntu, should work on most distributions). 48 | * `docker`: To interact with the n8n container. 49 | * `git`: To interact with the GitHub repository. 50 | * `curl`: To perform GitHub API pre-checks. 51 | * `bash`: The script interpreter. 52 | * **n8n Container:** 53 | * Must be running. 54 | * Must be based on an official n8n image (or include the `n8n` CLI tool). 55 | * The `git` command is *not* required inside the container. 56 | * **GitHub:** 57 | * A GitHub account. 58 | * A GitHub repository (private recommended) to store the backups. 59 | * A [GitHub Personal Access Token (PAT)](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) with the `repo` scope enabled. This scope is necessary to access repositories (both public and private) and push changes. 60 | 61 | ## 🚀 Installation 62 | 63 | You can install `n8n-manager` using the provided installation script. This will download the main script and place it in `/usr/local/bin` for easy system-wide access. 64 | 65 | **Note:** You need `curl` and `sudo` (or run as root) for the installation. 66 | 67 | ```bash 68 | curl -sSL https://raw.githubusercontent.com/Automations-Project/n8n-data-manager/main/install.sh | sudo bash 69 | ``` 70 | 71 | Alternatively, you can download the `n8n-manager.sh` script manually, make it executable (`chmod +x n8n-manager.sh`), and run it directly (`./n8n-manager.sh`) or place it in your desired `$PATH` directory. 72 | 73 | ## ⚙️ Configuration File (Optional) 74 | 75 | For convenience, you can create a configuration file to store default settings. The script looks for this file at `~/.config/n8n-manager/config` by default. You can specify a different path using the `--config` argument. 76 | 77 | Create the directory if it doesn't exist: 78 | 79 | ```bash 80 | mkdir -p ~/.config/n8n-manager 81 | ``` 82 | 83 | Create the file `~/.config/n8n-manager/config` with content like this: 84 | 85 | ```ini 86 | # GitHub Personal Access Token (Required) 87 | CONF_GITHUB_TOKEN="ghp_YourGitHubPATGoesHere" 88 | 89 | # GitHub Repository (Required, format: username/repo) 90 | CONF_GITHUB_REPO="your-github-username/n8n-backups" 91 | 92 | # Default GitHub Branch (Optional, defaults to main) 93 | CONF_GITHUB_BRANCH="main" 94 | 95 | # Default n8n Container Name or ID (Optional) 96 | CONF_DEFAULT_CONTAINER="my-n8n-container" 97 | 98 | # Use Dated Backups by default (Optional, true/false, defaults to false) 99 | CONF_DATED_BACKUPS=true 100 | 101 | # Default Restore Type (Optional, all/workflows/credentials, defaults to all) 102 | CONF_RESTORE_TYPE="all" 103 | 104 | # Enable Verbose Logging by default (Optional, true/false, defaults to false) 105 | CONF_VERBOSE=false 106 | 107 | # Log output to a file (Optional, must be an absolute path) 108 | CONF_LOG_FILE="/var/log/n8n-manager.log" 109 | ``` 110 | 111 | **Security Note:** Ensure the configuration file has appropriate permissions (e.g., `chmod 600 ~/.config/n8n-manager/config`) as it contains your GitHub PAT. 112 | 113 | Command-line arguments always override settings from the configuration file. 114 | 115 | ## 💡 Usage 116 | 117 | ### Interactive Mode 118 | 119 | Simply run the script without any arguments (or only optional ones like `--verbose`): 120 | 121 | ```bash 122 | n8n-manager.sh 123 | ``` 124 | 125 | The script will guide you through: 126 | 1. Selecting the action (Backup/Restore). 127 | 2. Selecting the target n8n container. 128 | 3. Entering GitHub details (Token, Repo, Branch) if not found in the config file or provided via arguments. 129 | 4. Confirming potentially destructive actions (like restore). 130 | 131 | ### Non-Interactive Mode 132 | 133 | Provide all required parameters via command-line arguments. This is ideal for automation (e.g., cron jobs). 134 | 135 | ```bash 136 | n8n-manager.sh --action --container --token --repo [OPTIONS] 137 | ``` 138 | 139 | **Required Arguments for Non-Interactive Use:** 140 | 141 | * `--action `: `backup` or `restore`. 142 | * `--container `: The name or ID of the running n8n Docker container. 143 | * `--token `: Your GitHub PAT. 144 | * `--repo `: Your GitHub repository. 145 | 146 | **Optional Arguments:** 147 | 148 | * `--branch `: GitHub branch to use (defaults to `main`). 149 | * `--dated`: (Backup only) Create a timestamped subdirectory for the backup. 150 | * `--restore-type `: (Restore only) Choose what to restore: `all` (default), `workflows`, or `credentials`. 151 | * `--dry-run`: Simulate the action without making changes. 152 | * `--verbose`: Enable detailed debug logging for troubleshooting. 153 | * `--trace`: Enable in-depth script debugging with bash execution trace. 154 | * `--log-file `: Append all logs (plain text) to the specified file. 155 | * `--config `: Use a custom configuration file path. 156 | * `-h`, `--help`: Show the help message. 157 | 158 | **Example: Non-Interactive Backup** 159 | 160 | ```bash 161 | n8n-manager.sh \ 162 | --action backup \ 163 | --container my-n8n-container \ 164 | --token "ghp_YourToken" \ 165 | --repo "myuser/my-n8n-backup" \ 166 | --branch main \ 167 | --dated \ 168 | --log-file /var/log/n8n-backup.log 169 | ``` 170 | 171 | **Example: Non-Interactive Restore (Workflows Only)** 172 | 173 | ```bash 174 | n8n-manager.sh \ 175 | --action restore \ 176 | --container my-n8n-container \ 177 | --token "ghp_YourToken" \ 178 | --repo "myuser/my-n8n-backup" \ 179 | --branch main \ 180 | --restore-type workflows 181 | ``` 182 | 183 | ## 🔄 Backup & Restore Process 184 | 185 | ### Backup 186 | 187 | 1. **Connect:** Establishes connection parameters (container, GitHub details). 188 | 2. **Pre-Checks:** Verifies GitHub token, scopes, and repository access. 189 | 3. **Git Prep:** Clones or fetches the specified branch into a temporary directory. 190 | 4. **Export:** Executes `n8n export:workflow` and `n8n export:credentials` inside the container. 191 | 5. **Environment:** Captures `N8N_` environment variables from the container. 192 | 6. **Copy:** Copies exported `workflows.json`, `credentials.json`, and `.env` files to the temporary Git directory (optionally into a dated subdirectory). 193 | 7. **Commit:** Commits the changes with a descriptive message including the n8n version and timestamp. 194 | 8. **Push:** Pushes the commit to the specified GitHub repository and branch. 195 | 9. **Cleanup:** Removes temporary files and directories. 196 | 197 | ### Restore 198 | 199 | 1. **Connect:** Establishes connection parameters. 200 | 2. **Pre-Checks:** Verifies GitHub token, scopes, repository, and *branch* access. 201 | 3. **Confirmation:** Prompts the user for confirmation in interactive mode. 202 | 4. **Pre-Restore Backup:** Exports current workflows and credentials from the container to a temporary local directory (for rollback). 203 | 5. **Fetch:** Clones the specified branch from the GitHub repository. 204 | 6. **Copy to Container:** Copies the `workflows.json` and/or `credentials.json` from the cloned repo to the container. 205 | 7. **Import:** Executes `n8n import:workflow` and/or `n8n import:credentials` inside the container. 206 | 8. **Cleanup:** Removes temporary files and directories. 207 | 9. **Rollback (on failure):** If any step after the pre-restore backup fails, the script attempts to import the backed-up data back into n8n. 208 | 209 | ## ⚠️ Error Handling & Rollback 210 | 211 | The script includes error trapping (`set -Eeuo pipefail`) and specific checks at various stages. Version 3.0+ includes significantly improved error handling specifically designed to address common issues in shell scripting: 212 | 213 | - **Explicit String Comparisons**: Boolean variables and conditions now use explicit string comparisons (e.g., `[ "$variable" = "true" ]`) to avoid empty command errors. 214 | - **Proper Return Values**: All functions have proper return values to avoid the "command not found" errors that occur with empty returns. 215 | - **Robust Git Operations**: Git operations have been restructured to use proper error handling and to verify commands succeed at each step. 216 | - **Alpine Container Compatibility**: Special handling for file operations in Alpine-based containers ensures compatibility regardless of container OS. 217 | 218 | ## 🔧 Container Compatibility 219 | 220 | Version 3.0.5 includes specific improvements for working with different container environments: 221 | 222 | ### Alpine Linux Containers 223 | 224 | Older versions of the script sometimes ran into issues with Alpine-based containers due to differences in shell behavior and file permissions. The latest version includes: 225 | 226 | - Use of the `ash` shell for Alpine-specific commands 227 | - More robust file existence checks before operations 228 | - Proper handling of temporary files 229 | - Intelligent error suppression for non-critical operations 230 | 231 | ### Best Practices for Both Container Types 232 | 233 | For optimal performance with both Alpine and Ubuntu/Debian containers: 234 | 235 | - Ensure the n8n CLI tool is available in the container 236 | - Check that Docker permissions are sufficient on the host machine 237 | - Consider using a named volume for n8n persistent data 238 | 239 | ## 📋 Changelog 240 | 241 | ### v3.0.5 (Latest) 242 | - Fixed backup operations to work reliably with Alpine containers 243 | - Eliminated unbound variable errors in backup process 244 | - Updated documentation and improved error messaging 245 | 246 | ### v3.0.4 247 | - Comprehensive fixes for backup functions and Alpine compatibility 248 | - Converted all boolean variables to use explicit string comparisons 249 | 250 | ### v3.0.3 251 | - Added Alpine container compatibility fixes for file cleanup 252 | - Improved handling of temp files in containers 253 | 254 | ### v3.0.2 255 | - Fixed remaining "command not found" errors 256 | 257 | ### v3.0.1 258 | - Fixed file validation in restore function 259 | - Improved restore process reliability 260 | 261 | ### v3.0.0 262 | - Emergency fix for restore functionality 263 | - Complete rewrite of the restore file handling for better reliability 264 | 265 | * **Dependency/Access Errors:** Fails early if dependencies are missing or GitHub access checks fail. 266 | * **Docker/Git Errors:** Reports errors from Docker or Git commands. 267 | * **Restore Rollback:** During a restore, if the import process fails, the script automatically attempts to restore the data it backed up just before the restore started. If the rollback also fails, the pre-restore backup files are kept locally for manual recovery. 268 | 269 | ## 📜 Logging 270 | 271 | * **Standard Output:** Provides colored, user-friendly status messages. 272 | * **Verbose Mode (`--verbose`):** Prints detailed debug information, including internal steps and command outputs. 273 | * **Log File (`--log-file `):** Appends plain-text, timestamped logs to the specified file, suitable for auditing or background processes. 274 | 275 | ## 🤝 Contributing 276 | 277 | Contributions are welcome! Please feel free to open issues on the GitHub repository. 278 | 279 | ## 📄 License 280 | 281 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 282 | 283 | --------------------------------------------------------------------------------