#!/usr/bin/env bash SCRIPT_NAME="$(basename "$0")" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh source "${SCRIPT_DIR}/lib/common.sh" # Core error/exit traps — ERR trap logs the failure, EXIT trap guarantees # every scaled-down namespace is restored regardless of how we exit. trap 'on_error "${LINENO}" "${BASH_COMMAND:-unknown}"' ERR trap '_exit_code=$?; restore_all_remaining; cleanup_run_staging; if [[ "$_exit_code" -eq 0 ]]; then log_info "Finished successfully"; else log_error "Finished with errors (exit code $_exit_code)"; fi' EXIT # Required configuration require_env "NFS_SOURCE_PATH" require_env "BACKUP_OUTPUT_PATH" # Optional configuration BACKUP_PASSWORD="${BACKUP_PASSWORD:-}" KUBECTL_BIN="${KUBECTL_BIN:-kubectl}" KUBE_CONTEXT="${KUBE_CONTEXT:-}" WORKLOAD_KINDS="${WORKLOAD_KINDS:-deployment,statefulset,replicaset}" EXCLUDED_NAMESPACES="${EXCLUDED_NAMESPACES-kube-system,kube-public,kube-node-lease}" BACKUP_STAGING_PATH="${BACKUP_STAGING_PATH:-/tmp/k8s-nfs-backup-staging}" RSYNC_BIN="${RSYNC_BIN:-rsync}" ARCHIVE_PREFIX="${ARCHIVE_PREFIX:-nfs-backup}" ARCHIVE_TS_FORMAT="${ARCHIVE_TS_FORMAT:-%Y%m%d_%H%M%S}" SEVENZ_METHOD="${SEVENZ_METHOD:-lzma2}" SEVENZ_LEVEL="${SEVENZ_LEVEL:-9}" SEVENZ_HEADER_ENCRYPT="${SEVENZ_HEADER_ENCRYPT:-on}" SEVENZ_THREADS="${SEVENZ_THREADS:-on}" SEVENZ_BIN="${SEVENZ_BIN:-7z}" SCALE_RETRY_COUNT="${SCALE_RETRY_COUNT:-3}" SCALE_RETRY_DELAY_SECONDS="${SCALE_RETRY_DELAY_SECONDS:-5}" SCALE_WAIT_SECONDS="${SCALE_WAIT_SECONDS:-30}" TMP_STATE_DIR="${TMP_STATE_DIR:-/tmp/k8s-nfs-backup}" NOTIFY_SUCCESS_URL="${NOTIFY_SUCCESS_URL-http://notify.haven/api/v2/notifications/backup}" NOTIFY_FAILURE_URL="${NOTIFY_FAILURE_URL-http://notify.haven/api/v2/notifications/error}" NOTIFY_TITLE="${NOTIFY_TITLE:-Kubernetes}" NOTIFY_ASSET="${NOTIFY_ASSET:-kube config}" NOTIFY_CALLER="${NOTIFY_CALLER:-Kubernetes NFS backup}" NOTIFY_SOURCE="${NOTIFY_SOURCE:-$NOTIFY_CALLER}" CLEANUP_KEEP_COUNT="${CLEANUP_KEEP_COUNT:-4}" RUN_STATE_DIR="" RUN_STAGING_DIR="" KUBECTL_ARGS=() if [[ -n "$KUBE_CONTEXT" ]]; then KUBECTL_ARGS+=(--context "$KUBE_CONTEXT") fi declare -a WORKLOAD_KIND_LIST=() declare -A NAMESPACE_MAP=() declare -A EXCLUDED_NAMESPACE_MAP=() total_folders=0 mapped_folders=0 unmapped_folders=0 backup_successes=0 backup_failures=0 scale_warnings=0 restore_warnings=0 total_backup_size_bytes=0 backup_started_seconds="$SECONDS" _kubectl() { "$KUBECTL_BIN" "${KUBECTL_ARGS[@]}" "$@" = 0" fi if [[ -z "$BACKUP_PASSWORD" ]]; then log_warn "BACKUP_PASSWORD is empty. Archive will be created without password protection." fi mkdir -p "$BACKUP_OUTPUT_PATH" mkdir -p "$TMP_STATE_DIR" if ! RUN_STATE_DIR="$(mktemp -d "${TMP_STATE_DIR%/}/run-XXXXXXXXXX")"; then die "Unable to create a per-run state directory under ${TMP_STATE_DIR}" fi log_debug "Using isolated run state directory: ${RUN_STATE_DIR}" local source_real local staging_real mkdir -p "$BACKUP_STAGING_PATH" source_real="$(cd "$NFS_SOURCE_PATH" && pwd -P)" staging_real="$(cd "$BACKUP_STAGING_PATH" && pwd -P)" case "${staging_real}/" in "${source_real%/}/"*) die "BACKUP_STAGING_PATH must not be inside NFS_SOURCE_PATH" ;; esac if ! RUN_STAGING_DIR="$(mktemp -d "${BACKUP_STAGING_PATH%/}/run-XXXXXXXXXX")"; then die "Unable to create a per-run staging directory under ${BACKUP_STAGING_PATH}" fi log_info "Using local run staging directory: ${RUN_STAGING_DIR}" if ! _kubectl get namespaces >/dev/null 2>&1; then die "Unable to list Kubernetes namespaces with ${KUBECTL_BIN}" fi } load_namespaces() { local ns while IFS= read -r ns; do [[ -z "$ns" ]] && continue NAMESPACE_MAP["$ns"]=1 done < <(_kubectl get namespaces -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}') } namespace_for_folder() { local folder_name="${1:?folder name required}" if [[ -n "${EXCLUDED_NAMESPACE_MAP[$folder_name]:-}" ]]; then return 2 fi if [[ -n "${NAMESPACE_MAP[$folder_name]:-}" ]]; then printf '%s' "$folder_name" return 0 fi return 1 } state_file_for_namespace() { local namespace="${1:?namespace required}" printf '%s/%s.state' "$RUN_STATE_DIR" "$namespace" } capture_replicas_state() { local namespace="${1:?namespace is required}" local state_file="${2:?state file path is required}" : > "$state_file" local kind local name local replicas local owners local output for kind in "${WORKLOAD_KIND_LIST[@]}"; do if ! output="$(_kubectl -n "$namespace" get "$kind" -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.replicas}{"\t"}{range .metadata.ownerReferences[*]}{.kind},{end}{"\n"}{end}' 2>/dev/null)"; then log_warn "Failed to list kind '${kind}' in namespace '${namespace}' while capturing state" scale_warnings=$((scale_warnings + 1)) continue fi while IFS=$'\t' read -r name replicas owners; do [[ -z "$name" ]] && continue # Skip resources managed by a higher-level controller (e.g. a # ReplicaSet owned by a Deployment) — scaling them directly # conflicts with the owning controller and can leave workloads # in an inconsistent state after restore. if [[ -n "$owners" ]]; then log_debug "Skipping ${kind}/${name} in namespace '${namespace}': owned by ${owners%,}" continue fi if [[ -z "$replicas" ]]; then replicas="1" log_warn "Resource ${kind}/${name} had no explicit replicas; defaulting saved value to 1" scale_warnings=$((scale_warnings + 1)) fi printf '%s\t%s\t%s\n' "$kind" "$name" "$replicas" >> "$state_file" done <<< "$output" done } scale_resource() { local namespace="${1:?namespace is required}" local kind="${2:?kind is required}" local name="${3:?name is required}" local replicas="${4:?replicas is required}" retry "$SCALE_RETRY_COUNT" "$SCALE_RETRY_DELAY_SECONDS" \ _kubectl -n "$namespace" scale "${kind}/${name}" --replicas="$replicas" } scale_namespace_to_zero() { local namespace="${1:?namespace is required}" local state_file="${2:?state file is required}" local kind local name local replicas while IFS=$'\t' read -r kind name replicas; do [[ -z "$kind" || -z "$name" ]] && continue if scale_resource "$namespace" "$kind" "$name" "0"; then log_info "Scaled down ${namespace}:${kind}/${name} to 0" else log_warn "Failed to scale down ${namespace}:${kind}/${name}; continuing with backup by policy" scale_warnings=$((scale_warnings + 1)) fi done < "$state_file" } restore_namespace_replicas() { local namespace="${1:?namespace is required}" local state_file="${2:?state file is required}" local kind local name local replicas while IFS=$'\t' read -r kind name replicas; do [[ -z "$kind" || -z "$name" ]] && continue if scale_resource "$namespace" "$kind" "$name" "$replicas"; then log_info "Restored ${namespace}:${kind}/${name} to ${replicas}" else log_warn "Failed to restore ${namespace}:${kind}/${name} to ${replicas}" restore_warnings=$((restore_warnings + 1)) fi done < "$state_file" } restore_all_remaining() { if [[ -z "${RUN_STATE_DIR:-}" ]] || [[ ! -d "${RUN_STATE_DIR:-}" ]]; then return 0 fi local state_file local namespace local kind name replicas local found=0 for state_file in "${RUN_STATE_DIR}"/*.state; do [[ -f "$state_file" ]] || continue found=1 namespace="$(basename "$state_file" .state)" [[ -z "$namespace" ]] && continue log_info "EXIT cleanup: restoring workloads in namespace '${namespace}'" while IFS=$'\t' read -r kind name replicas; do [[ -z "$kind" || -z "$name" ]] && continue if scale_resource "$namespace" "$kind" "$name" "$replicas"; then log_info "EXIT cleanup: restored ${namespace}:${kind}/${name} to ${replicas}" else log_warn "EXIT cleanup: failed to restore ${namespace}:${kind}/${name} to ${replicas}" restore_warnings=$((restore_warnings + 1)) fi done < "$state_file" rm -f "$state_file" done if [[ "$found" -eq 1 ]]; then log_info "EXIT cleanup complete - all remaining workloads restored" fi if ! rmdir -- "$RUN_STATE_DIR" 2>/dev/null; then log_warn "Run state directory is not empty; leaving it in place: ${RUN_STATE_DIR}" fi } cleanup_run_staging() { if [[ -z "${RUN_STAGING_DIR:-}" ]] || [[ ! -d "${RUN_STAGING_DIR:-}" ]]; then return 0 fi case "$RUN_STAGING_DIR" in "${BACKUP_STAGING_PATH%/}/run-"*) rm -rf -- "$RUN_STAGING_DIR" ;; *) log_error "Refusing to remove unexpected staging directory: ${RUN_STAGING_DIR}" return 1 ;; esac } cleanup_staged_folder() { local staging_folder="${1:?staging folder required}" case "$staging_folder" in "$RUN_STAGING_DIR"/*) rm -rf -- "$staging_folder" ;; *) log_error "Refusing to remove staging folder outside the current run: ${staging_folder}" return 1 ;; esac } sync_folder_to_staging() { local source_folder="${1:?source folder required}" local staging_folder="${2:?staging folder required}" local sync_output mkdir -p "$staging_folder" if ! sync_output="$("$RSYNC_BIN" -a --delete -- "${source_folder%/}/" "${staging_folder%/}/" 2>&1)"; then log_error "rsync failed from '${source_folder}' to '${staging_folder}'" if [[ -n "$sync_output" ]]; then log_error "rsync output: ${sync_output}" fi return 1 fi } archive_path_for_run() { local ts ts="$(date +"$ARCHIVE_TS_FORMAT")" printf '%s/%s_%s.7z' "$BACKUP_OUTPUT_PATH" "$ARCHIVE_PREFIX" "$ts" } json_escape() { local value="${1:-}" value="${value//\\/\\\\}" value="${value//\"/\\\"}" value="${value//$'\n'/ }" printf '%s' "$value" } send_backup_notification() { local url="${1:-}" local title="${2:-}" local asset="${3:-}" local size_bytes="${4:-0}" local duration_seconds="${5:-0}" [[ -z "$url" ]] && return 0 local payload payload=$(cat </dev/null; then log_warn "Failed to send backup notification to ${url}" return 1 fi return 0 } send_backup_failure_notification() { local url="${1:-}" local message="${2:-}" local size_bytes="${3:-0}" [[ -z "$url" ]] && return 0 local payload payload=$(cat </dev/null; then log_warn "Failed to send backup failure notification to ${url}" return 1 fi return 0 } archive_staging() { local archive_path="${1:?archive path required}" if [[ "$archive_path" != /* ]]; then archive_path="$(cd "$(dirname "$archive_path")" && pwd -P)/$(basename "$archive_path")" fi local -a cmd=( "$SEVENZ_BIN" a -t7z "$archive_path" . "-m0=${SEVENZ_METHOD}" "-mx=${SEVENZ_LEVEL}" "-mmt=${SEVENZ_THREADS}" ) if [[ -n "$BACKUP_PASSWORD" ]]; then cmd+=("-p${BACKUP_PASSWORD}" "-mhe=${SEVENZ_HEADER_ENCRYPT}") fi local cmd_output rc filtered_output cmd_output="$(cd "$RUN_STAGING_DIR" && "${cmd[@]}" 2>&1 >/dev/null)" rc=$? filtered_output="$(printf '%s\n' "$cmd_output" | grep -v -F 'WARNING: errno=2 : No such file or directory' || true)" if [[ -n "$filtered_output" ]]; then log_warn "7z output for staging directory '${RUN_STAGING_DIR}': ${filtered_output}" fi if (( rc == 0 )); then return 0 elif (( rc == 1 )) && [[ -z "$filtered_output" ]]; then return 0 fi return 1 } cleanup_archives() { if (( CLEANUP_KEEP_COUNT == 0 )); then log_info "Cleanup disabled (CLEANUP_KEEP_COUNT=0)" return 0 fi shopt -s nullglob local archive_candidates=( "$BACKUP_OUTPUT_PATH"/"${ARCHIVE_PREFIX}"_*.7z ) shopt -u nullglob local total_candidates="${#archive_candidates[@]}" if (( total_candidates == 0 )); then log_info "Cleanup skipped: no archives found for prefix '${ARCHIVE_PREFIX}' in ${BACKUP_OUTPUT_PATH}" return 0 fi local sorted_archives=() mapfile -t sorted_archives < <(ls -1t -- "${archive_candidates[@]}") local total_sorted="${#sorted_archives[@]}" if (( total_sorted <= CLEANUP_KEEP_COUNT )); then log_info "Cleanup skipped: ${total_sorted} archive(s) present, keeping ${CLEANUP_KEEP_COUNT}" return 0 fi local keep_count="$CLEANUP_KEEP_COUNT" local i for ((i = keep_count; i < total_sorted; i++)); do local archive_path="${sorted_archives[$i]}" rm -f -- "$archive_path" log_info "Cleanup deleted archive: ${archive_path}" done } process_folder() { local folder_path="${1:?folder path is required}" local folder_name local namespace="" local state_file="" local has_mapping=0 local namespace_match_status=0 local staging_folder local copy_succeeded=0 folder_name="$(basename "$folder_path")" staging_folder="${RUN_STAGING_DIR}/${folder_name}" total_folders=$((total_folders + 1)) log_info "Processing folder: ${folder_name}" if namespace="$(namespace_for_folder "$folder_name")"; then has_mapping=1 mapped_folders=$((mapped_folders + 1)) log_info "Exact namespace match found for folder '${folder_name}' -> namespace '${namespace}'" state_file="$(state_file_for_namespace "$namespace")" capture_replicas_state "$namespace" "$state_file" scale_namespace_to_zero "$namespace" "$state_file" log_info "Waiting ${SCALE_WAIT_SECONDS} seconds for namespace '${namespace}' to scale down..." sleep "$SCALE_WAIT_SECONDS" log_info "Copying quiesced namespace folder '${folder_name}' to local staging" if sync_folder_to_staging "$folder_path" "$staging_folder"; then copy_succeeded=1 fi else namespace_match_status=$? unmapped_folders=$((unmapped_folders + 1)) if (( namespace_match_status == 2 )); then log_warn "Folder '${folder_name}' matches an excluded namespace; skipping Kubernetes scale actions and running backup only." else log_warn "No namespace matched folder '${folder_name}'. Running backup only." fi log_info "Copying live folder '${folder_name}' to local staging" if sync_folder_to_staging "$folder_path" "$staging_folder"; then copy_succeeded=1 fi fi if [[ "$has_mapping" -eq 1 ]]; then restore_namespace_replicas "$namespace" "$state_file" log_info "Waiting ${SCALE_WAIT_SECONDS} seconds for namespace '${namespace}' to restore replicas..." sleep "$SCALE_WAIT_SECONDS" rm -f "$state_file" fi if (( copy_succeeded == 0 )); then backup_failures=$((backup_failures + 1)) log_error "Backup failed for folder '${folder_name}' while copying to local staging" cleanup_staged_folder "$staging_folder" || true return 0 fi backup_successes=$((backup_successes + 1)) log_info "Staged folder '${folder_name}' for the run archive" } print_summary() { log_info "Run summary: processed=${total_folders} mapped=${mapped_folders} unmapped=${unmapped_folders} backup_successes=${backup_successes} backup_failures=${backup_failures} scale_warnings=${scale_warnings} restore_warnings=${restore_warnings}" } main() { parse_workload_kinds parse_excluded_namespaces validate_inputs load_namespaces local folder_path local run_archive_path local found=0 run_archive_path="$(archive_path_for_run)" log_info "Using single archive for this run: ${run_archive_path}" for folder_path in "${NFS_SOURCE_PATH}"/*; do [[ -d "$folder_path" ]] || continue [[ "$(basename "$folder_path")" == .* ]] && continue found=1 process_folder "$folder_path" done if [[ "$found" -eq 0 ]]; then log_warn "No folders found in NFS_SOURCE_PATH=${NFS_SOURCE_PATH}" elif (( backup_successes > 0 )); then log_info "Compressing the complete local staging directory into: ${run_archive_path}" if ! archive_staging "$run_archive_path"; then backup_failures=$((backup_failures + backup_successes)) backup_successes=0 rm -f -- "$run_archive_path" log_error "Backup failed while compressing the local staging directory" fi fi if [[ -f "$run_archive_path" ]]; then local file_size_bytes file_size_bytes="$(wc -c < "$run_archive_path" | tr -d '[:space:]')" if [[ "$file_size_bytes" =~ ^[0-9]+$ ]]; then total_backup_size_bytes="$file_size_bytes" fi fi print_summary local duration_seconds=$((SECONDS - backup_started_seconds)) if (( backup_successes == 0 )); then send_backup_failure_notification \ "$NOTIFY_FAILURE_URL" \ "No NFS folders were backed up successfully" \ "$total_backup_size_bytes" || true die "No folder backups succeeded" 1 fi if (( backup_failures > 0 )); then send_backup_failure_notification \ "$NOTIFY_FAILURE_URL" \ "Some NFS folders failed to back up" \ "$total_backup_size_bytes" || true log_warn "Some folder backups failed; continuing because ${backup_successes} folder backup(s) succeeded" fi cleanup_archives || true send_backup_notification \ "$NOTIFY_SUCCESS_URL" \ "$NOTIFY_TITLE" \ "${NOTIFY_ASSET}" \ "$total_backup_size_bytes" \ "$duration_seconds" || true } main "$@"