#!/bin/sh # Full-instance backups for a Docker Compose install of Sure. # # Runs as the `backup` service, from the same Sure image as web/worker (see # compose.example.yml). A backup bundle holds everything needed to bring the # same version back up elsewhere: # # $BACKUP_DIR//-/ # manifest.json version, commit, image tags, schema version, ... # db.dump pg_dump custom format # storage.tar.gz the app-storage volume (uploaded files) # config/ the host's .env (contains secrets), compose file(s) and # bin/sure-backup # restore.sh restores the bundle into a new folder (see `recover`) # SHA256SUMS # # Usage (from the compose directory): # docker compose run --rm backup create [--version X] [--commit SHA] # docker compose run --rm backup list # docker compose run --rm backup restore [--yes] [--force] # docker compose run --rm backup prune # # With no arguments it runs `scheduled` (create + prune) every day at the time # in BACKUP_SCHEDULE ("M H * * *", UTC). Off-site copies are made by the # separate `backup-offsite` service. # # Or on the host, from the folder with compose.yml, without a backup service # (works for any Sure version, including 0.7.5 and older): # sh sure-backup create | list | restore | prune # # On the host, a bundle restores itself into a new project folder: # sh /restore.sh [--target DIR] [--port N] [--with-worker | --without-worker] [--yes] # which runs `sure-backup recover ...`. It needs Docker with Compose. set -e # shellcheck disable=SC3040 if (set -o pipefail 2>/dev/null); then set -o pipefail fi BACKUP_DIR=${BACKUP_DIR_IN_CONTAINER:-/backups} STORAGE_DIR=${SURE_STORAGE_DIR:-/rails/storage} CONFIG_DIR=${SURE_CONFIG_DIR:-/config} STAMP_FILE="${STORAGE_DIR}/.sure-runtime" IMAGE_REPO=${SURE_IMAGE_REPO:-ghcr.io/we-promise/sure} BACKUP_SCHEDULE=${BACKUP_SCHEDULE:-0 2 * * *} BACKUP_KEEP_DAYS=${BACKUP_KEEP_DAYS:-7} INSTANCE_ID=${INSTANCE_ID:-default} POSTGRES_PORT=${POSTGRES_PORT:-5432} log() { echo "[sure-backup] $*"; } warn() { echo "[sure-backup] WARNING: $*" >&2; } die() { echo "[sure-backup] ERROR: $*" >&2; exit 1; } usage() { awk 'NR > 1 && /^#/ { sub(/^# ?/, ""); print; next } NR > 1 { exit }' "$0" } # --- setup ------------------------------------------------------------------- ensure_deps() { # The Sure image has all of these. The apk fallback keeps Alpine-based # backup services from v0.7.5 working. missing="" if ! command -v pg_dump >/dev/null 2>&1; then # Prefer the client the Sure image ships (17); dumps it makes need a # pg_restore at least that new. Older Alpine releases only have 16. if apk search -q postgresql17-client 2>/dev/null | grep -qx postgresql17-client; then missing="$missing postgresql17-client" else missing="$missing postgresql16-client~=16" fi fi # BusyBox tar lacks --numeric-owner/--exclude semantics we rely on. tar --version 2>/dev/null | grep -q "GNU tar" || missing="$missing tar~=1" [ -z "$missing" ] && return 0 command -v apk >/dev/null 2>&1 || die "missing tools (${missing# }) and no apk to install them" log "Installing:${missing}" # shellcheck disable=SC2086 if ! apk_out=$(apk add --no-cache --quiet $missing 2>&1); then echo "$apk_out" >&2 die "could not install required packages" fi } check_db_env() { if [ -z "$POSTGRES_DB" ] || [ -z "$POSTGRES_USER" ] || [ -z "$POSTGRES_PASSWORD" ] || [ -z "$POSTGRES_HOST" ]; then die "database connection variables are not fully set" fi case "${POSTGRES_HOST}${POSTGRES_PORT}${POSTGRES_DB}${POSTGRES_USER}${POSTGRES_PASSWORD}" in *" "*) die "database connection parameters must not contain newlines" ;; esac } check_keep_days() { if ! [ "$BACKUP_KEEP_DAYS" -gt 0 ] 2>/dev/null; then die "BACKUP_KEEP_DAYS must be a positive integer (got: ${BACKUP_KEEP_DAYS})" fi } # Write a .pgpass with escaped fields so the password never hits argv. setup_pgpass() { WORK_DIR=$(umask 077 && mktemp -d) trap 'rm -rf "${WORK_DIR}"' EXIT INT TERM pg_esc() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/:/\\:/g'; } # "*" for the database so dropdb/createdb can use the maintenance database. (umask 077 && printf '%s:%s:*:%s:%s\n' \ "$(pg_esc "$POSTGRES_HOST")" "$(pg_esc "$POSTGRES_PORT")" \ "$(pg_esc "$POSTGRES_USER")" "$(pg_esc "$POSTGRES_PASSWORD")" > "${WORK_DIR}/.pgpass") export PGPASSFILE="${WORK_DIR}/.pgpass" export PGHOST="$POSTGRES_HOST" PGPORT="$POSTGRES_PORT" PGUSER="$POSTGRES_USER" } psql_value() { psql -X -q -t -A -v ON_ERROR_STOP=1 -d "${2:-$POSTGRES_DB}" -c "$1" } json_str() { printf '"%s"' "$(printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')" } manifest_value() { sed -n "s/^[[:space:]]*\"$2\": \"\([^\"]*\)\".*/\1/p" "$1/manifest.json" | head -n 1 } # The Sure image ships an empty /rails/storage, so "is a directory" is not # enough: the uploaded-files volume must actually be mounted there. storage_mounted() { [ -d "$STORAGE_DIR" ] && awk -v d="$STORAGE_DIR" '$2 == d { found = 1 } END { exit !found }' /proc/mounts 2>/dev/null } stamp_value() { sed -n "s/^$1=//p" "$STAMP_FILE" 2>/dev/null | head -n 1 } # --- create ------------------------------------------------------------------ cmd_create() { trigger=manual version="" commit="" while [ $# -gt 0 ]; do case "$1" in --version) version=$2; shift 2 ;; --commit) commit=$2; shift 2 ;; --scheduled) trigger=scheduled; shift ;; *) die "unknown option for create: $1" ;; esac done check_db_env ensure_deps setup_pgpass db_schema=$(psql_value "SELECT max(version) FROM schema_migrations" 2>/dev/null) \ || die "cannot read schema_migrations from ${POSTGRES_DB}; is the database initialized?" # The web container stamps the version that migrated the database. Trust it # only if the database is still at that schema, otherwise the stamp is stale # (e.g. the image was swapped but web has not booted the new one yet). if [ -f "$STAMP_FILE" ]; then stamp_version=$(stamp_value SURE_VERSION) stamp_schema=$(stamp_value SURE_SCHEMA_VERSION) if [ -n "$version" ] && [ "$version" != "$stamp_version" ]; then die "--version ${version} disagrees with the running app (${stamp_version}); drop --version" fi if [ "$stamp_schema" != "$db_schema" ]; then die "database schema ${db_schema} does not match the app's stamped schema ${stamp_schema} (version ${stamp_version}). Start the web service so it re-stamps, then retry." fi version=$stamp_version commit=${commit:-$(stamp_value SURE_COMMIT)} elif [ -z "$version" ] && [ -f /rails/.sure-version ] && [ -f /rails/db/schema.rb ]; then # Running in a Sure image that predates stamping: use the image's own # version, but only if the database is at that version's schema. image_version=$(tr -d '[:space:]' < /rails/.sure-version) image_schema=$(sed -n 's/.*define(version: \([0-9_]*\)).*/\1/p' /rails/db/schema.rb | tr -d _) if [ "$image_schema" != "$db_schema" ]; then die "database schema ${db_schema} does not match this image (Sure ${image_version}, schema ${image_schema}). Run the backup with the image the app runs on, or pass --version." fi version=$image_version commit=${commit:-${BUILD_COMMIT_SHA:-}} log "No version stamp; using this image's version ${version} (database schema matches)" elif [ -z "$version" ]; then die "no version stamp at ${STAMP_FILE} (app image predates stamping). Pass --version, e.g. --version 0.7.4" else warn "no version stamp; trusting --version ${version}" fi case "$version" in "" | *[!A-Za-z0-9._+-]*) die "invalid version: ${version}" ;; esac name="$(date -u +%Y-%m-%d_%H%M%S)-${trigger}" final_dir="${BACKUP_DIR}/${version}/${name}" [ -e "$final_dir" ] && die "${final_dir} already exists" tmp_dir="${BACKUP_DIR}/.tmp-${name}-$$" mkdir -p "$BACKUP_DIR" (umask 077 && mkdir -p "${tmp_dir}/config") trap 'rm -rf "${WORK_DIR}" "${tmp_dir}"' EXIT INT TERM log "Backing up Sure ${version} to ${version}/${name}" log "Dumping database ${POSTGRES_DB}..." (umask 077 && pg_dump -Fc -d "$POSTGRES_DB" -f "${tmp_dir}/db.dump") storage_service="" if [ -f "${CONFIG_DIR}/.env" ]; then storage_service=$(sed -n 's/^ACTIVE_STORAGE_SERVICE=//p' "${CONFIG_DIR}/.env" | tail -n 1 | tr -d '"'"'"' ') fi case "$storage_service" in "" | local) ;; *) warn "uploaded files are stored in '${storage_service}' (ACTIVE_STORAGE_SERVICE), not in this backup" ;; esac if storage_mounted; then log "Archiving file storage..." (umask 077 && tar -C "$STORAGE_DIR" --numeric-owner --exclude=./.sure-runtime -czf "${tmp_dir}/storage.tar.gz" .) else warn "${STORAGE_DIR} is not mounted; uploaded files are NOT included" fi if [ -f "${CONFIG_DIR}/.env" ]; then cp "${CONFIG_DIR}/.env" "${tmp_dir}/config/.env" else warn "no .env in ${CONFIG_DIR}; it is NOT included" fi for f in compose.yml compose.yaml docker-compose.yml docker-compose.yaml; do [ -f "${CONFIG_DIR}/${f}" ] || continue cp "${CONFIG_DIR}/${f}" "${tmp_dir}/config/${f}" compose_found=true done [ "${compose_found:-}" = true ] || warn "no compose file in ${CONFIG_DIR}; it is NOT included" for f in compose.override.yml compose.override.yaml docker-compose.override.yml docker-compose.override.yaml; do [ -f "${CONFIG_DIR}/${f}" ] && cp "${CONFIG_DIR}/${f}" "${tmp_dir}/config/${f}" done find "${tmp_dir}/config" -type f -exec chmod 0600 {} + # This script and an entry point for it, so a bundle restores on its own. mkdir -p "${tmp_dir}/config/bin" cp "$0" "${tmp_dir}/config/bin/sure-backup" chmod 0755 "${tmp_dir}/config/bin/sure-backup" cat > "${tmp_dir}/restore.sh" <<'EOF' #!/bin/sh # Restores this backup into a new folder as a separate Sure instance. # Run it with: sh restore.sh (needs Docker with Compose; --help for options) dir=$(cd "$(dirname "$0")" && pwd) exec sh "$dir/config/bin/sure-backup" recover "$dir" "$@" EOF chmod 0755 "${tmp_dir}/restore.sh" pg_server=$(psql_value "SHOW server_version") if [ -n "$commit" ]; then commit_json=$(json_str "$commit") image_by_commit_json=$(json_str "${IMAGE_REPO}:sha-${commit}") else commit_json=null image_by_commit_json=null fi # One key per line: manifest_value reads it back with sed. { echo "{" echo " \"format\": 1," echo " \"sure_version\": $(json_str "$version")," echo " \"sure_commit\": ${commit_json}," echo " \"image\": $(json_str "${IMAGE_REPO}:${version}")," echo " \"image_by_commit\": ${image_by_commit_json}," echo " \"schema_version\": $(json_str "$db_schema")," echo " \"created_at\": $(json_str "$(date -u +%Y-%m-%dT%H:%M:%SZ)")," echo " \"trigger\": $(json_str "$trigger")," echo " \"instance_id\": $(json_str "$INSTANCE_ID")," echo " \"database\": $(json_str "$POSTGRES_DB")," echo " \"postgres_server_version\": $(json_str "$pg_server")," echo " \"pg_dump_version\": $(json_str "$(pg_dump --version)")" echo "}" } > "${tmp_dir}/manifest.json" (cd "$tmp_dir" && find . -type f | sort | xargs sha256sum > "${WORK_DIR}/SHA256SUMS") mv "${WORK_DIR}/SHA256SUMS" "${tmp_dir}/SHA256SUMS" mkdir -p "${BACKUP_DIR}/${version}" mv "$tmp_dir" "$final_dir" log "Done: ${final_dir} ($(du -sh "$final_dir" | cut -f1))" } # --- list -------------------------------------------------------------------- bundles() { # Newest first; the directory name starts with a sortable UTC timestamp. find "$BACKUP_DIR" -mindepth 3 -maxdepth 3 -name manifest.json ! -path '*/.tmp-*' 2>/dev/null \ | while read -r m; do d=$(dirname "$m") printf '%s\t%s\n' "$(basename "$d")" "$d" done | sort -r | cut -f2 } cmd_list() { found=$(bundles) if [ -z "$found" ]; then log "No backups in ${BACKUP_DIR}" return 0 fi printf '%-40s %-10s %-8s %s\n' "BACKUP" "TRIGGER" "SIZE" "IMAGE" echo "$found" | while read -r d; do printf '%-40s %-10s %-8s %s\n' \ "${d#"${BACKUP_DIR}"/}" \ "$(manifest_value "$d" trigger)" \ "$(du -sh "$d" | cut -f1)" \ "$(manifest_value "$d" image)" done } # --- restore ----------------------------------------------------------------- resolve_bundle() { case "$1" in "") die "restore needs a backup id (see 'list'), a path, or 'latest'" ;; latest) dir=$(bundles | head -n 1) ;; /*) dir=$1 ;; *) dir="${BACKUP_DIR}/$1" ;; esac [ -n "$dir" ] && [ -f "${dir}/manifest.json" ] || die "no backup found at ${dir:-$1}" echo "$dir" } env_secret_hash() { [ -f "$1" ] || return 0 sed -n 's/^SECRET_KEY_BASE=//p' "$1" | tail -n 1 | sha256sum | cut -c1-16 } cmd_restore() { target="" assume_yes=false force=false while [ $# -gt 0 ]; do case "$1" in --yes) assume_yes=true; shift ;; --force) force=true; shift ;; -*) die "unknown option for restore: $1" ;; *) target=$1; shift ;; esac done check_db_env ensure_deps dir=$(resolve_bundle "$target") setup_pgpass log "Verifying checksums..." (cd "$dir" && sha256sum -c SHA256SUMS >/dev/null) || die "checksum verification failed for ${dir}" version=$(manifest_value "$dir" sure_version) image=$(manifest_value "$dir" image) image_by_commit=$(manifest_value "$dir" image_by_commit) # pg_restore can't read dumps made by a newer pg_dump; say so plainly. dump_major=$(manifest_value "$dir" pg_dump_version | sed -n 's/.* \([0-9][0-9]*\)\..*/\1/p') client_major=$(pg_restore --version | sed -n 's/.* \([0-9][0-9]*\)\..*/\1/p') case "$client_major" in "" | *[!0-9]*) die "cannot read the pg_restore version" ;; esac case "$dump_major" in *[!0-9]*) dump_major="" ;; esac if [ -n "$dump_major" ] && [ "$dump_major" -gt "$client_major" ]; then die "this backup was made with pg_dump ${dump_major}, but this pg_restore is ${client_major}. Restore it from the host with 'sh sure-backup restore', which runs in the Sure image, or with PostgreSQL ${dump_major} client tools." fi # Checked up front so a missing mount can't leave a half-restored instance. if [ -f "${dir}/storage.tar.gz" ] && ! storage_mounted; then die "${STORAGE_DIR} is not mounted; cannot restore uploaded files" fi others=$(psql_value "SELECT count(*) FROM pg_stat_activity WHERE datname = '${POSTGRES_DB}' AND pid <> pg_backend_pid()" postgres) if [ "$others" -gt 0 ] && [ "$force" != true ]; then die "${others} other connection(s) to ${POSTGRES_DB}. Stop the app first: docker compose stop web worker (or pass --force)" fi if [ "$assume_yes" != true ]; then if [ -t 0 ]; then printf 'This REPLACES database %s and all uploaded files with %s. Type "restore" to continue: ' "$POSTGRES_DB" "${dir#"${BACKUP_DIR}"/}" read -r answer [ "$answer" = "restore" ] || die "aborted" else die "refusing to restore without a TTY; pass --yes" fi fi log "Recreating database ${POSTGRES_DB}..." dropdb --maintenance-db=postgres --if-exists --force "$POSTGRES_DB" createdb --maintenance-db=postgres -O "$POSTGRES_USER" "$POSTGRES_DB" log "Restoring database..." server_major=$(( $(psql_value "SHOW server_version_num" postgres) / 10000 )) if [ "$client_major" -le "$server_major" ]; then pg_restore --no-owner --role="$POSTGRES_USER" --exit-on-error -d "$POSTGRES_DB" "${dir}/db.dump" else # A newer pg_restore emits settings the older server rejects (e.g. # transaction_timeout, new in 17), so restore through SQL without them. pg_restore --no-owner --role="$POSTGRES_USER" -f "${WORK_DIR}/restore.sql" "${dir}/db.dump" sed '/^SET transaction_timeout = /d' "${WORK_DIR}/restore.sql" \ | psql -X -q -v ON_ERROR_STOP=1 -d "$POSTGRES_DB" >/dev/null rm -f "${WORK_DIR}/restore.sql" fi if [ -f "${dir}/storage.tar.gz" ]; then log "Restoring file storage..." find "$STORAGE_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} + tar -C "$STORAGE_DIR" --numeric-owner -xzpf "${dir}/storage.tar.gz" else warn "backup has no storage.tar.gz; file storage left untouched" fi if [ -f "${dir}/config/.env" ] && [ -f "${CONFIG_DIR}/.env" ] \ && [ "$(env_secret_hash "${dir}/config/.env")" != "$(env_secret_hash "${CONFIG_DIR}/.env")" ]; then warn "SECRET_KEY_BASE in this backup differs from the current .env." warn "Encrypted data will not decrypt until you use the backup's value (see ${dir#"${BACKUP_DIR}"/}/config/.env)." fi if [ "${SURE_RECOVER:-}" = 1 ]; then # Called by `recover`, which pins the image and starts the app itself. log "Restored Sure ${version}." return 0 fi log "Restored Sure ${version}. Next, on the host:" log " 1. Run the app on ${image}${image_by_commit:+ (or ${image_by_commit} for non-release builds)}:" if [ -f "${CONFIG_DIR}/compose.restore.yml" ]; then log " set the web and worker image in compose.restore.yml (it overrides SURE_IMAGE_TAG)" else log " set SURE_IMAGE_TAG=${version} in .env, or the web/worker image in your compose file" fi log " 2. docker compose up -d" } # --- recover (runs on the host) ----------------------------------------------- # ask PROMPT DEFAULT: prints the answer, or DEFAULT on an empty line or EOF. ask() { if [ "$assume_yes" = true ]; then printf '%s\n' "$2" return 0 fi printf '%s%s: ' "$1" "${2:+ [$2]}" >&2 answer="" read -r answer || true printf '%s\n' "${answer:-$2}" } env_set() { awk -v k="$2" -v v="$3" ' $0 ~ "^" k "=" { if (!done) print k "=" v; done = 1; next } { print } END { if (!done) print k "=" v } ' "$1" > "$1.tmp" mv "$1.tmp" "$1" chmod 0600 "$1" } env_get() { sed -n "s/^$2=//p" "$1" 2>/dev/null | tail -n 1 | sed -e 's/[[:space:]]*#.*$//' -e 's/^"\(.*\)"$/\1/' } port_in_use() { docker ps --format '{{.Ports}}' 2>/dev/null | grep -q ":$1->" && return 0 if command -v nc >/dev/null 2>&1 && nc -z 127.0.0.1 "$1" >/dev/null 2>&1; then return 0 fi return 1 } # Compose projects with a running Sure web or worker container. running_sure_projects() { docker ps --format '{{.Label "com.docker.compose.project"}}|{{.Label "com.docker.compose.service"}}|{{.Image}}' 2>/dev/null \ | awk -F'|' '($2 == "web" || $2 == "worker") && $3 ~ /sure/ { print ($1 == "" ? "(not compose)" : $1) }' \ | sort -u } cmd_recover() { bundle="" target="" port="" worker="" assume_yes=false start=true while [ $# -gt 0 ]; do case "$1" in --target) target=$2; shift 2 ;; --port) port=$2; shift 2 ;; --with-worker) worker=yes; shift ;; --without-worker) worker=no; shift ;; --yes) assume_yes=true; shift ;; --no-start) start=false; shift ;; -h | --help) usage; return 0 ;; -*) die "unknown option for recover: $1" ;; *) bundle=$1; shift ;; esac done [ -n "$bundle" ] && [ -f "${bundle}/manifest.json" ] || die "recover needs a backup bundle folder (one with manifest.json)" bundle=$(cd "$bundle" && pwd) if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then die "Docker with the Compose plugin is required (https://docs.docker.com/compose/install/)" fi version=$(manifest_value "$bundle" sure_version) image=$(manifest_value "$bundle" image) created=$(manifest_value "$bundle" created_at) [ -n "$version" ] && [ -n "$image" ] || die "cannot read version and image from ${bundle}/manifest.json" [ -f "${bundle}/config/.env" ] || die "the bundle has no config/.env; restore it by hand (see docs/hosting/docker.md)" compose_file="" for f in compose.yml compose.yaml docker-compose.yml docker-compose.yaml; do [ -f "${bundle}/config/${f}" ] && { compose_file=$f; break; } done [ -n "$compose_file" ] || die "the bundle has no compose file; restore it by hand (see docs/hosting/docker.md)" log "Backup: Sure ${version}, taken ${created:-at an unknown time} (${image})" log "Verifying checksums..." if command -v sha256sum >/dev/null 2>&1; then (cd "$bundle" && sha256sum -c SHA256SUMS >/dev/null) || die "checksum verification failed" elif command -v shasum >/dev/null 2>&1; then (cd "$bundle" && shasum -a 256 -c SHA256SUMS >/dev/null) || die "checksum verification failed" else warn "no sha256sum or shasum; skipping checksum verification here (restore checks it again)" fi # Where to put the restored project. Its folder name becomes the Compose # project name, which names its containers and data volumes. [ -n "$target" ] || target=$(ask "Folder for the restored instance" "$(pwd)/sure-restore-${version}") # A literal "~" typed at the prompt is not expanded by the shell. # shellcheck disable=SC2088 case "$target" in "~") target=$HOME ;; "~/"*) target="${HOME}/${target#"~/"}" ;; esac if [ -e "$target" ] && [ -n "$(ls -A "$target" 2>/dev/null)" ]; then die "${target} is not empty. Pick a new folder; to roll back an existing install, use 'restore' there instead." fi created_target=false [ -d "$target" ] || created_target=true mkdir -p "$target" target=$(cd "$target" && pwd) # On abort, remove the folder only if this run created it. cleanup_target() { [ "$created_target" = true ] && rmdir "$target" 2>/dev/null; true; } project=$(basename "$target" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9_-' | sed 's/^[^a-z0-9]*//') [ -n "$project" ] || project="sure-restore" if [ -n "$(docker ps -aq --filter "label=com.docker.compose.project=${project}")" ] \ || [ -n "$(docker volume ls -q --filter "label=com.docker.compose.project=${project}")" ]; then cleanup_target die "Docker already has containers or volumes for a project named '${project}'. Pick a folder with another name, or this would overwrite that instance's data." fi # Other Sure instances on this machine. others=$(running_sure_projects) if [ -n "$others" ]; then warn "Other Sure instances are running on this machine: $(echo "$others" | tr '\n' ' ')" cat >&2 <<'EOF' If one of them holds the same data as this backup, running both at once means: - Both workers sync the same bank connections. Some providers rotate access tokens on every sync, so the copy can disconnect the original. - Scheduled jobs, such as recurring transactions and emails, run twice. - Signing in to both on the same host name (e.g. localhost on another port) signs you out of the other, because browsers share cookies across ports. Starting the restored instance without its worker avoids the first two. EOF [ -n "$worker" ] || case "$(ask "Start the restored worker too? (no = web only, abort = stop here)" "no")" in y | yes) worker=yes ;; abort | a | q) cleanup_target; die "aborted" ;; *) worker=no ;; esac fi [ -n "$worker" ] || worker=yes backup_port=$(env_get "${bundle}/config/.env" PORT) backup_port=${backup_port:-3000} if [ -z "$port" ]; then port=$backup_port if port_in_use "$port"; then suggestion=$port while port_in_use "$suggestion"; do suggestion=$((suggestion + 1)); done warn "port ${port} is already in use" port=$(ask "Port for the restored instance" "$suggestion") fi fi case "$port" in "" | *[!0-9]*) die "invalid port: ${port}" ;; esac port_in_use "$port" && warn "port ${port} looks busy; the web container may fail to start" log "Restoring into ${target} (project '${project}', port ${port})" cp -R "${bundle}/config/." "${target}/" # Use this copy of the script, which may be newer than the bundle's. mkdir -p "${target}/bin" cp "$0" "${target}/bin/sure-backup" chmod 0755 "${target}/bin/sure-backup" mkdir -p "${target}/backups/${version}" cp -R "$bundle" "${target}/backups/${version}/" name=$(basename "$bundle") compose_files=$compose_file for f in compose.override.yml compose.override.yaml docker-compose.override.yml docker-compose.override.yaml; do [ -f "${target}/${f}" ] && compose_files="${compose_files}:${f}" done compose_files="${compose_files}:compose.restore.yml" cat > "${target}/compose.restore.yml" </dev/null) || die "docker compose cannot read the copied configuration in ${target}" shared=$(printf '%s\n' "$resolved" | awk -v t="$target" ' /^[[:space:]]*external: true/ { print " an external volume or network (external: true)" } /^[[:space:]]*source: \// { if (index($2, t) != 1) print " host path " $2 } ' | sort -u) if [ -n "$shared" ]; then warn "the copied compose configuration uses storage outside ${target}:" printf '%s\n' "$shared" >&2 die "restoring here could overwrite another instance's data. Make these local to ${target} in its compose files, then run there: docker compose up -d db redis && ${restore_cmd}" fi # Local files the compose files mount (e.g. "- ./certs/ca.pem:/...") are not # part of the backup. Docker would create empty directories in their place # and the containers using them would fail to start. missing_mounts="" for f in $(echo "$compose_files" | tr ':' ' '); do # The pattern stops at whitespace, so word splitting is safe here. # shellcheck disable=SC2013 for src in $(sed -n 's/^[[:space:]]*-[[:space:]]*\(\.\/[^:[:space:]]*\):.*/\1/p' "${target}/${f}"); do case "$src" in ./backups | ./backups/* | ./bin/sure-backup) continue ;; esac [ -e "${target}/${src}" ] || missing_mounts="${missing_mounts} ${src}" done done if [ -n "$missing_mounts" ]; then warn "the compose files mount local paths that are not in the backup:${missing_mounts}" warn "copy them into ${target} from the original install before starting." start=false fi if [ "$start" != true ]; then log "Prepared ${target}. Start it with:" services="web worker"; [ "$worker" = yes ] || services="web" log " cd ${target}" log " export COMPOSE_PROJECT_NAME=${project} COMPOSE_FILE=${compose_files}" log " docker compose up -d db redis && ${restore_cmd} && docker compose up -d ${services}" return 0 fi log "Starting the database..." docker compose up -d db redis log "Restoring the backup..." docker compose run --rm -e SURE_RECOVER=1 sure-backup restore "${version}/${name}" --yes if [ "$worker" = yes ]; then log "Starting web and worker..." docker compose up -d web worker else log "Starting web only (no worker)..." docker compose up -d web fi url="http://localhost:${port}" if command -v curl >/dev/null 2>&1; then log "Waiting for ${url} ..." tries=0 until curl -fsS -o /dev/null "${url}/up" 2>/dev/null; do tries=$((tries + 1)) [ "$tries" -lt 60 ] || { warn "the app did not answer within 3 minutes; check: docker compose logs web"; break; } sleep 3 done fi log "" log "Restored Sure ${version} is running at ${url}" log " Folder: ${target}" log " Manage: cd ${target} && docker compose ps | logs web | down" [ "$worker" = yes ] || log " Worker: not started; start it with 'docker compose up -d worker' once the original instance is stopped" log " Remove: cd ${target} && docker compose down -v (deletes this copy's data)" } # --- prune / offsite ---------------------------------------------------------- cmd_prune() { check_keep_days minutes=$((BACKUP_KEEP_DAYS * 1440)) log "Pruning scheduled backups older than ${BACKUP_KEEP_DAYS} days (manual backups are kept)..." find "$BACKUP_DIR" -mindepth 2 -maxdepth 2 -type d -name '*-scheduled' -mmin "+${minutes}" ! -path '*/.tmp-*' \ | while read -r d; do log " removing ${d#"${BACKUP_DIR}"/}" rm -rf "$d" done # Version directories left empty by pruning. find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d ! -name '.tmp-*' -empty -exec rmdir {} + 2>/dev/null || true # Interrupted runs. find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '.tmp-*' -mmin +1440 -exec rm -rf {} + 2>/dev/null || true } cmd_scheduled() { check_keep_days cmd_create --scheduled cmd_prune } # Seconds until the next daily run at H:M (UTC). seconds_until() { now=$(date -u +%s) next=$(date -u -d "$(date -u +%Y-%m-%d) $1:$2:00" +%s) [ "$next" -gt "$now" ] || next=$((next + 86400)) echo $((next - now)) } cmd_daemon() { check_db_env check_keep_days ensure_deps # Only daily schedules: "M H * * *" with a numeric minute and hour. set -f # shellcheck disable=SC2086 set -- $BACKUP_SCHEDULE set +f minute=${1:-}; hour=${2:-} if [ $# -ne 5 ] || [ "$3" != "*" ] || [ "$4" != "*" ] || [ "$5" != "*" ] \ || ! [ "$minute" -ge 0 ] 2>/dev/null || [ "$minute" -gt 59 ] \ || ! [ "$hour" -ge 0 ] 2>/dev/null || [ "$hour" -gt 23 ]; then die "BACKUP_SCHEDULE must be a daily time like \"0 2 * * *\" (minute hour * * *, UTC); got \"${BACKUP_SCHEDULE}\"" fi minute=$((minute + 0)); hour=$((hour + 0)) hhmm=$(printf '%02d:%02d' "$hour" "$minute") if [ -n "${BACKUP_DESTINATION:-}" ]; then warn "BACKUP_DESTINATION is ignored here; off-site copies are made by the backup-offsite service" fi # Exit promptly on docker stop; the sleep runs in the background so the trap fires. trap 'exit 0' TERM INT log "Scheduled backups every day at ${hhmm} UTC into ${BACKUP_DIR}" while true; do wait_s=$(seconds_until "$hour" "$minute") log "Next backup in $((wait_s / 3600))h $((wait_s % 3600 / 60))m" sleep "$wait_s" & wait $! ( cmd_scheduled ) || warn "scheduled backup failed; retrying at the next scheduled time" done } # --- host mode ----------------------------------------------------------------- # # Run on the host from the folder with compose.yml, `create`, `restore`, # `prune` and `scheduled` start this script once in a container from the # exact image of the install's `web` container, with its uploaded-files # volume, network and database settings. No backup service needed, so it # also works for Sure 0.7.5 and older. on_host() { [ ! -f /.dockerenv ] && [ ! -f /run/.containerenv ] && command -v docker >/dev/null 2>&1 } host_run() { docker compose version >/dev/null 2>&1 || die "Docker with the Compose plugin is required" cid=$(docker compose ps -aq web 2>/dev/null | head -n 1) [ -n "$cid" ] || die "no Sure 'web' container for the Compose project in $(pwd). Run this from the folder with your compose.yml; the app must have been started at least once." # An idle app holds no database connections, so the in-container check can # miss it; here Docker can tell whether the app is running. if [ "$1" = restore ]; then case " $* " in *" --force "*) ;; *) if [ -n "$(docker compose ps -q --status running web worker 2>/dev/null)" ]; then die "the app is running. Stop it first: docker compose stop web worker (or pass --force)" fi esac fi inspect() { docker inspect -f "$1" "$cid"; } image=$(inspect '{{.Image}}') workdir=$(inspect '{{index .Config.Labels "com.docker.compose.project.working_dir"}}') workdir=${workdir:-$(pwd)} # A Go template for docker inspect, not shell expansion. # shellcheck disable=SC2016 network=$(inspect '{{range $name, $_ := .NetworkSettings.Networks}}{{$name}}{{"\n"}}{{end}}' | sed -n '1p') storage=$(inspect '{{range .Mounts}}{{if eq .Destination "/rails/storage"}}{{if eq .Type "volume"}}{{.Name}}{{else}}{{.Source}}{{end}}{{end}}{{end}}') container_env=$(inspect '{{range .Config.Env}}{{println .}}{{end}}') env_of() { printf '%s\n' "$container_env" | sed -n "s/^$1=//p" | tail -n 1; } backups=$(env_get "${workdir}/.env" BACKUP_DIR) backups=${backups:-./backups} case "$backups" in /*) ;; *) backups="${workdir}/${backups#./}" ;; esac mkdir -p "$backups" script_path=$(cd "$(dirname "$0")" && pwd)/$(basename "$0") # Database credentials go through a private env file, not the command line. host_tmp=$(umask 077 && mktemp -d) trap 'rm -rf "${host_tmp}"' EXIT INT TERM { echo "POSTGRES_HOST=$(env_of DB_HOST)" echo "POSTGRES_PORT=$(env_of DB_PORT)" echo "POSTGRES_USER=$(env_of POSTGRES_USER)" echo "POSTGRES_PASSWORD=$(env_of POSTGRES_PASSWORD)" echo "POSTGRES_DB=$(env_of POSTGRES_DB)" echo "BUILD_COMMIT_SHA=$(env_of BUILD_COMMIT_SHA)" for v in BACKUP_KEEP_DAYS INSTANCE_ID; do val=$(env_get "${workdir}/.env" "$v") [ -z "$val" ] || echo "${v}=${val}" done } > "${host_tmp}/env" # Build `docker run [options] IMAGE SUBCOMMAND ARGS` by prepending to "$@". set -- "$image" "$@" set -- --entrypoint /usr/local/bin/sure-backup "$@" set -- -v "${script_path}:/usr/local/bin/sure-backup:ro" "$@" set -- -v "${workdir}:/config:ro" -v "${backups}:/backups" "$@" if [ -n "$storage" ]; then set -- -v "${storage}:/rails/storage" "$@" else warn "the web container has no /rails/storage mount; uploaded files are not included" fi [ -n "$network" ] && set -- --network "$network" "$@" [ -t 0 ] && set -- -t "$@" set -- --rm -i --user 0 --env-file "${host_tmp}/env" "$@" host_status=0 docker run "$@" || host_status=$? rm -rf "$host_tmp" return "$host_status" } command=${1:-daemon} [ $# -gt 0 ] && shift # On the host, run the database work in a container (see host_run). if on_host; then case "$command" in create | restore | prune | scheduled) host_run "$command" "$@" exit $? ;; list) dir=$(env_get .env BACKUP_DIR) BACKUP_DIR=${dir:-./backups} ;; esac fi case "$command" in daemon) cmd_daemon ;; create) cmd_create "$@" ;; scheduled) cmd_scheduled ;; list) cmd_list ;; restore) cmd_restore "$@" ;; recover) cmd_recover "$@" ;; prune) ensure_deps; cmd_prune ;; -h | --help | help) usage ;; *) usage; exit 1 ;; esac