From b572d74b84a8090e72182a2c74362448b6da6d45 Mon Sep 17 00:00:00 2001 From: Houssem Chergui Date: Sat, 16 May 2026 14:25:28 +0100 Subject: [PATCH] Add native Slack notification module Mirrors the existing email module so Slack delivery is wired into the same four call sites (cleanup_on_exit, handle_sigterm, replicate-only end, normal session end). This means failure-path notifications fire even when a run aborts before the normal end-of-main code path. The two notifiers are independent: SLACK_ENABLED, SLACK_ON_SUCCESS, and SLACK_ON_FAILURE let operators run Slack-only, email-only, or both. Session totals (VMs ok/failed/skipped/excluded, total bytes, duration) are pulled from the same sessions row the email module uses via sqlite_query_session_summary. curl is the only new dependency. --- CHANGELOG.md | 6 + config/template/slack.conf | 62 +++++++++ modules/slack_notification_module.sh | 195 +++++++++++++++++++++++++++ vmbackup.sh | 60 ++++++++- 4 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 config/template/slack.conf create mode 100644 modules/slack_notification_module.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 5341da1..8d0a209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to [vmbackup](https://github.com/doutsis/vmbackup) will be d Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions follow [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- **Native Slack notifications** — New `modules/slack_notification_module.sh` and `config/template/slack.conf` add first-class Slack incoming-webhook delivery alongside the existing email path. Mirrors the email module's load/send shape (`load_slack_config` / `send_slack_notification`) and is invoked at the same four call sites (`cleanup_on_exit`, `handle_sigterm`, replicate-only end, normal session end) so failure-path notifications fire even when the run aborts before the normal end-of-main email send. Independent enable/conditional flags (`SLACK_ENABLED`, `SLACK_ON_SUCCESS`, `SLACK_ON_FAILURE`) let operators run Slack-only, email-only, or both. Session totals (VMs ok/failed/skipped/excluded, total bytes, duration) are pulled from the same `sessions` row the email module uses via `sqlite_query_session_summary`. Only runtime dependency is `curl`. + ## [0.5.6] - 2026-04-26 ### Changed diff --git a/config/template/slack.conf b/config/template/slack.conf new file mode 100644 index 0000000..3ea9568 --- /dev/null +++ b/config/template/slack.conf @@ -0,0 +1,62 @@ +#!/bin/bash +################################################################################# +# Slack Notification Configuration for vmbackup.sh +# +# TEMPLATE FILE - Copy to config//slack.conf and customize +# +# This file configures Slack incoming-webhook posts after backup operations. +# Independent of email — both modules can be enabled at the same time. +# +# Prerequisites: +# 1. Create a Slack incoming webhook: +# https://api.slack.com/messaging/webhooks +# 2. Paste the webhook URL into SLACK_WEBHOOK_URL below. +# 3. curl must be installed (already a vmbackup runtime dependency). +# +# Usage: Auto-sourced by slack_notification_module.sh +# +# Version: 1.0 +################################################################################# + +#============================================================================= +# ENABLE / DISABLE +#============================================================================= + +# Master enable/disable for Slack notifications +SLACK_ENABLED="no" + +#============================================================================= +# WEBHOOK +#============================================================================= + +# Slack incoming-webhook URL +# Format: https://hooks.slack.com/services/T.../B.../... +SLACK_WEBHOOK_URL="" + +#============================================================================= +# MESSAGE FORMATTING +#============================================================================= + +# Hostname displayed in the message title +# Leave empty to use short hostname: $(hostname -s) +SLACK_HOSTNAME="" + +# Title prefix (appears before host + status) +SLACK_TITLE_PREFIX="[vmbackup]" + +#============================================================================= +# CONDITIONAL POSTING +#============================================================================= + +# Post to Slack on successful sessions? +SLACK_ON_SUCCESS="yes" + +# Post to Slack on failed / partial sessions? +SLACK_ON_FAILURE="yes" + +#============================================================================= +# TRANSPORT +#============================================================================= + +# curl --max-time for the webhook POST (seconds) +SLACK_TIMEOUT="10" diff --git a/modules/slack_notification_module.sh b/modules/slack_notification_module.sh new file mode 100644 index 0000000..f6d9041 --- /dev/null +++ b/modules/slack_notification_module.sh @@ -0,0 +1,195 @@ +#!/bin/bash +################################################################################# +# Slack Notification Module for vmbackup.sh +# +# Posts a session summary to a Slack incoming webhook after a backup, +# replicate-only, or pre-flight-aborted run. Designed to mirror the call +# sites of email_report_module.sh so both can be enabled independently. +# +# Dependencies: +# - curl (transport) +# - lib/sqlite_module.sh (session totals; falls back to empty stats) +# - config//slack.conf (per-instance configuration) +# +# Usage: +# source slack_notification_module.sh +# load_slack_config +# send_slack_notification "$start" "$end" "$status" +# +# Status values handled: success, partial, failed, unknown +################################################################################# + +SLACK_MODULE_VERSION="1.0" +SLACK_MODULE_LOADED=0 +SLACK_MODULE_AVAILABLE=0 + +#------------------------------------------------------------------------------- +# load_slack_config - Load Slack configuration from instance config directory +# Returns: 0 on success, 1 if disabled or invalid +#------------------------------------------------------------------------------- +load_slack_config() { + local script_dir="${SCRIPT_DIR:-$(dirname "$(readlink -f "$0")")}" + local instance="${CONFIG_INSTANCE:-default}" + local config_file="$script_dir/config/${instance}/slack.conf" + + if [[ ! -f "$config_file" ]]; then + SLACK_MODULE_AVAILABLE=0 + SLACK_ENABLED="no" + return 1 + fi + + # shellcheck source=/dev/null + if ! source "$config_file" 2>/dev/null; then + echo "ERROR: Failed to load Slack config: $config_file" >&2 + SLACK_MODULE_AVAILABLE=0 + SLACK_ENABLED="no" + return 1 + fi + + if [[ "${SLACK_ENABLED:-no}" != "yes" ]]; then + SLACK_MODULE_AVAILABLE=0 + return 1 + fi + + if [[ -z "${SLACK_WEBHOOK_URL:-}" ]]; then + echo "ERROR: SLACK_WEBHOOK_URL not set in $config_file" >&2 + SLACK_MODULE_AVAILABLE=0 + return 1 + fi + + SLACK_HOSTNAME="${SLACK_HOSTNAME:-$(hostname -s)}" + SLACK_TITLE_PREFIX="${SLACK_TITLE_PREFIX:-[vmbackup]}" + SLACK_ON_SUCCESS="${SLACK_ON_SUCCESS:-yes}" + SLACK_ON_FAILURE="${SLACK_ON_FAILURE:-yes}" + SLACK_TIMEOUT="${SLACK_TIMEOUT:-10}" + + if ! command -v curl >/dev/null 2>&1; then + echo "WARNING: curl not found - Slack delivery will fail" >&2 + fi + + SLACK_MODULE_AVAILABLE=1 + SLACK_MODULE_LOADED=1 + return 0 +} + +#------------------------------------------------------------------------------- +# Helpers +#------------------------------------------------------------------------------- + +# Format bytes as TiB/GiB/MiB/KiB/B (no awk; integer math is fine for ranges). +_slack_format_bytes() { + local bytes="${1:-0}" + [[ "$bytes" =~ ^[0-9]+$ ]] || { echo "0 B"; return; } + if (( bytes >= 1099511627776 )); then printf '%d.%d TiB' $((bytes/1099511627776)) $(((bytes%1099511627776)*10/1099511627776)) + elif (( bytes >= 1073741824 )); then printf '%d.%d GiB' $((bytes/1073741824)) $(((bytes%1073741824)*10/1073741824)) + elif (( bytes >= 1048576 )); then printf '%d.%d MiB' $((bytes/1048576)) $(((bytes%1048576)*10/1048576)) + elif (( bytes >= 1024 )); then printf '%d.%d KiB' $((bytes/1024)) $(((bytes%1024)*10/1024)) + else printf '%d B' "$bytes" + fi +} + +# Compute duration in Xh Ym Zs given two "YYYY-MM-DD HH:MM:SS [TZ]" strings. +_slack_format_duration() { + local start_epoch end_epoch diff + start_epoch=$(date -d "$1" +%s 2>/dev/null) || return 1 + end_epoch=$(date -d "$2" +%s 2>/dev/null) || return 1 + diff=$(( end_epoch - start_epoch )) + (( diff < 0 )) && diff=0 + printf '%dh %dm %02ds' $((diff/3600)) $((diff%3600/60)) $((diff%60)) +} + +# JSON-escape a string for inline embedding in a payload. +_slack_json_escape() { + local s="$1" + s=${s//\\/\\\\} + s=${s//\"/\\\"} + s=${s//$'\n'/\\n} + s=${s//$'\r'/} + s=${s//$'\t'/\\t} + printf '%s' "$s" +} + +#------------------------------------------------------------------------------- +# send_slack_notification - Build and POST the session summary +# Args: +# $1 - start_time string +# $2 - end_time string +# $3 - overall_status (success|partial|failed|unknown) +# Returns: 0 on success, 1 on transport failure, 2 if intentionally skipped +#------------------------------------------------------------------------------- +send_slack_notification() { + local start_time="${1:-unknown}" + local end_time="${2:-$(date '+%Y-%m-%d %H:%M:%S %Z')}" + local overall_status="${3:-unknown}" + + if [[ "${SLACK_MODULE_AVAILABLE:-0}" -ne 1 ]]; then + return 2 + fi + + case "$overall_status" in + success) + [[ "${SLACK_ON_SUCCESS:-yes}" == "yes" ]] || return 2 + ;; + partial|failed|unknown) + [[ "${SLACK_ON_FAILURE:-yes}" == "yes" ]] || return 2 + ;; + esac + + local color emoji status_label + case "$overall_status" in + success) color="#36a64f"; emoji=":white_check_mark:"; status_label="SUCCESS" ;; + partial) color="#daa038"; emoji=":warning:"; status_label="PARTIAL" ;; + failed) color="#cc0000"; emoji=":rotating_light:"; status_label="FAILED" ;; + *) color="#888888"; emoji=":grey_question:"; status_label="UNKNOWN" ;; + esac + + # Pull session totals from SQLite if available; otherwise leave blank. + local total=0 ok=0 fail=0 skip=0 excl=0 bytes=0 + if declare -f sqlite_query_session_summary >/dev/null 2>&1; then + local row + row=$(sqlite_query_session_summary 2>/dev/null | head -1) + if [[ -n "$row" ]]; then + IFS='|' read -r total ok fail skip excl bytes _status _stype <<<"$row" + fi + fi + + local size_h + size_h=$(_slack_format_bytes "${bytes:-0}") + local dur_h + dur_h=$(_slack_format_duration "$start_time" "$end_time" 2>/dev/null) || dur_h="n/a" + + local instance="${CONFIG_INSTANCE:-default}" + local title="${SLACK_TITLE_PREFIX} ${SLACK_HOSTNAME} — ${status_label}" + local summary="VMs: ${ok:-0} ok / ${fail:-0} failed / ${skip:-0} skipped / ${excl:-0} excluded (total ${total:-0})" + local meta="Size: ${size_h} | Duration: ${dur_h} | Instance: ${instance}" + + local payload + payload=$(cat </dev/null) + + if [[ "$http_code" =~ ^2 ]]; then + return 0 + fi + echo "ERROR: Slack webhook returned HTTP ${http_code:-no-response}" >&2 + return 1 +} diff --git a/vmbackup.sh b/vmbackup.sh index 4bc78c3..ff3b812 100755 --- a/vmbackup.sh +++ b/vmbackup.sh @@ -5427,6 +5427,26 @@ cleanup_on_exit() { fi fi + # Slack notification on non-zero exit (parallel to the email path above). + if [[ $exit_code -ne 0 ]] && \ + [[ -n "${SQLITE_CURRENT_SESSION_ID:-}" ]] && \ + [[ "${_SLACK_SENT:-false}" != "true" ]] && \ + [[ "$DRY_RUN" != true ]] && \ + [[ -f "${SCRIPT_DIR}/modules/slack_notification_module.sh" ]]; then + # shellcheck source=/dev/null + source "${SCRIPT_DIR}/modules/slack_notification_module.sh" + if load_slack_config; then + local _slack_end_time + _slack_end_time=$(date '+%Y-%m-%d %H:%M:%S %Z') + if send_slack_notification "${session_start_time:-unknown}" "$_slack_end_time" "failed"; then + _SLACK_SENT=true + log_info "vmbackup.sh" "cleanup_on_exit" "Slack notification sent (cleanup path)" + else + log_warn "vmbackup.sh" "cleanup_on_exit" "Failed to send Slack notification from cleanup path" + fi + fi + fi + log_info "vmbackup.sh" "cleanup_on_exit" "Cleaning up temporary files before exit (exit code: $exit_code)" # Remove stale lock files — only those whose owning process is no longer running. @@ -5549,7 +5569,21 @@ handle_sigterm() { log_debug "vmbackup.sh" "handle_sigterm" "Email disabled or not configured for this instance" fi fi - + + if [[ "$DRY_RUN" != true ]] && \ + [[ "${_SLACK_SENT:-false}" != "true" ]] && \ + [[ -f "${SCRIPT_DIR}/modules/slack_notification_module.sh" ]]; then + source "${SCRIPT_DIR}/modules/slack_notification_module.sh" + if load_slack_config; then + if send_slack_notification "${session_start_time:-unknown}" "$session_end_time" "failed"; then + log_info "vmbackup.sh" "handle_sigterm" "Slack notification sent" + _SLACK_SENT=true + else + log_warn "vmbackup.sh" "handle_sigterm" "Failed to send Slack notification" + fi + fi + fi + exit 143 } @@ -5961,6 +5995,14 @@ _run_replicate_only() { fi fi + if [[ -f "${SCRIPT_DIR}/modules/slack_notification_module.sh" ]]; then + source "${SCRIPT_DIR}/modules/slack_notification_module.sh" + if load_slack_config; then + send_slack_notification "$session_start_time" "$session_end_time" "$final_status" || true + _SLACK_SENT=true + fi + fi + log_info "vmbackup.sh" "main" "===== REPLICATE-ONLY MODE END (exit=$any_failed) =====" return $any_failed } @@ -6919,7 +6961,21 @@ main() { else log_debug "vmbackup.sh" "main" "Email report module not found - skipping email notification" fi - + + if [[ "$DRY_RUN" == true ]]; then + log_info "vmbackup.sh" "main" "[DRY-RUN] Skipping Slack notification" + elif [[ -f "${SCRIPT_DIR}/modules/slack_notification_module.sh" ]]; then + source "${SCRIPT_DIR}/modules/slack_notification_module.sh" + if load_slack_config; then + if send_slack_notification "$session_start_time" "$session_end_time" "$overall_status"; then + log_info "vmbackup.sh" "main" "Slack notification sent" + _SLACK_SENT=true + else + log_warn "vmbackup.sh" "main" "Failed to send Slack notification" + fi + fi + fi + if (( fail_count > 0 )); then log_error "vmbackup.sh" "main" "Session ended with failures - exit code 1" exit 1