diff --git a/settings.env b/settings.env new file mode 100644 index 0000000..7d654cf --- /dev/null +++ b/settings.env @@ -0,0 +1,17 @@ +# ========================================== +# Portainer Stack Backup – Settings +# ========================================== + +# Portainer Verbindung +PORTAINER_URL="https://192.168.178.25:9443" + +# Portainer API Key +API_KEY="ptr_QX6nhHj2A9wF/XD4wEQ6z/b+udOkIC3Ocao+bC1PP7M=" + +# Zielverzeichnis für Backups +OUT_DIR="/home/thorsten/Schreibtisch/Backup" + +# Archiv erstellen? +# 1 = tar.gz erzeugen +# 0 = nur Ordner behalten +CREATE_ARCHIVE=1 diff --git a/shell_portainer_stack_backup.sh b/shell_portainer_stack_backup.sh new file mode 100644 index 0000000..be4f62d --- /dev/null +++ b/shell_portainer_stack_backup.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +############################################ +# Portainer Stack Backup (quiet/basic logging) +# Settings file: settings.env (same dir as script) +# Required settings: +# PORTAINER_URL +# API_KEY +# OUT_DIR +# CREATE_ARCHIVE (0/1) +# +# Behavior: +# - Always writes a log file in the run directory while running +# - If CREATE_ARCHIVE=1: creates RUN_DIR.tar.gz and then removes RUN_DIR +# - If CREATE_ARCHIVE=0: keeps RUN_DIR (and the log inside it) +############################################ + +# ---------- load settings ---------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SETTINGS_FILE="$SCRIPT_DIR/settings.env" + +if [[ -f "$SETTINGS_FILE" ]]; then + # shellcheck disable=SC1090 + source "$SETTINGS_FILE" +else + echo "ERROR: settings.env not found in: $SCRIPT_DIR" >&2 + exit 1 +fi + +: "${PORTAINER_URL:?PORTAINER_URL not set in settings.env}" +: "${API_KEY:?API_KEY not set in settings.env}" +: "${OUT_DIR:?OUT_DIR not set in settings.env}" +: "${CREATE_ARCHIVE:?CREATE_ARCHIVE not set in settings.env}" + +if [[ "$CREATE_ARCHIVE" != "0" && "$CREATE_ARCHIVE" != "1" ]]; then + echo "ERROR: CREATE_ARCHIVE must be 0 or 1 (got: '$CREATE_ARCHIVE')" >&2 + exit 1 +fi + +command -v curl >/dev/null 2>&1 || { echo "ERROR: curl missing"; exit 1; } +command -v jq >/dev/null 2>&1 || { echo "ERROR: jq missing"; exit 1; } + +# tar is only required when CREATE_ARCHIVE=1 +if [[ "$CREATE_ARCHIVE" == "1" ]]; then + command -v tar >/dev/null 2>&1 || { echo "ERROR: tar missing (required because CREATE_ARCHIVE=1)"; exit 1; } +fi + +# ---------- output + logging ---------- +BASE_OUT="$OUT_DIR" +RUN_NAME="portainer-stack-backup-$(date +%F_%H%M%S)" +RUN_DIR="$BASE_OUT/$RUN_NAME" +STACKS_DIR="$RUN_DIR/stacks" +mkdir -p "$STACKS_DIR" + +LOG_FILE="$RUN_DIR/backup.log" +# Mirror ALL output (stdout+stderr) to terminal + logfile +exec > >(tee -a "$LOG_FILE") 2>&1 + +# ---------- minimal log helpers ---------- +say() { echo "$*"; } +ok() { echo "[OK] $*"; } +fail() { echo "[FAIL] $*"; } + +# ---------- curl defaults ---------- +# -k to tolerate self-signed certs on 9443 +CURL_BASE=(curl -skS) +HDR=(-H "X-API-Key: ${API_KEY}") + +############################################ +# Header +############################################ +say "Portainer Stack Backup started" +say "Target: $RUN_DIR" +say + +############################################ +# Preflight +############################################ +if "${CURL_BASE[@]}" "$PORTAINER_URL/api/status" >/dev/null; then + say "Portainer reachable" +else + say "Portainer not reachable" + exit 1 +fi + +if "${CURL_BASE[@]}" "${HDR[@]}" "$PORTAINER_URL/api/users/me" >/dev/null; then + say "API key valid" +else + say "API key invalid" + exit 1 +fi + +stacks_json="$("${CURL_BASE[@]}" "${HDR[@]}" "$PORTAINER_URL/api/stacks")" +if ! echo "$stacks_json" | jq -e 'type=="array"' >/dev/null 2>&1; then + say "Unexpected response while fetching stacks (not a JSON array)." + exit 1 +fi + +stack_count="$(echo "$stacks_json" | jq 'length')" +say "Stacks found: $stack_count" +say +say "Starting backup" +say + +############################################ +# Export stacks (one line per stack) +############################################ +ok_count=0 +fail_count=0 + +while IFS= read -r s; do + id="$(echo "$s" | jq -r '.Id')" + name_raw="$(echo "$s" | jq -r '.Name')" + endpoint="$(echo "$s" | jq -r '.EndpointId')" + + # sanitize name for filesystem + name_fs="$(echo "$name_raw" | tr '/ ' '__' | tr -cd '[:alnum:]_.-')" + [[ -n "$name_fs" ]] || name_fs="stack_${id}" + + stack_dir="$STACKS_DIR/$name_fs" + mkdir -p "$stack_dir" + + # minimal meta (helps mapping) + echo "$s" | jq '.' > "$stack_dir/meta.json" + + # fetch stack file and extract StackFileContent + file_json="$("${CURL_BASE[@]}" "${HDR[@]}" \ + "$PORTAINER_URL/api/stacks/$id/file?endpointId=$endpoint" 2>/dev/null || true)" + + content="$(echo "$file_json" | jq -r '.StackFileContent // empty' 2>/dev/null || true)" + + if [[ -n "$content" && "$content" != "null" ]]; then + printf "%s\n" "$content" > "$stack_dir/docker-compose.yml" + ok "$name_raw" + ok_count=$((ok_count+1)) + else + fail "$name_raw" + fail_count=$((fail_count+1)) + # keep raw response for troubleshooting (quiet, but useful) + printf "%s\n" "$file_json" > "$stack_dir/_file_response.json" 2>/dev/null || true + fi +done < <(echo "$stacks_json" | jq -c '.[]') + +say +say "Backup finished: OK=$ok_count FAIL=$fail_count" + +############################################ +# Optional archive + cleanup +############################################ +if [[ "$CREATE_ARCHIVE" == "1" ]]; then + tarball="${RUN_DIR}.tar.gz" + tar -C "$RUN_DIR" -czf "$tarball" . + say "Archive created: $(basename "$tarball")" + + # Cleanup: remove the run directory after successful archive creation + rm -rf "$RUN_DIR" + say "Cleanup done: removed $RUN_DIR" +else + say "Keeping directory: $RUN_DIR" + say "Log: $LOG_FILE" +fi + +say "Done."