Add interactive prod backup script for disaster-recovery archives. #10
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
/deploy/certs
|
/deploy/certs
|
||||||
/deploy/certbot/www
|
/deploy/certbot/www
|
||||||
|
/deploy/backups
|
||||||
|
|
||||||
/monero-wallet-rpc/wallet
|
/monero-wallet-rpc/wallet
|
||||||
/electrum-daemon/wallet
|
/electrum-daemon/wallet
|
||||||
|
|||||||
@@ -159,10 +159,18 @@ Tor Browser will then show ".onion available" when visitors open the clearnet si
|
|||||||
3. Finish the setup checklist in settings.
|
3. Finish the setup checklist in settings.
|
||||||
4. Connect SimpleX notifications in shop settings.
|
4. Connect SimpleX notifications in shop settings.
|
||||||
|
|
||||||
## 10. Updates
|
## Updates
|
||||||
|
|
||||||
|
This pulls the latest code and rebuilds the stack (`deploy.sh`).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./deploy/scripts/update.sh
|
./deploy/scripts/update.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This pulls the latest code and rebuilds the stack (`deploy.sh`).
|
## Backups
|
||||||
|
|
||||||
|
This creates a full disaster-recovery archive.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy/scripts/backup.sh
|
||||||
|
```
|
||||||
|
|||||||
Executable
+335
@@ -0,0 +1,335 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
ENV_FILE="${ROOT_DIR}/.env.prod"
|
||||||
|
COMPOSE_FILE="${ROOT_DIR}/docker-compose.prod.yml"
|
||||||
|
BACKUP_DIR="${ROOT_DIR}/deploy/backups"
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
echo "Missing ${ENV_FILE}. Copy .env.example to .env.prod and configure it." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
if [[ -z "${COMPOSE_PROJECT_NAME:-}" ]]; then
|
||||||
|
echo "COMPOSE_PROJECT_NAME is not set in .env.prod" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${POSTGRES_USER:-}" || -z "${POSTGRES_DB:-}" ]]; then
|
||||||
|
echo "POSTGRES_USER and POSTGRES_DB must be set in .env.prod" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${MONERO_WALLET_DIR:-}" ]]; then
|
||||||
|
echo "MONERO_WALLET_DIR is not set in .env.prod" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${ELECTRUM_WALLET_DIR:-}" ]]; then
|
||||||
|
echo "ELECTRUM_WALLET_DIR is not set in .env.prod" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
compose() {
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_host_path() {
|
||||||
|
local rel="${1:-}"
|
||||||
|
local label="$2"
|
||||||
|
|
||||||
|
if [[ -z "$rel" ]]; then
|
||||||
|
echo "Cannot resolve empty path for ${label}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$rel" == /* ]]; then
|
||||||
|
echo "$rel"
|
||||||
|
else
|
||||||
|
echo "${ROOT_DIR}/${rel#./}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
build_compose_volume_name() {
|
||||||
|
local vol_key="$1"
|
||||||
|
echo "${COMPOSE_PROJECT_NAME}_${vol_key}"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_min_bytes() {
|
||||||
|
local file="$1"
|
||||||
|
local min_bytes="$2"
|
||||||
|
local label="$3"
|
||||||
|
|
||||||
|
if [[ ! -f "$file" ]] || (( $(stat -c%s "$file") < min_bytes )); then
|
||||||
|
echo "Backup failed: ${label} is missing or empty." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_tar_entries() {
|
||||||
|
local file="$1"
|
||||||
|
local label="$2"
|
||||||
|
local entry_count
|
||||||
|
|
||||||
|
if [[ ! -f "$file" ]]; then
|
||||||
|
echo "Backup failed: ${label} is missing or has no archive entries." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
entry_count="$(tar tf "$file" 2>/dev/null | wc -l)"
|
||||||
|
|
||||||
|
if (( entry_count < 1 )); then
|
||||||
|
echo "Backup failed: ${label} is missing or has no archive entries." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
tar_volume() {
|
||||||
|
local vol_key="$1"
|
||||||
|
local archive_name="$2"
|
||||||
|
local vol_name
|
||||||
|
|
||||||
|
vol_name="$(build_compose_volume_name "$vol_key")"
|
||||||
|
|
||||||
|
if ! docker volume inspect "$vol_name" >/dev/null 2>&1; then
|
||||||
|
echo "Backup failed: Docker volume ${vol_name} not found. Is the stack deployed?" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "${vol_name}:/data:ro" \
|
||||||
|
-v "${STAGING_TMP}:/backup" \
|
||||||
|
alpine tar -cf "/backup/${archive_name}" -C /data .
|
||||||
|
}
|
||||||
|
|
||||||
|
SHOULD_STOP=false
|
||||||
|
STOPPED=false
|
||||||
|
STAGING_TMP=""
|
||||||
|
ARCHIVE_TMP=""
|
||||||
|
|
||||||
|
on_exit() {
|
||||||
|
if [[ -n "$STAGING_TMP" && -d "$STAGING_TMP" ]]; then
|
||||||
|
rm -rf "$STAGING_TMP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$ARCHIVE_TMP" && -f "$ARCHIVE_TMP" ]]; then
|
||||||
|
rm -f "$ARCHIVE_TMP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$STOPPED" == true ]]; then
|
||||||
|
echo "Restarting NullCart..."
|
||||||
|
|
||||||
|
compose start
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
trap on_exit EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
echo "Checking if PostgreSQL is running and accepting connections..."
|
||||||
|
|
||||||
|
cid="$(compose ps --status running -q postgres 2>/dev/null | head -n1)"
|
||||||
|
|
||||||
|
if [[ -z "$cid" ]]; then
|
||||||
|
echo "Backup failed: PostgreSQL is not running. Start the stack (for example: docker compose --env-file \"${ENV_FILE}\" -f \"${COMPOSE_FILE}\" up -d postgres)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! compose exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" >/dev/null 2>&1; then
|
||||||
|
echo "Backup failed: PostgreSQL is not accepting connections. Check container logs or wait until the database is healthy." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if the script is running from an interactive terminal
|
||||||
|
if [[ ! -r /dev/tty ]]; then
|
||||||
|
echo "Run this script from an interactive terminal (for example: ssh -t)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Would you like to stop NullCart before doing backup? Doing backup while NullCart is running may lead to backup data corruption [y/n] (recommended y): '
|
||||||
|
|
||||||
|
read -n 1 -r STOP_ANSWER </dev/tty || {
|
||||||
|
echo "Backup failed: could not read answer from the terminal." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
|
||||||
|
case "$STOP_ANSWER" in
|
||||||
|
y)
|
||||||
|
SHOULD_STOP=true
|
||||||
|
;;
|
||||||
|
n)
|
||||||
|
echo "Continuing backup with NullCart running..."
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Invalid answer. Use y or n." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
STAGING_TMP="$(mktemp -d)"
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
TIMESTAMP="$(date -u +%Y%m%d-%H%M%S)"
|
||||||
|
ARCHIVE_PATH="${BACKUP_DIR}/nullcart-backup-${TIMESTAMP}.tar.gz"
|
||||||
|
ARCHIVE_TMP="${ARCHIVE_PATH}.tmp"
|
||||||
|
|
||||||
|
if [[ "$SHOULD_STOP" == true ]]; then
|
||||||
|
stop_services=()
|
||||||
|
compose_services="$(compose config --services)"
|
||||||
|
|
||||||
|
while IFS= read -r svc; do
|
||||||
|
if [[ -z "$svc" || "$svc" == "postgres" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
stop_services+=("$svc")
|
||||||
|
done <<<"$compose_services"
|
||||||
|
|
||||||
|
if ((${#stop_services[@]} == 0)); then
|
||||||
|
echo "Backup failed: no application services found to stop in ${COMPOSE_FILE}." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Stopping NullCart application services (PostgreSQL stays up for dump)..."
|
||||||
|
|
||||||
|
compose stop "${stop_services[@]}"
|
||||||
|
STOPPED=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Backing up PostgreSQL..."
|
||||||
|
|
||||||
|
compose exec -T postgres pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc > "${STAGING_TMP}/postgres.dump"
|
||||||
|
require_min_bytes "${STAGING_TMP}/postgres.dump" 64 "postgres.dump"
|
||||||
|
|
||||||
|
echo "Backing up environment file..."
|
||||||
|
|
||||||
|
cp "$ENV_FILE" "${STAGING_TMP}/env.prod"
|
||||||
|
require_min_bytes "${STAGING_TMP}/env.prod" 1 "env.prod"
|
||||||
|
|
||||||
|
echo "Backing up uploads..."
|
||||||
|
|
||||||
|
UPLOADS_DIR="${ROOT_DIR}/backend/uploads"
|
||||||
|
|
||||||
|
if [[ ! -d "$UPLOADS_DIR" ]]; then
|
||||||
|
echo "Backup failed: uploads directory not found at ${UPLOADS_DIR}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
tar -cf "${STAGING_TMP}/uploads.tar" -C "${ROOT_DIR}/backend" uploads
|
||||||
|
require_tar_entries "${STAGING_TMP}/uploads.tar" "uploads.tar"
|
||||||
|
|
||||||
|
echo "Backing up Monero wallet..."
|
||||||
|
|
||||||
|
MONERO_WALLET_PATH="$(resolve_host_path "$MONERO_WALLET_DIR" "Monero wallet")"
|
||||||
|
|
||||||
|
if [[ ! -d "$MONERO_WALLET_PATH" ]]; then
|
||||||
|
echo "Backup failed: Monero wallet directory not found at ${MONERO_WALLET_PATH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$(ls -A "$MONERO_WALLET_PATH" 2>/dev/null || true)" ]]; then
|
||||||
|
echo "Backup failed: Monero wallet directory is empty at ${MONERO_WALLET_PATH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
tar -cf "${STAGING_TMP}/monero-wallet.tar" -C "$(dirname "$MONERO_WALLET_PATH")" "$(basename "$MONERO_WALLET_PATH")"
|
||||||
|
require_tar_entries "${STAGING_TMP}/monero-wallet.tar" "monero-wallet.tar"
|
||||||
|
|
||||||
|
echo "Backing up Electrum wallet..."
|
||||||
|
|
||||||
|
ELECTRUM_WALLET_PATH="$(resolve_host_path "$ELECTRUM_WALLET_DIR" "Electrum wallet")"
|
||||||
|
|
||||||
|
if [[ ! -d "$ELECTRUM_WALLET_PATH" ]]; then
|
||||||
|
echo "Backup failed: Electrum wallet directory not found at ${ELECTRUM_WALLET_PATH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$(ls -A "$ELECTRUM_WALLET_PATH" 2>/dev/null || true)" ]]; then
|
||||||
|
echo "Backup failed: Electrum wallet directory is empty at ${ELECTRUM_WALLET_PATH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
tar -cf "${STAGING_TMP}/electrum-wallet.tar" -C "$(dirname "$ELECTRUM_WALLET_PATH")" "$(basename "$ELECTRUM_WALLET_PATH")"
|
||||||
|
require_tar_entries "${STAGING_TMP}/electrum-wallet.tar" "electrum-wallet.tar"
|
||||||
|
|
||||||
|
echo "Backing up Docker volumes..."
|
||||||
|
|
||||||
|
echo "Backing up Tor hidden service volume..."
|
||||||
|
|
||||||
|
tar_volume "nullcart.tor.prod.data" "volume-tor.tar"
|
||||||
|
require_tar_entries "${STAGING_TMP}/volume-tor.tar" "volume-tor.tar"
|
||||||
|
|
||||||
|
echo "Backing up SimpleX volume..."
|
||||||
|
|
||||||
|
tar_volume "nullcart.simplex.prod.data" "volume-simplex.tar"
|
||||||
|
require_tar_entries "${STAGING_TMP}/volume-simplex.tar" "volume-simplex.tar"
|
||||||
|
|
||||||
|
echo "Backing up pgAdmin volume..."
|
||||||
|
|
||||||
|
tar_volume "nullcart.pgadmin.prod.data" "volume-pgadmin.tar"
|
||||||
|
require_tar_entries "${STAGING_TMP}/volume-pgadmin.tar" "volume-pgadmin.tar"
|
||||||
|
|
||||||
|
echo "Backing up TLS certs directory..."
|
||||||
|
|
||||||
|
CERTS_DIR="${ROOT_DIR}/deploy/certs"
|
||||||
|
|
||||||
|
if [[ ! -d "$CERTS_DIR" ]]; then
|
||||||
|
echo "Backup failed: TLS certs directory not found at ${CERTS_DIR}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
tar -cf "${STAGING_TMP}/certs.tar" -C "${ROOT_DIR}/deploy" certs
|
||||||
|
require_tar_entries "${STAGING_TMP}/certs.tar" "certs.tar"
|
||||||
|
|
||||||
|
echo "Backing up Git commit..."
|
||||||
|
|
||||||
|
GIT_REV=""
|
||||||
|
|
||||||
|
if git -C "$ROOT_DIR" rev-parse HEAD >/dev/null 2>&1; then
|
||||||
|
GIT_REV="$(git -C "$ROOT_DIR" rev-parse HEAD)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "NullCart backup"
|
||||||
|
echo "Created (UTC): $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||||
|
echo "Compose project: ${COMPOSE_PROJECT_NAME}"
|
||||||
|
echo "Stopped application services before pg_dump: $([[ "$STOPPED" == true ]] && echo yes || echo no)"
|
||||||
|
|
||||||
|
if [[ -n "$GIT_REV" ]]; then
|
||||||
|
echo "Git commit: ${GIT_REV}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Contents:"
|
||||||
|
echo " postgres.dump PostgreSQL custom-format dump (pg_restore)"
|
||||||
|
echo " env.prod Production environment file"
|
||||||
|
echo " uploads.tar backend/uploads"
|
||||||
|
echo " monero-wallet.tar Monero wallet directory"
|
||||||
|
echo " electrum-wallet.tar Electrum wallet directory"
|
||||||
|
echo " volume-tor.tar Tor hidden service keys"
|
||||||
|
echo " volume-simplex.tar SimpleX CLI data"
|
||||||
|
echo " volume-pgadmin.tar pgAdmin data"
|
||||||
|
echo " certs.tar deploy/certs (TLS)"
|
||||||
|
} > "${STAGING_TMP}/MANIFEST.txt"
|
||||||
|
|
||||||
|
require_min_bytes "${STAGING_TMP}/MANIFEST.txt" 1 "MANIFEST.txt"
|
||||||
|
|
||||||
|
rm -f "$ARCHIVE_TMP"
|
||||||
|
tar czf "$ARCHIVE_TMP" -C "$STAGING_TMP" .
|
||||||
|
tar tzf "$ARCHIVE_TMP" >/dev/null
|
||||||
|
chmod 600 "$ARCHIVE_TMP"
|
||||||
|
mv -f "$ARCHIVE_TMP" "$ARCHIVE_PATH"
|
||||||
|
|
||||||
|
echo "Backup complete: ${ARCHIVE_PATH}"
|
||||||
|
echo "This archive contains secrets. It is recommended to store it encrypted on an external storage."
|
||||||
Reference in New Issue
Block a user