#!/usr/bin/env bash ######################################################################################### #### jenkins-backup.sh — Backup and restore Jenkins configuration and jobs #### #### Supports JENKINS_HOME tar, job XML export, and credential backup #### #### Requires: bash 4+, tar, curl #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.01 #### #### #### #### Usage: #### #### export JENKINS_HOME="/var/lib/jenkins" #### #### ./jenkins-backup.sh --backup #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── JENKINS_HOME="${JENKINS_HOME:-/var/lib/jenkins}" BACKUP_DIR="${BACKUP_DIR:-/var/backups/jenkins}" RETENTION_COUNT="${RETENTION_COUNT:-7}" JENKINS_URL="${JENKINS_URL:-}" JENKINS_USER="${JENKINS_USER:-}" JENKINS_TOKEN="${JENKINS_TOKEN:-}" BACKUP_TYPE="${BACKUP_TYPE:-full}" EXCLUDE_PLUGINS="${EXCLUDE_PLUGINS:-false}" CURL_TIMEOUT="${CURL_TIMEOUT:-30}" CURL_INSECURE="${CURL_INSECURE:-false}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME RUN_MODE="backup" RESTORE_DIR="" DRY_RUN="false" CONFIG_ONLY="false" API_BACKUP="false" TMPDIR_WORK="" START_TIME="" # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' BOLD='\033[1m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${BLUE}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${BLUE}[DEBUG]${RESET} $*"; fi; } # ── Cleanup ─────────────────────────────────────────────────────────── cleanup() { if [[ -n "$TMPDIR_WORK" && -d "$TMPDIR_WORK" ]]; then verbose "Cleaning up temp directory: $TMPDIR_WORK" rm -rf "$TMPDIR_WORK" fi } trap cleanup EXIT # ── Helpers ─────────────────────────────────────────────────────────── human_size() { local bytes="$1" if [[ "$bytes" -ge 1073741824 ]]; then echo "$(( bytes / 1073741824 ))G" elif [[ "$bytes" -ge 1048576 ]]; then echo "$(( bytes / 1048576 ))M" elif [[ "$bytes" -ge 1024 ]]; then echo "$(( bytes / 1024 ))K" else echo "${bytes}B" fi } elapsed_time() { local end_time end_time=$(date +%s) local duration=$(( end_time - START_TIME )) local mins=$(( duration / 60 )) local secs=$(( duration % 60 )) if [[ "$mins" -gt 0 ]]; then echo "${mins}m ${secs}s" else echo "${secs}s" fi } jenkins_api() { local endpoint="$1" local output="${2:-}" local url="${JENKINS_URL%/}${endpoint}" local curl_opts=(-s -S --max-time "$CURL_TIMEOUT" -f) [[ "$CURL_INSECURE" == "true" ]] && curl_opts+=(-k) if [[ -n "$JENKINS_USER" && -n "$JENKINS_TOKEN" ]]; then curl_opts+=(-u "${JENKINS_USER}:${JENKINS_TOKEN}") fi if [[ -n "$output" ]]; then curl "${curl_opts[@]}" "$url" -o "$output" else curl "${curl_opts[@]}" "$url" fi } # ── Show Help ───────────────────────────────────────────────────────── show_help() { cat << EOF Usage: $SCRIPT_NAME [OPTIONS] Backup and restore Jenkins configuration, jobs, credentials, and plugins. Supports full JENKINS_HOME archives, config-only snapshots, and remote API exports with automatic retention. OPTIONS: --backup Run a backup (default) --config-only Backup configuration files only (no workspaces/builds) --api-backup Export job configs via Jenkins API (remote, no filesystem) --restore DIR Restore from the specified backup directory or archive --dry-run With --restore, show what would be restored without changes --list List available backups --verify DIR Verify backup integrity via checksums --help, -h Show this help message ENVIRONMENT VARIABLES: JENKINS_HOME Jenkins home directory (default: /var/lib/jenkins) BACKUP_DIR Root backup directory (default: /var/backups/jenkins) RETENTION_COUNT Number of backups to retain (default: 7) JENKINS_URL Jenkins base URL (required for --api-backup) JENKINS_USER Jenkins username (required for --api-backup) JENKINS_TOKEN Jenkins API token (required for --api-backup) BACKUP_TYPE Backup type: full, config, api (default: full) EXCLUDE_PLUGINS Skip plugin JPI files in config backup (default: false) CURL_TIMEOUT HTTP timeout in seconds (default: 30) CURL_INSECURE Allow self-signed certs (default: false) VERBOSE Enable verbose output (default: false) COLOR Color output: auto, always, never (default: auto) EXAMPLES: # Full JENKINS_HOME backup JENKINS_HOME=/var/lib/jenkins ./jenkins-backup.sh --backup # Config-only backup (XML configs + plugins, no build data) ./jenkins-backup.sh --config-only # Remote API backup (no filesystem access needed) JENKINS_URL=http://localhost:8080 JENKINS_USER=admin JENKINS_TOKEN=xxxx \\ ./jenkins-backup.sh --api-backup # Restore from a backup ./jenkins-backup.sh --restore /var/backups/jenkins/20260404-120000-full # Dry-run restore ./jenkins-backup.sh --restore /var/backups/jenkins/20260404-120000-full --dry-run # List available backups ./jenkins-backup.sh --list # Verify a backup ./jenkins-backup.sh --verify /var/backups/jenkins/20260404-120000-full EOF exit 0 } # ── Parse Arguments ─────────────────────────────────────────────────── parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --backup) RUN_MODE="backup"; shift ;; --config-only) RUN_MODE="backup"; CONFIG_ONLY="true"; BACKUP_TYPE="config"; shift ;; --api-backup) RUN_MODE="backup"; API_BACKUP="true"; BACKUP_TYPE="api"; shift ;; --restore) RUN_MODE="restore" if [[ $# -lt 2 ]]; then err "--restore requires a directory argument" exit 1 fi RESTORE_DIR="$2"; shift 2 ;; --dry-run) DRY_RUN="true"; shift ;; --list) RUN_MODE="list"; shift ;; --verify) RUN_MODE="verify" if [[ $# -lt 2 ]]; then err "--verify requires a directory argument" exit 1 fi RESTORE_DIR="$2"; shift 2 ;; --help|-h) show_help ;; *) err "Unknown option: $1" echo "Run '$SCRIPT_NAME --help' for usage." >&2 exit 1 ;; esac done } # ── Validation ──────────────────────────────────────────────────────── validate_backup() { if [[ "$API_BACKUP" == "true" ]]; then if [[ -z "$JENKINS_URL" ]]; then err "JENKINS_URL is required for --api-backup" exit 1 fi if [[ -z "$JENKINS_USER" || -z "$JENKINS_TOKEN" ]]; then err "JENKINS_USER and JENKINS_TOKEN are required for --api-backup" exit 1 fi if ! command -v curl &>/dev/null; then err "curl is required but not installed" exit 1 fi else if [[ ! -d "$JENKINS_HOME" ]]; then err "JENKINS_HOME does not exist: $JENKINS_HOME" exit 1 fi for cmd in tar sha256sum; do if ! command -v "$cmd" &>/dev/null; then err "$cmd is required but not installed" exit 1 fi done fi mkdir -p "$BACKUP_DIR" } # ── Full Backup ─────────────────────────────────────────────────────── do_full_backup() { local timestamp timestamp=$(date +%Y%m%d-%H%M%S) local dest="$BACKUP_DIR/${timestamp}-full" local archive="$dest/jenkins-full-${timestamp}.tar.gz" mkdir -p "$dest" log "Starting full backup of $JENKINS_HOME" verbose "Destination: $dest" local tar_excludes=( --exclude='workspace' --exclude='*/builds/*/archive' --exclude='*.log' ) tar czf "$archive" \ "${tar_excludes[@]}" \ -C "$(dirname "$JENKINS_HOME")" \ "$(basename "$JENKINS_HOME")" 2>/dev/null || { err "Failed to create tar archive" rm -rf "$dest" exit 1 } sha256sum "$archive" > "$dest/checksums.sha256" echo "full" > "$dest/.backup-type" local size size=$(stat -c%s "$archive" 2>/dev/null || stat -f%z "$archive" 2>/dev/null || echo 0) log "Full backup complete: $(human_size "$size") in $(elapsed_time)" log "Archive: $archive" } # ── Config-Only Backup ──────────────────────────────────────────────── do_config_backup() { local timestamp timestamp=$(date +%Y%m%d-%H%M%S) local dest="$BACKUP_DIR/${timestamp}-config" local archive="$dest/jenkins-config-${timestamp}.tar.gz" mkdir -p "$dest" log "Starting config-only backup of $JENKINS_HOME" verbose "Destination: $dest" TMPDIR_WORK=$(mktemp -d) local staging="$TMPDIR_WORK/jenkins-config" mkdir -p "$staging" # Core config files for f in config.xml credentials.xml hudson.model.UpdateCenter.xml jenkins.model.JenkinsLocationConfiguration.xml; do if [[ -f "$JENKINS_HOME/$f" ]]; then cp "$JENKINS_HOME/$f" "$staging/" verbose " Copied $f" fi done # Directories: users, secrets, nodes for d in users secrets nodes; do if [[ -d "$JENKINS_HOME/$d" ]]; then cp -r "$JENKINS_HOME/$d" "$staging/" verbose " Copied $d/" fi done # Job configs (config.xml only) if [[ -d "$JENKINS_HOME/jobs" ]]; then find "$JENKINS_HOME/jobs" -name config.xml -type f | while IFS= read -r jobxml; do local relpath relpath="${jobxml#"$JENKINS_HOME"/}" local target_dir target_dir="$staging/$(dirname "$relpath")" mkdir -p "$target_dir" cp "$jobxml" "$target_dir/" done verbose " Copied job configs" fi # Plugin JPI files if [[ "$EXCLUDE_PLUGINS" != "true" && -d "$JENKINS_HOME/plugins" ]]; then mkdir -p "$staging/plugins" find "$JENKINS_HOME/plugins" -maxdepth 1 -name '*.jpi' -exec cp {} "$staging/plugins/" \; local plugin_count plugin_count=$(find "$staging/plugins" -name '*.jpi' 2>/dev/null | wc -l) verbose " Copied $plugin_count plugin files" fi tar czf "$archive" -C "$TMPDIR_WORK" "jenkins-config" 2>/dev/null || { err "Failed to create config archive" rm -rf "$dest" exit 1 } sha256sum "$archive" > "$dest/checksums.sha256" echo "config" > "$dest/.backup-type" local size size=$(stat -c%s "$archive" 2>/dev/null || stat -f%z "$archive" 2>/dev/null || echo 0) log "Config backup complete: $(human_size "$size") in $(elapsed_time)" log "Archive: $archive" } # ── API Backup ──────────────────────────────────────────────────────── do_api_backup() { local timestamp timestamp=$(date +%Y%m%d-%H%M%S) local dest="$BACKUP_DIR/${timestamp}-api" mkdir -p "$dest/jobs" "$dest/plugins" log "Starting API backup from $JENKINS_URL" verbose "Destination: $dest" # Get job list — parse JSON without jq local job_list_json job_list_json=$(jenkins_api "/api/json?tree=jobs[name]") || { err "Failed to fetch job list from Jenkins API" rm -rf "$dest" exit 1 } # Extract job names from JSON using grep/sed local job_names job_names=$(echo "$job_list_json" | { grep -o '"name" *: *"[^"]*"' || true; } | sed 's/.*: *"//;s/"$//') local job_count=0 local job_fail=0 if [[ -n "$job_names" ]]; then while IFS= read -r job_name; do [[ -z "$job_name" ]] && continue local encoded_name encoded_name="${job_name// /%20}" if jenkins_api "/job/${encoded_name}/config.xml" "$dest/jobs/${job_name}.xml" 2>/dev/null; then verbose " Exported job: $job_name" job_count=$((job_count + 1)) else warn "Failed to export job: $job_name" job_fail=$((job_fail + 1)) fi done <<< "$job_names" fi log "Exported $job_count jobs ($job_fail failed)" # Plugin list local plugin_json if plugin_json=$(jenkins_api "/pluginManager/api/json?depth=1&tree=plugins[shortName,version,enabled,active]"); then echo "$plugin_json" > "$dest/plugins/plugin-list.json" local plugin_count plugin_count=$(echo "$plugin_json" | { grep -o '"shortName"' || true; } | wc -l) log "Exported plugin list: $plugin_count plugins" else warn "Failed to fetch plugin list" fi # Create checksum file for all exported files if command -v sha256sum &>/dev/null; then find "$dest" -type f ! -name 'checksums.sha256' -exec sha256sum {} + > "$dest/checksums.sha256" 2>/dev/null || true fi echo "api" > "$dest/.backup-type" local size size=$(du -sb "$dest" 2>/dev/null | cut -f1) size="${size:-0}" log "API backup complete: $(human_size "$size") in $(elapsed_time)" log "Backup directory: $dest" } # ── Backup Entry Point ─────────────────────────────────────────────── do_backup() { START_TIME=$(date +%s) validate_backup if [[ "$API_BACKUP" == "true" ]]; then do_api_backup elif [[ "$CONFIG_ONLY" == "true" ]]; then do_config_backup else do_full_backup fi prune_backups } # ── Prune ───────────────────────────────────────────────────────────── prune_backups() { log "Pruning old backups (retaining $RETENTION_COUNT)..." local backup_count backup_count=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l) if [[ "$backup_count" -le "$RETENTION_COUNT" ]]; then log " No pruning needed ($backup_count backups present)" return 0 fi local remove_count=$((backup_count - RETENTION_COUNT)) find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | sort | head -n "$remove_count" | while IFS= read -r dir; do log " Removing $(basename "$dir")" rm -rf "$dir" done log " Pruned $remove_count old backups" } # ── Restore ─────────────────────────────────────────────────────────── do_restore() { if [[ -z "$RESTORE_DIR" ]]; then err "--restore requires a directory argument" exit 1 fi if [[ ! -d "$RESTORE_DIR" ]]; then err "Restore directory does not exist: $RESTORE_DIR" exit 1 fi # Find the tar archive in the backup directory local archive archive=$(find "$RESTORE_DIR" -maxdepth 1 -name '*.tar.gz' -type f | head -1) if [[ -z "$archive" ]]; then err "No tar archive found in: $RESTORE_DIR" exit 1 fi log "Restore source: $archive" log "Restore target: $JENKINS_HOME" if [[ "$DRY_RUN" == "true" ]]; then log "${BOLD}Dry run — listing archive contents:${RESET}" tar tzf "$archive" | head -50 local total total=$(tar tzf "$archive" | wc -l) log " ($total files total)" log "Dry run complete — no changes made" return 0 fi # Verify checksum if available if [[ -f "$RESTORE_DIR/checksums.sha256" ]]; then log "Verifying backup integrity..." if (cd "$RESTORE_DIR" && sha256sum -c checksums.sha256 --quiet 2>/dev/null); then log " ${GREEN}Checksums verified${RESET}" else warn "Checksum verification failed — proceed with caution" fi fi if [[ ! -d "$JENKINS_HOME" ]]; then mkdir -p "$JENKINS_HOME" fi log "Restoring archive..." tar xzf "$archive" -C "$(dirname "$JENKINS_HOME")" || { err "Failed to extract archive" exit 1 } warn "Jenkins must be restarted for changes to take effect" warn " sudo systemctl restart jenkins" log "Restore complete" } # ── List ────────────────────────────────────────────────────────────── do_list() { if [[ ! -d "$BACKUP_DIR" ]]; then log "No backups found (directory does not exist: $BACKUP_DIR)" return 0 fi local count=0 local format=" %-28s %-8s %s\n" printf "\n" # shellcheck disable=SC2059 printf "$format" "BACKUP" "TYPE" "SIZE" # shellcheck disable=SC2059 printf "$format" "----------------------------" "--------" "--------" for dir in "$BACKUP_DIR"/*/; do [[ -d "$dir" ]] || continue count=$((count + 1)) local name name=$(basename "$dir") local btype="unknown" if [[ -f "$dir/.backup-type" ]]; then btype=$(cat "$dir/.backup-type") fi local size size=$(du -sh "$dir" 2>/dev/null | cut -f1) size="${size:-?}" # shellcheck disable=SC2059 printf "$format" "$name" "$btype" "$size" done printf "\n" if [[ "$count" -eq 0 ]]; then log "No backups found in $BACKUP_DIR" else log "$count backup(s) in $BACKUP_DIR" fi } # ── Verify ──────────────────────────────────────────────────────────── do_verify() { if [[ -z "$RESTORE_DIR" ]]; then err "--verify requires a directory argument" exit 1 fi if [[ ! -d "$RESTORE_DIR" ]]; then err "Backup directory does not exist: $RESTORE_DIR" exit 1 fi log "Verifying backup: $RESTORE_DIR" # Check backup type local btype="unknown" if [[ -f "$RESTORE_DIR/.backup-type" ]]; then btype=$(cat "$RESTORE_DIR/.backup-type") fi log " Backup type: $btype" # Check for checksums if [[ -f "$RESTORE_DIR/checksums.sha256" ]]; then log " Verifying checksums..." if (cd "$RESTORE_DIR" && sha256sum -c checksums.sha256 2>/dev/null); then log " ${GREEN}All checksums passed${RESET}" else err "Checksum verification FAILED" exit 1 fi else warn "No checksum file found — cannot verify integrity" fi # Archive contents summary local archive archive=$(find "$RESTORE_DIR" -maxdepth 1 -name '*.tar.gz' -type f | head -1) if [[ -n "$archive" ]]; then local size size=$(stat -c%s "$archive" 2>/dev/null || stat -f%z "$archive" 2>/dev/null || echo 0) log " Archive: $(basename "$archive") ($(human_size "$size"))" local file_count file_count=$(tar tzf "$archive" | wc -l) log " Files in archive: $file_count" fi # API backup — list exported files if [[ "$btype" == "api" ]]; then local job_count job_count=$(find "$RESTORE_DIR/jobs" -name '*.xml' 2>/dev/null | wc -l) log " Exported jobs: $job_count" if [[ -f "$RESTORE_DIR/plugins/plugin-list.json" ]]; then local plugin_count plugin_count=$({ grep -o '"shortName"' "$RESTORE_DIR/plugins/plugin-list.json" || true; } | wc -l) log " Plugins recorded: $plugin_count" fi fi log "Verification complete" } # ── Main ────────────────────────────────────────────────────────────── main() { setup_colors parse_args "$@" case "$RUN_MODE" in backup) do_backup ;; restore) do_restore ;; list) do_list ;; verify) do_verify ;; *) err "Unknown mode: $RUN_MODE"; exit 1 ;; esac } main "$@"