diff --git a/k8s/README.md b/k8s/README.md index dcb1f5c..5d94839 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -6,23 +6,24 @@ Kubernetes-focused shell scripts intended for cronjobs and operational utilities ### `automated-nfs-backup.sh` -Backs up each top-level folder found in `NFS_SOURCE_PATH` into an encrypted `.7z` archive, with optional Kubernetes workload quiescing when a folder name exactly matches a namespace name. +Backs up each top-level folder found in `NFS_SOURCE_PATH` into a single encrypted `.7z` archive per run, with optional Kubernetes workload quiescing when a folder name exactly matches a namespace name. Behavior: - Exact folder-to-namespace mapping only. - Unmapped folder: backup still runs, Kubernetes scale actions are skipped. -- Mapped folder: saves replicas, scales selected workloads down, waits, runs backup, restores replicas, waits again. +- Mapped folder: saves replicas, scales selected workloads down, waits, adds that folder to the shared run archive, restores replicas, waits again. - Scale-down issues are warnings by policy (backup still runs). - Restore issues are warnings by policy (run can still complete successfully). +- Cleanup can delete the last `N` archives ordered by date (oldest side), while keeping at least one archive. ## Environment Variables Required: - `NFS_SOURCE_PATH`: Root path containing folders to back up. - `BACKUP_OUTPUT_PATH`: Destination path for generated `.7z` archives. -- `BACKUP_PASSWORD`: Password used for 7z encryption. Optional: +- `BACKUP_PASSWORD` (default: empty): When set, archive uses password protection; when empty, archive is not encrypted. - `KUBECTL_BIN` (default: `kubectl`) - `KUBE_CONTEXT` (default: empty) - `WORKLOAD_KINDS` (default: `deployment,statefulset,replicaset,replicationcontroller`) @@ -30,7 +31,7 @@ Optional: - `ARCHIVE_TS_FORMAT` (default: `%Y%m%d_%H%M%S`) - `SEVENZ_METHOD` (default: `lzma2`) - `SEVENZ_LEVEL` (default: `9`) -- `SEVENZ_HEADER_ENCRYPT` (default: `on`) +- `SEVENZ_HEADER_ENCRYPT` (default: `on`, only applied when `BACKUP_PASSWORD` is set) - `SEVENZ_THREADS` (default: `on`) - `SCALE_TIMEOUT_SECONDS` (default: `600`) - `SCALE_RETRY_COUNT` (default: `3`) @@ -41,6 +42,7 @@ Optional: - `NOTIFY_FAILURE_URL` (default: empty, disabled) - `NOTIFY_TITLE` (default: `Kubernetes`) - `NOTIFY_ASSET` (default: `kube config`) +- `CLEANUP_DELETE_COUNT` (default: `5`) Notification payload (success and failure): @@ -67,6 +69,7 @@ Notification payload (success and failure): - Restore warnings/timeouts: logged and counted, script does not fail solely because of restore warnings. - If `NOTIFY_SUCCESS_URL` is set, success notification is sent at the end of a successful run. - If `NOTIFY_FAILURE_URL` is set, failure notification is sent when backup failures are detected. +- On successful runs, cleanup removes the last `CLEANUP_DELETE_COUNT` archives by date ordering (oldest side), without deleting the final remaining archive. - Final summary always logs: - `processed` - `mapped` diff --git a/k8s/automated-nfs-backup.sh b/k8s/automated-nfs-backup.sh index bc0bb59..10d3158 100644 --- a/k8s/automated-nfs-backup.sh +++ b/k8s/automated-nfs-backup.sh @@ -9,9 +9,9 @@ setup_exit_handling # Required configuration require_env "NFS_SOURCE_PATH" require_env "BACKUP_OUTPUT_PATH" -require_env "BACKUP_PASSWORD" # Optional configuration +BACKUP_PASSWORD="${BACKUP_PASSWORD:-}" KUBECTL_BIN="${KUBECTL_BIN:-kubectl}" KUBE_CONTEXT="${KUBE_CONTEXT:-}" WORKLOAD_KINDS="${WORKLOAD_KINDS:-deployment,statefulset,replicaset,replicationcontroller}" @@ -29,6 +29,7 @@ NOTIFY_SUCCESS_URL="${NOTIFY_SUCCESS_URL:-}" NOTIFY_FAILURE_URL="${NOTIFY_FAILURE_URL:-}" NOTIFY_TITLE="${NOTIFY_TITLE:-Kubernetes}" NOTIFY_ASSET="${NOTIFY_ASSET:-kube config}" +CLEANUP_DELETE_COUNT="${CLEANUP_DELETE_COUNT:-5}" KUBECTL_ARGS=() if [[ -n "$KUBE_CONTEXT" ]]; then @@ -78,6 +79,14 @@ validate_inputs() { die "NFS_SOURCE_PATH does not exist or is not a directory: ${NFS_SOURCE_PATH}" fi + if [[ ! "$CLEANUP_DELETE_COUNT" =~ ^[0-9]+$ ]]; then + die "CLEANUP_DELETE_COUNT must be an integer >= 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" @@ -250,11 +259,10 @@ wait_for_scale_state() { done } -archive_path_for_folder() { - local folder_name="${1:?folder name required}" +archive_path_for_run() { local ts ts="$(date +"$ARCHIVE_TS_FORMAT")" - printf '%s/%s_%s_%s.7z' "$BACKUP_OUTPUT_PATH" "$ARCHIVE_PREFIX" "$folder_name" "$ts" + printf '%s/%s_%s.7z' "$BACKUP_OUTPUT_PATH" "$ARCHIVE_PREFIX" "$ts" } json_escape() { @@ -304,25 +312,72 @@ EOF backup_folder() { local folder_path="${1:?folder path required}" local archive_path="${2:?archive path required}" - local password_arg="-p${BACKUP_PASSWORD}" + local -a cmd=( + 7z a -t7z + "$archive_path" + "$folder_path" + "-m0=${SEVENZ_METHOD}" + "-mx=${SEVENZ_LEVEL}" + "-mmt=${SEVENZ_THREADS}" + ) - 7z a -t7z \ - "$archive_path" \ - "$folder_path" \ - -m0="$SEVENZ_METHOD" \ - -mx="$SEVENZ_LEVEL" \ - -mhe="$SEVENZ_HEADER_ENCRYPT" \ - -mmt="$SEVENZ_THREADS" \ - "$password_arg" \ - >/dev/null + if [[ -n "$BACKUP_PASSWORD" ]]; then + cmd+=("-p${BACKUP_PASSWORD}" "-mhe=${SEVENZ_HEADER_ENCRYPT}") + fi + + "${cmd[@]}" >/dev/null +} + +cleanup_archives() { + if (( CLEANUP_DELETE_COUNT == 0 )); then + log_info "Cleanup disabled (CLEANUP_DELETE_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 <= 1 )); then + log_info "Cleanup skipped: only one archive present" + return 0 + fi + + local delete_count="$CLEANUP_DELETE_COUNT" + local max_deletable=$((total_sorted - 1)) + if (( delete_count > max_deletable )); then + delete_count="$max_deletable" + fi + + if (( delete_count == 0 )); then + return 0 + fi + + local start_index=$((total_sorted - delete_count)) + local i + for ((i = start_index; 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 archive_path="${2:?archive path is required}" local folder_name local namespace="" local state_file="" - local archive_path="" local has_mapping=0 folder_name="$(basename "$folder_path")" @@ -349,15 +404,9 @@ process_folder() { log_warn "No namespace matched folder '${folder_name}'. Running backup only." fi - archive_path="$(archive_path_for_folder "$folder_name")" if backup_folder "$folder_path" "$archive_path"; then backup_successes=$((backup_successes + 1)) - local file_size_bytes - file_size_bytes="$(wc -c < "$archive_path" | tr -d '[:space:]')" - if [[ "$file_size_bytes" =~ ^[0-9]+$ ]]; then - ((total_backup_size_bytes += file_size_bytes)) - fi - log_info "Backup created: ${archive_path}" + log_info "Added folder '${folder_name}' to archive: ${archive_path}" else backup_failures=$((backup_failures + 1)) log_error "Backup failed for folder '${folder_name}'" @@ -385,16 +434,27 @@ main() { load_namespaces local folder_path + local run_archive_path="" local found=0 for folder_path in "${NFS_SOURCE_PATH}"/*; do [[ -d "$folder_path" ]] || continue [[ "$(basename "$folder_path")" == .* ]] && continue + if [[ -z "$run_archive_path" ]]; then + run_archive_path="$(archive_path_for_run)" + log_info "Using single archive for this run: ${run_archive_path}" + fi found=1 - process_folder "$folder_path" + process_folder "$folder_path" "$run_archive_path" done if [[ "$found" -eq 0 ]]; then log_warn "No folders found in NFS_SOURCE_PATH=${NFS_SOURCE_PATH}" + elif [[ -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 @@ -410,6 +470,8 @@ main() { die "One or more folder backups failed" 1 fi + cleanup_archives || true + send_backup_notification \ "$NOTIFY_SUCCESS_URL" \ "$NOTIFY_TITLE" \ diff --git a/k8s/examples/automated-nfs-backup.env.example b/k8s/examples/automated-nfs-backup.env.example index 951d627..e516b36 100644 --- a/k8s/examples/automated-nfs-backup.env.example +++ b/k8s/examples/automated-nfs-backup.env.example @@ -1,6 +1,6 @@ NFS_SOURCE_PATH=/path/to/nfs/source BACKUP_OUTPUT_PATH=/path/to/backup/output -BACKUP_PASSWORD=replace-with-strong-password +BACKUP_PASSWORD= KUBECTL_BIN=kubectl KUBE_CONTEXT= WORKLOAD_KINDS=deployment,statefulset,replicaset,replicationcontroller @@ -19,3 +19,4 @@ NOTIFY_SUCCESS_URL= NOTIFY_FAILURE_URL= NOTIFY_TITLE=Kubernetes NOTIFY_ASSET=kube config +CLEANUP_DELETE_COUNT=5