Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ 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`.

### Fixed

- **Misleading "Failed to send email report" WARN on intentional skip** — `send_backup_report()` returns `2` when delivery is intentionally suppressed (module disabled, `EMAIL_ON_SUCCESS=no`, `EMAIL_ON_FAILURE=no`), distinct from `1` for real transport failure. All four call sites in `vmbackup.sh` used `if send_backup_report ...; then OK; else WARN; fi`, collapsing the intentional-skip case into the failure log line. New `_handle_notifier_rc()` helper interprets the three return codes correctly (`0`→info+sent-guard, `2`→debug+sent-guard, other→warn) and is now used at every email and Slack call site. Side benefit: the sent-guard flags (`_EMAIL_SENT` / `_SLACK_SENT`) are now also set on intentional skip, so later code paths don't retry a notification the operator explicitly suppressed.
- **`cleanup_on_exit` logged "SQLite session finalized as 'incomplete'" on successful runs** — The catch-all session finalizer ran unconditionally on every exit. On a normal successful session, `main()` had already called `sqlite_session_end` with `status='success'`; `sqlite_session_end()`'s `_SQLITE_SESSION_ENDED` idempotency guard correctly suppressed the duplicate DB write, but the surrounding `vmbackup.sh` log line still claimed the session was finalized as `incomplete`. The catch-all is now gated on `_SQLITE_SESSION_ENDED != 1` so the misleading WARN is no longer emitted when the normal exit path already finalized the session.

## [0.5.6] - 2026-04-26

### Changed
Expand Down
62 changes: 62 additions & 0 deletions config/template/slack.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/bin/bash
#################################################################################
# Slack Notification Configuration for vmbackup.sh
#
# TEMPLATE FILE - Copy to config/<instance>/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"
195 changes: 195 additions & 0 deletions modules/slack_notification_module.sh
Original file line number Diff line number Diff line change
@@ -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/<instance>/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 <<JSON
{
"attachments": [
{
"color": "$color",
"fallback": "$(_slack_json_escape "$title — $summary")",
"title": "$(_slack_json_escape "$emoji $title")",
"text": "$(_slack_json_escape "$summary")",
"footer": "$(_slack_json_escape "$meta")",
"ts": $(date +%s)
}
]
}
JSON
)

local http_code
http_code=$(curl --silent --show-error --max-time "${SLACK_TIMEOUT:-10}" \
--output /dev/null --write-out '%{http_code}' \
-X POST -H 'Content-Type: application/json' \
--data "$payload" \
"$SLACK_WEBHOOK_URL" 2>/dev/null)

if [[ "$http_code" =~ ^2 ]]; then
return 0
fi
echo "ERROR: Slack webhook returned HTTP ${http_code:-no-response}" >&2
return 1
}
Loading