apiscp-borg/bin/borg-apiscp-backup
Laurence Horrocks-Barlow 2f0d93a0ae Initial scaffold: Borg engine + docs (Layer 1 milestone)
apiscp-borg is a sibling of apiscp-kopia built on BorgBackup. This first
milestone lands the project scaffold and the Layer 1 engine:
- borg-apiscp-backup: per-site named archives (siteN-shadow/info/db), fresh
  per-site DB dumps via ApisCP site-context export, system + custom archives,
  borg prune retention per prefix, Prometheus metrics, email notifications.
- apiscp-borg-common.sh: logging, mail, site/owner helpers.
- config example, systemd service+timer, install.sh, README, DESIGN.

Borg preserves POSIX ACLs and xattrs natively, so (unlike the kopia engine) no
metadata sidecar is required; DESIGN.md records how Borg reshapes the design.
Repository/restore tools, Layer 2 panel integration, hooks, uninstall, and the
full reference are the next milestones.
2026-07-24 22:33:37 +01:00

305 lines
12 KiB
Bash

#!/bin/sh
#
# borg-apiscp-backup: an ApisCP-aware backup engine for BorgBackup.
#
# On every run, in order:
# 1. Refresh ApisCP's per-site database dumps (backup_dbs.php) FIRST, so the
# logical .sql exports under each site are current before archiving them.
# 2. Create one Borg archive per site subtree (shadow, info), named with a
# structured prefix (siteN-shadow-{now}) so per-site restore and retention
# are glob-addressable. Borg preserves POSIX ACLs and xattrs natively, so
# no metadata sidecar is required (unlike the kopia engine).
# 3. Dump and archive each site's databases via ApisCP's site-context export.
# 4. Archive the shared system paths (_system-{now}) and any custom paths.
# 5. Apply retention with `borg prune` per archive prefix.
# 6. Emit Prometheus textfile metrics and, if configured, email the outcome.
#
# Config: /etc/apiscp-borg/config (see apiscp-borg.config.example).
# Exit status: 0 if every archive was created, 1 if any failed.
set -u
# --- locate the shared library -------------------------------------------------
_self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
for _cand in \
"${APISCP_BORG_LIB:-}" \
"$_self_dir/../lib/apiscp-borg-common.sh" \
/usr/local/lib/apiscp-borg/apiscp-borg-common.sh \
/usr/lib/apiscp-borg/apiscp-borg-common.sh; do
[ -n "$_cand" ] && [ -r "$_cand" ] && { . "$_cand"; _lib_loaded=1; break; }
done
[ "${_lib_loaded:-0}" = 1 ] || { echo "FATAL: cannot find apiscp-borg-common.sh" >&2; exit 1; }
# --- configuration -------------------------------------------------------------
CONFIG_FILE="${APISCP_BORG_CONFIG:-/etc/apiscp-borg/config}"
# shellcheck source=/dev/null
[ -r "$CONFIG_FILE" ] && . "$CONFIG_FILE"
: "${APNSCP_ROOT:=/usr/local/apnscp}"
: "${APNSCP_CMD:=/usr/local/apnscp/bin/cmd}"
: "${VIRTBASE:=/home/virtual}"
: "${STAGE:=/var/lib/apiscp-borg}"
: "${RUN_DB_EXPORT:=1}"
: "${DB_EXPORT_CMD:=$APNSCP_ROOT/bin/scripts/backup_dbs.php}"
: "${SYSTEM_PATHS:=/etc /opt}"
: "${SYSTEM_GLOBS:=/var/log/mailer_table* /var/lib/mysql/mysql-grants* /var/lib/pgsql/*/backups /root/apnscp* /root/license*}"
: "${METRICS_DIR:=}"
: "${LOCK_FILE:=/run/apiscp-borg.lock}"
: "${BACKUP_SITES:=all}"
: "${BACKUP_SYSTEM:=1}"
: "${BACKUP_DATABASES:=1}"
: "${BACKUP_PATHS:=}"
: "${BACKUP_EXCLUDES:=}"
: "${CAPTURE_META_SIDECAR:=0}"
: "${NOTIFY_EMAIL:=}"
: "${NOTIFY_ON:=failure}"
: "${NOTIFY_FROM:=}"
: "${RETENTION_KEEP_WITHIN:=}"
: "${RETENTION_KEEP_DAILY:=}"
: "${RETENTION_KEEP_WEEKLY:=}"
: "${RETENTION_KEEP_MONTHLY:=}"
: "${RETENTION_KEEP_ANNUAL:=}"
: "${BORG_REPO:=}"
: "${BORG_RSH:=ssh -o BatchMode=yes}"
[ -n "$BORG_REPO" ] || akp_die "BORG_REPO is not set (edit $CONFIG_FILE)"
export BORG_REPO BORG_RSH
[ -n "${BORG_PASSPHRASE:-}" ] && export BORG_PASSPHRASE
# Do not block on interactive prompts (unknown repo, etc.) in an unattended run.
export BORG_RELOCATED_REPO_ACCESS_IS_OK="${BORG_RELOCATED_REPO_ACCESS_IS_OK:-no}"
# akp_site_selected SITE: succeed if SITE should be backed up per BACKUP_SITES.
akp_site_selected() {
[ "$BACKUP_SITES" = "all" ] && return 0
[ "$BACKUP_SITES" = "none" ] && return 1
_want="$1"; _ifs=$IFS; IFS=,
for _s in $BACKUP_SITES; do
_s=$(printf '%s' "$_s" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
[ "$_s" = "$_want" ] && { IFS=$_ifs; return 0; }
done
IFS=$_ifs; return 1
}
# --- single-instance lock ------------------------------------------------------
if command -v flock >/dev/null 2>&1; then
exec 9>"$LOCK_FILE" || akp_die "cannot open lock $LOCK_FILE"
flock -n 9 || akp_die "another run holds $LOCK_FILE; aborting"
fi
# --- metrics + notify accounting ----------------------------------------------
_start_epoch=$(date +%s 2>/dev/null || echo 0)
OK=0
FAILED=0
DB_EXPORT_RC=-1
_reached_end=0
_detect_metrics_dir() {
[ -n "$METRICS_DIR" ] && { printf '%s' "$METRICS_DIR"; return; }
for d in /var/lib/node_exporter/textfile_collector /var/lib/prometheus/node-exporter /var/lib/prometheus/node_exporter; do
[ -d "$d" ] && { printf '%s' "$d"; return; }
done
printf ''
}
write_metrics() {
_mdir=$(_detect_metrics_dir)
[ -n "$_mdir" ] || { akp_warn "no textfile collector dir; metrics not written"; return; }
_now=$(date +%s 2>/dev/null || echo 0)
_dur=$(( _now - _start_epoch ))
_run_ok=0; [ "$FAILED" -eq 0 ] && _run_ok=1
_tmp="$_mdir/.apiscp-borg.prom.$$"
{
echo "# HELP apiscp_borg_run_success Whether the last run archived every source (1) or not (0)."
echo "# TYPE apiscp_borg_run_success gauge"
echo "apiscp_borg_run_success $_run_ok"
echo "# HELP apiscp_borg_archives_ok Archives created successfully in the last run."
echo "# TYPE apiscp_borg_archives_ok gauge"
echo "apiscp_borg_archives_ok $OK"
echo "# HELP apiscp_borg_archives_failed Archives that failed in the last run."
echo "# TYPE apiscp_borg_archives_failed gauge"
echo "apiscp_borg_archives_failed $FAILED"
echo "# HELP apiscp_borg_duration_seconds Wall-clock duration of the last run."
echo "# TYPE apiscp_borg_duration_seconds gauge"
echo "apiscp_borg_duration_seconds $_dur"
echo "# HELP apiscp_borg_db_export_success backup_dbs.php exit (1 ok, 0 failed, -1 skipped)."
echo "# TYPE apiscp_borg_db_export_success gauge"
echo "apiscp_borg_db_export_success $DB_EXPORT_RC"
echo "# HELP apiscp_borg_last_run_timestamp_seconds Unix time the last run finished."
echo "# TYPE apiscp_borg_last_run_timestamp_seconds gauge"
echo "apiscp_borg_last_run_timestamp_seconds $_now"
} > "$_tmp" 2>/dev/null && mv -f "$_tmp" "$_mdir/apiscp-borg.prom" 2>/dev/null \
|| akp_warn "failed to write metrics to $_mdir"
}
notify() {
[ -n "${NOTIFY_EMAIL:-}" ] || return 0
_n_ok=0; { [ "$FAILED" -eq 0 ] && [ "$_reached_end" -eq 1 ]; } && _n_ok=1
case "${NOTIFY_ON:-failure}" in
never) return 0 ;;
always) ;;
*) [ "$_n_ok" -eq 1 ] && return 0 ;;
esac
_n_host=$(hostname 2>/dev/null || echo apiscp)
_n_dur=$(( $(date +%s 2>/dev/null || echo 0) - _start_epoch ))
_n_res=$( [ "$_n_ok" -eq 1 ] && echo success || echo FAILURE )
_n_db=$( case "$DB_EXPORT_RC" in 1) echo ok ;; 0) echo failed ;; *) echo skipped ;; esac )
_n_body=$(printf 'apiscp-borg backup run on %s\n\nResult: %s\nFinished: %s\nArchives: %s ok, %s failed\nDatabases: %s\nDuration: %ss\nRepository: %s\n' \
"$_n_host" "$_n_res" "$(_akp_now)" "$OK" "$FAILED" "$_n_db" "$_n_dur" "$BORG_REPO")
[ "$_n_ok" -eq 1 ] || _n_body="$_n_body
See 'journalctl -t ${APISCP_BORG_LOG_TAG:-apiscp-borg}' on $_n_host for details."
akp_send_mail "$NOTIFY_EMAIL" "[apiscp-borg] backup $_n_res on $_n_host" "$_n_body" "${NOTIFY_FROM:-}" \
&& akp_log "notification emailed to $NOTIFY_EMAIL" || akp_warn "notification email failed"
}
on_exit() { write_metrics; notify; }
trap on_exit EXIT INT TERM
# Build the shared --exclude argument list (always exclude the restore staging).
_add_excludes() {
set -- --exclude '*/.borg-restore' --exclude '/.borg-restore'
_oifs=$IFS; IFS=',
'
for _p in ${BACKUP_EXCLUDES:-}; do
[ -n "$_p" ] && set -- "$@" --exclude "$_p"
done
IFS=$_oifs
EXCLUDE_ARGS="$*" # only used for logging; real calls use "$@" below
BORG_EXCLUDES_SET=1
# Export via positional re-use is awkward in sh; callers rebuild instead.
}
# create_archive NAME PATH...: borg create with excludes; update counters.
create_archive() {
_name="$1"; shift
[ "$#" -ge 1 ] || return 0
# Assemble excludes fresh each call.
set -- "$@" # keep source paths in "$@"
_srcs="$*"
# shellcheck disable=SC2086
if _create_with_excludes "$_name" "$@"; then
OK=$(( OK + 1 )); akp_log "archived $_name"
else
FAILED=$(( FAILED + 1 )); akp_warn "archive FAILED: $_name ($_srcs)"
fi
}
# _create_with_excludes NAME SRC...: run borg create '::NAME-{now}' with excludes.
_create_with_excludes() {
_cn="$1"; shift
# Build: create [excludes] ::NAME-{now} SRC...
set -- ::"$_cn"-{now} "$@"
# Prepend excludes.
_ex_oifs=$IFS; IFS=',
'
for _p in ${BACKUP_EXCLUDES:-}; do
[ -n "$_p" ] && set -- --exclude "$_p" "$@"
done
IFS=$_ex_oifs
set -- --exclude '*/.borg-restore' --exclude '/.borg-restore' "$@"
akp_borg create --compression "${BORG_COMPRESSION:-zstd}" "$@" >/dev/null 2>&1
}
# prune_prefix PREFIX: apply retention to archives named PREFIX-* if any keep rule set.
prune_prefix() {
_pref="$1"
set --
[ -n "$RETENTION_KEEP_WITHIN" ] && set -- "$@" --keep-within "$RETENTION_KEEP_WITHIN"
[ -n "$RETENTION_KEEP_DAILY" ] && set -- "$@" --keep-daily "$RETENTION_KEEP_DAILY"
[ -n "$RETENTION_KEEP_WEEKLY" ] && set -- "$@" --keep-weekly "$RETENTION_KEEP_WEEKLY"
[ -n "$RETENTION_KEEP_MONTHLY" ] && set -- "$@" --keep-monthly "$RETENTION_KEEP_MONTHLY"
[ -n "$RETENTION_KEEP_ANNUAL" ] && set -- "$@" --keep-yearly "$RETENTION_KEEP_ANNUAL"
[ "$#" -ge 1 ] || return 0
if akp_borg prune --glob-archives "$_pref-*" "$@" >/dev/null 2>&1; then
akp_log "pruned $_pref-* per retention"
else
akp_warn "prune failed for $_pref-*"
fi
}
# ==============================================================================
akp_log "run start (repo: $BORG_REPO)"
# 1. Refresh per-site database exports before archiving them.
if [ "$RUN_DB_EXPORT" = "1" ]; then
if [ -x "$DB_EXPORT_CMD" ] || [ -r "$DB_EXPORT_CMD" ]; then
akp_log "running ApisCP database export: $DB_EXPORT_CMD"
if "$DB_EXPORT_CMD" >/dev/null 2>&1; then DB_EXPORT_RC=1; else DB_EXPORT_RC=0; akp_warn "database export returned non-zero"; fi
else
akp_warn "DB_EXPORT_CMD not found ($DB_EXPORT_CMD); skipping"
fi
fi
# 2 + 3. Per-site archives and databases.
if [ -d "$VIRTBASE" ]; then
for sitedir in "$VIRTBASE"/site[0-9]*; do
[ -d "$sitedir" ] || continue
site=$(basename "$sitedir")
akp_site_selected "$site" || { akp_log "skip $site (not in BACKUP_SITES)"; continue; }
[ -d "$sitedir/shadow" ] && create_archive "$site-shadow" "$sitedir/shadow"
[ -d "$sitedir/info" ] && create_archive "$site-info" "$sitedir/info"
if [ "$BACKUP_DATABASES" = "1" ]; then
dbdom=$(akp_site_domain "$site")
if [ -z "$dbdom" ]; then
akp_warn "no domain for $site; skipping database dump"
elif [ ! -x "$APNSCP_CMD" ] && [ ! -r "$APNSCP_CMD" ]; then
akp_warn "APNSCP_CMD not found ($APNSCP_CMD); skipping database dump for $site"
else
db_siterel="/tmp/.apiscp-borg-db"
dbroot="$sitedir/fst$db_siterel"
rm -rf "$dbroot"
_dbany=0
for eng in mysql pgsql; do
dblist=$("$APNSCP_CMD" -o json -d "$dbdom" "$eng:list_databases" 2>/dev/null) || continue
[ -n "$dblist" ] || continue
for db in $(printf '%s\n' "$dblist" | grep -oE '"[A-Za-z0-9_-]+"' | sed 's/"//g'); do
[ -n "$db" ] || continue
if "$APNSCP_CMD" -d "$dbdom" "$eng:export" "$db" "$db_siterel/$eng/$db.sql" >/dev/null 2>&1; then
_dbany=1; akp_log "exported $eng db $db for $site"
else
akp_warn "failed to export $eng db $db for $site"
fi
done
done
[ "$_dbany" = 1 ] && [ -d "$dbroot" ] && create_archive "$site-db" "$dbroot"
rm -rf "$dbroot"
fi
fi
# Per-site retention.
prune_prefix "$site-shadow"
prune_prefix "$site-info"
prune_prefix "$site-db"
done
else
akp_warn "VIRTBASE $VIRTBASE not present; no per-site backups"
fi
# 4. Shared system paths (one archive), then custom paths.
if [ "$BACKUP_SYSTEM" = "1" ]; then
# Expand globs; keep only existing paths.
set --
# shellcheck disable=SC2086
for p in $SYSTEM_PATHS $SYSTEM_GLOBS; do
[ -e "$p" ] && set -- "$@" "$p"
done
[ "$#" -ge 1 ] && create_archive "_system" "$@"
prune_prefix "_system"
else
akp_log "skipping shared system paths (BACKUP_SYSTEM=0)"
fi
if [ -n "$BACKUP_PATHS" ]; then
_paths=$(printf '%s' "$BACKUP_PATHS" | tr ',' ' ')
for cp in $_paths; do
[ -e "$cp" ] || { akp_warn "skip custom path (missing): $cp"; continue; }
slug=$(akp_slug "$cp")
create_archive "_custom-$slug" "$cp"
prune_prefix "_custom-$slug"
done
fi
akp_log "run done: $OK ok, $FAILED failed"
_reached_end=1
[ "$FAILED" -eq 0 ]