#!/usr/bin/env bash # # Write one database dump, prove the file is readable before trusting it, and # keep the newest few. # # bash scripts/backup.sh # write one verified dump # bash scripts/backup.sh --dry-run # show what it would write and delete # bash scripts/backup.sh --no-prune # write, delete nothing # BACKUP_KEEP=30 bash scripts/backup.sh # keep thirty instead of seven # # 0 3 * * * cd /srv/app && bash scripts/backup.sh # from cron, see below # # =========================================================================== # TEMPLATE COPY — configure this before the first run # =========================================================================== # # Copy to `scripts/backup.sh`, add `"backup": "bash scripts/backup.sh"` to # package.json if this is a Node project, and set the two values in the # CONFIGURATION block below. The script refuses to run until they are set: it # has no defaults, deliberately. # # A BACKUP_DIR or BACKUP_NAME inherited from another project is not a cosmetic # mistake. BACKUP_NAME is the filename prefix, and the filename prefix is what # the retention rule globs on — so the wrong name writes this project's rows # into that project's series and then deletes that project's oldest dumps to # make room for them. Both halves are silent, the second is irreversible, and # the directory afterwards looks exactly like a healthy backup series. Hence: # no defaults, and a loud failure instead. # # Assumes: bash, coreutils, `awk`, `sed`, and the client tools for the engine # (`pg_dump` and `pg_restore` for the default). No jq, no Node, nothing # language-specific — unlike the release script beside it, this does not care # what the project is written in. # # awk is named because it is not coreutils, a minimal container image can be # without it, and it is what counts the tables — so it is checked at startup # alongside the engine's tools. Left unchecked, its absence arrives as "the # archive holds 0 tables", which is a sentence about the database and would send # somebody looking at the wrong thing entirely. # # ## Why this exists # # A backup nobody has restored is a hypothesis, not a backup. # # The failure this is built around is not "the backup did not run". That one is # loud: the directory is empty and somebody notices. The failure is a job that # runs every night for a year and writes a file every night for a year, and the # file is truncated, or is a dump of the wrong database, or is 400 bytes of # `pg_dump: error:` because the password expired in March. Nothing about the # directory listing distinguishes that from a working backup. The size column # is plausible. The timestamps march forward. It is discovered on the one day # it matters, by somebody who has already lost the database. # # So this script does not write a file and call it a backup. It writes the file # under a temporary name, reads the file back with `pg_restore --list`, counts # what is in it, refuses the whole run if the archive cannot be read, holds no # tables, or CANNOT BE COUNTED, and only then renames it into place. # # The third of those is not padding. The counting step is one `awk` away from # returning nothing, and a count that did not happen is not a count of zero: if # the result were trusted unchecked, `[ "$TABLES" -gt 0 ]` would fail with # "integer expression expected", `if` would read that failure as false, and an # archive nobody had counted would be renamed into place and announced as # verified — with retention then deleting real dumps to make room for it. # # ## What --list proves, and what it does not # # It proves the file is a real archive: the header parses, the table of # contents is intact and complete, and the dump contains the tables you can see # named in the output. Combined with `pg_dump` exiting zero, that eliminates # the truncated file, the zero-byte file, the error message written where a # dump should be, and the dump taken against an empty or wrong database. # # It does not decompress and check every data block, and it is not a restore. # An archive can list cleanly and still fail to load — a broken large object, a # circular constraint order, an extension that is not installed on the machine # you are restoring onto. Nothing short of an actual restore finds those, which # is why "the other half" below is not optional. # # ## Atomic, because the failure mode is a good backup destroyed by a bad one # # Every write goes to `.part` and is renamed only after it verifies. The # rename is within one directory, so it is rename(2) and not a copy: the final # name never exists holding half a file, and a reader — a sync job, a human, an # offsite copy — cannot pick up a dump that is still being written. # # Writing directly to the final path would mean a dump that dies at 90% has # already overwritten last night's, which was fine. That is worse than not # running at all: it converts a working backup into a broken one and reports # success while doing it. # # A dump that fails verification is KEPT, under its `.part` name, and the path # is printed. It is evidence — usually the error message is inside it — and # deleting evidence to keep the directory tidy is how the cause stays unknown. # # ## Retention deletes by explicit path, inside one directory, and nowhere else # # There is no `find -delete`, no `rm` with a glob, and no recursion. The # candidate list is a glob of the exact filename shape this script writes, and # every path is then checked one at a time: its parent directory, resolved with # `pwd -P` rather than compared as text, must BE the backup directory; it must # be a regular file and not a symlink; its name must match the pattern. Four # checks, all of which have to pass, for each file, immediately before the `rm`. # # This is deliberately more than is needed for paths the script generated # itself. The point is that no configuration value, no symlink planted in the # directory, and no future edit to the glob can produce a deletion outside the # backup directory — the guard does not trust the list it was handed. # # ## Credentials # # Connection details come from the environment and are never written here. # PGHOST/PGPORT/PGUSER/PGDATABASE with a ~/.pgpass is the preferred form; # DATABASE_URL works and is warned about, because a URL carries the password in # it and reaches pg_dump as an argument, where `ps` shows it to every user on # the host. # # Nothing prints a password or a connection URL — not to stdout, not to stderr, # and above all not into a filename, where it would sit in the directory # listing forever and be copied offsite with the dumps. Credentials in logs is # the classic leak in backup scripts and it is usually introduced by an # innocent-looking `say "dumping $DATABASE_URL"`. Do not add one. # # ## Changing the engine # # PostgreSQL is the supported default. Everything engine-specific is in the # block marked ENGINE below — four functions and two variables — and nothing # outside that block knows what a database is. Swapping in MySQL, SQLite or # anything else is an edit to that block alone. # # One constraint carries over: `engine_verify` must READ THE FILE THAT WAS JUST # WRITTEN. Re-querying the database, checking an exit code again, or trusting # the file size verifies nothing about the artefact. Engines whose dump is # plain SQL have no `--list` equivalent; for those the honest verification is a # restore into a scratch database, and if that is too expensive to do on every # run then the backup is unverified and the header of this script should be # edited to stop claiming otherwise. # # ## What it deliberately does not do # # It does not copy the dump anywhere. A backup on the same disk as the database # survives `DROP TABLE` and nothing else — not the disk, not the host, not the # provider account. Getting these files onto different hardware is a separate, # deliberate act (rsync, restic, object storage) for the same reason the # release script does not deploy: two decisions, made one at a time. # # It does not schedule itself. BACKUP_KEEP is a count of files, not a period — # seven of these is a week of nightly runs or seven hours of hourly ones. # # It does not restore. See below. # # ## The other half: a restore you have actually performed # # This script verifies the artefact. Only a restore verifies the backup, and a # restore is also the only way to learn the number that matters during an # incident, which is how long it takes. # # Do it on a schedule you write down — quarterly is a reasonable floor — into a # scratch database, from the newest file this script produced, and record the # duration and the command. Until that has happened once, the honest status of # this directory is "dumps that appear to be readable", and the first restore # will be attempted by somebody who has already lost the database. # # pg_restore --clean --if-exists --no-owner -d "$SCRATCH_URL" set -uo pipefail # Glob expansion and `sort` follow the collation locale, and a cron job's # locale is not the one your shell has. C collation makes the ordering of the # candidate list byte-order and therefore the same everywhere — the ordering # decides which files retention calls "oldest", so it deciding differently # under cron than under test would be discovered by deleting the wrong ones. # LC_COLLATE only: LC_ALL=C would also change LC_CTYPE, and character handling # is not something a backup script should be quietly redefining. unset LC_ALL export LC_COLLATE=C # An unmatched glob otherwise expands to the pattern itself, so an empty backup # directory would produce one "file" literally named `myapp-[0-9][0-9]…` and # hand it to the retention loop as a real candidate. shopt -s nullglob say() { printf '\033[1mbackup:\033[0m %s\n' "$*" >&2; } die() { printf '\033[1mbackup:\033[0m %s\n' "$*" >&2; exit 1; } # --------------------------------------------------------------------------- # CONFIGURATION — set these two, then delete this banner. # # Both are empty on purpose. See the note at the top: an inherited BACKUP_NAME # points the retention rule at another project's dumps, and an inherited # BACKUP_DIR puts this project's rows where that project's offsite copy will # collect them. # --------------------------------------------------------------------------- # Absolute path to the directory holding the dumps. Must already exist, must be # outside the repository, and should be readable only by the user running this # — a dump is every row in the database in one file. BACKUP_DIR="${BACKUP_DIR:-}" # Filename prefix identifying this project's series, e.g. `acme-orders`. # Letters, digits, dot, underscore and hyphen only. This is what retention # globs on; see the header for why it is not derived from the database name. BACKUP_NAME="${BACKUP_NAME:-}" # --------------------------------------------------------------------------- # Tunables. These have defaults because none of them names anything belonging # to a particular project. # --------------------------------------------------------------------------- # How many dumps survive, newest first. Seven is a week of nightly runs. # # Two is the floor enforced below. Keeping one means every run destroys the # only copy you have in order to make a copy that has not yet been shown to be # worth anything — and "worth something" here is more than readable, since a # dump of an empty or wrong database verifies perfectly. Two means the newest # dump can turn out to be worthless without that being the end of it. KEEP="${BACKUP_KEEP:-7}" # Refuse a dump containing fewer tables than this. One is the default because # an archive with no tables in it is not a backup of anything, and the way you # get one is `pg_dump` connecting somewhere you did not intend — see the # PGDATABASE note below, where the accident produces a valid, tiny, entirely # empty archive. Set 0 only if this project's database genuinely has no tables, # in which case there is nothing here to protect. MIN_TABLES="${BACKUP_MIN_TABLES:-1}" # Warn when the new dump is smaller than this percentage of the previous one. # A dump that halves overnight is usually a partial dump, a lost permission on # some schema, or the wrong database — none of which fail loudly on their own. # # A warning and never a refusal: a legitimate large delete would otherwise # block every backup from that moment on, which is a way of losing data by # being careful about data. SHRINK_PCT="${BACKUP_SHRINK_PCT:-50}" # =========================================================================== # ENGINE — PostgreSQL. To use another engine, replace this block and nothing # else. See "Changing the engine" in the header for the one rule that binds. # =========================================================================== DUMP_SUFFIX=".dump" # The custom format is not a preference. `pg_restore --list` only reads custom, # directory and tar archives; a plain SQL dump (`-Fp`, the pg_dump default) # cannot be verified without restoring it, and this whole script is built on # being able to verify. It also compresses by default, so nothing is piped # through gzip — a pipeline would put a second program's exit status where # pg_dump's needs to be. engine_tools() { printf 'pg_dump pg_restore'; } engine_dump() { local out="$1" # --file rather than a `>` redirect: with a redirect the SHELL creates the # file, so a pg_dump that never starts (missing binary, bad PATH under cron) # still leaves a zero-byte file behind, and the writer and the exit status # stop being the same process. # # -w never prompts for a password. Without it, a run whose credentials have # gone stale blocks on a terminal that cron does not have, and the backup # does not fail — it hangs, forever, holding the lock, which reads as "no # output, no error" for however long it takes somebody to look. if [ -n "${DATABASE_URL:-}" ]; then pg_dump -w --format=custom --file="$out" -d "$DATABASE_URL" else pg_dump -w --format=custom --file="$out" fi } # Reads the archive back and writes its table of contents to $2. Anything # pg_restore says goes to $3, kept separately so it cannot be counted as # archive content by the summariser below. engine_verify() { pg_restore --list "$1" >"$2" 2>"$3" } # Prints "" from a TOC listing. # # TOC entries look like `216; 1259 16385 TABLE public users postgres`, and the # data sections like `3021; 0 16385 TABLE DATA public users postgres`. Comment # lines start with `;` and are excluded by the shape of field one. Counting # both matters because a --data-only dump has no TABLE entries and a # --schema-only dump has no TABLE DATA entries, and reporting zero for either # without saying which kind of dump this is would be a lie by omission. engine_summarise() { awk '$1 ~ /^[0-9]+;$/ && $4 == "TABLE" { if ($5 == "DATA") d++; else t++ } END { printf "%d %d\n", t + 0, d + 0 }' "$1" } # =========================================================================== # End of ENGINE block. # =========================================================================== DRY_RUN="" NO_PRUNE="" for arg in "$@"; do case "$arg" in --dry-run) DRY_RUN="yes" ;; --no-prune) NO_PRUNE="yes" ;; # No positional arguments exist, so one is a mistyped flag or a path # somebody expected this to accept. Either way, acting on the rest of the # command line as though it were fine is how a backup goes somewhere else. *) die "unknown argument '$arg'. Usage: bash scripts/backup.sh [--dry-run] [--no-prune]" ;; esac done # --------------------------------------------------------------------------- # Refuse to run half-configured. Everything checkable without a side effect, # checked before the first side effect, and named one at a time so the message # says which value is wrong rather than "configuration error". # --------------------------------------------------------------------------- [ -n "$BACKUP_DIR" ] || die "set BACKUP_DIR — the directory to write dumps into. See the CONFIGURATION block." [ -n "$BACKUP_NAME" ] || die "set BACKUP_NAME — the filename prefix for this project's dumps. See the CONFIGURATION block." # A relative path resolves against the working directory, and cron's working # directory is not yours. The dumps would land somewhere else, and retention — # globbing that same relative path — would find that other directory empty, # report "nothing to remove", and let the real series grow until the disk # filled. Both halves look healthy in isolation. case "$BACKUP_DIR" in /*) : ;; *) die "BACKUP_DIR ('$BACKUP_DIR') must be an absolute path — a relative one points somewhere different under cron than it does in your shell." ;; esac # The glob and the delete guard are built from this. A name containing `*`, `?` # or `/` would widen the candidate list past this project's dumps, which is the # one thing the guard cannot make safe by checking paths. case "$BACKUP_NAME" in *[!A-Za-z0-9._-]*) die "BACKUP_NAME ('$BACKUP_NAME') may contain only letters, digits, dot, underscore and hyphen — it is used as a filename glob." ;; esac case "$KEEP" in ''|*[!0-9]*) die "BACKUP_KEEP must be a whole number, got '$KEEP'." ;; esac # Refused rather than clamped: a caller who typed 1 meant something, and it was # not "destroy the only copy before checking the new one is worth having". [ "$KEEP" -ge 2 ] || die "BACKUP_KEEP must be at least 2 — see the note above KEEP in this script." case "$MIN_TABLES" in ''|*[!0-9]*) die "BACKUP_MIN_TABLES must be a whole number, got '$MIN_TABLES'." ;; esac case "$SHRINK_PCT" in ''|*[!0-9]*) die "BACKUP_SHRINK_PCT must be a whole number, got '$SHRINK_PCT'." ;; esac for tool in $(engine_tools); do command -v "$tool" >/dev/null 2>&1 \ || die "$tool is not on PATH. Install the client tools for this engine (Debian/Ubuntu: postgresql-client) and run this again." done # awk is checked separately because it is not part of the engine and not part of # coreutils: it is what turns the table of contents into a number, and cron's # PATH is not your shell's. Without this check its absence surfaces further down # as "the archive holds 0 table(s)" — a measurement, about the database, that # nothing measured. command -v awk >/dev/null 2>&1 \ || die "awk is not on PATH. It is what counts what is inside the archive, and without it this script cannot tell a good dump from an empty one. Under cron, PATH is not the PATH your shell has." [ -d "$BACKUP_DIR" ] || die "BACKUP_DIR ('$BACKUP_DIR') does not exist. Create it deliberately: mkdir -p -m 700 '$BACKUP_DIR' It is not created here on purpose — a typo in the path is otherwise indistinguishable from a first run, and the typo'd directory would fill with a complete, correct-looking series that nobody restores from." # Resolved once, here, and used for every path comparison afterwards. Comparing # the configured string instead would let `/srv/backups/../../etc` pass a # prefix test while being nowhere near the backup directory. BACKUP_DIR_REAL=$(cd "$BACKUP_DIR" && pwd -P) || die "cannot resolve BACKUP_DIR ('$BACKUP_DIR')." # Ahead of the writability check on purpose: for an unprivileged user / is not # writable, so leaving this until later answers "permission denied" to somebody # who is one `sudo` away from asking the same question again and being told yes. [ "$BACKUP_DIR_REAL" != "/" ] || die "BACKUP_DIR resolves to / — retention would treat the root of the filesystem as a directory of expendable files." [ -w "$BACKUP_DIR" ] || die "BACKUP_DIR ('$BACKUP_DIR') is not writable by $(id -un)." # A dump inside the checkout is one `git add -A` from being committed, and one # push from being public, with every row in the database in it. It is also # copied by every clone and every deploy from then on. The engine that made it # does not put it back once that happens. repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || repo_root="" if [ -n "$repo_root" ] && repo_real=$(cd "$repo_root" && pwd -P); then case "$BACKUP_DIR_REAL/" in "$repo_real"/*) die "BACKUP_DIR ('$BACKUP_DIR_REAL') is inside the repository at $repo_real. Put it outside the checkout — a dump in a working tree is one 'git add -A' away from being committed and pushed." ;; esac fi # Permissions are reported, not corrected. Changing the mode of a directory the # operator created — which may be a mount, or shared with a sync agent — is a # decision this script does not get to make on their behalf; failing to mention # that every row in the database is world-readable would be worse. dir_mode=$(ls -ld "$BACKUP_DIR_REAL" 2>/dev/null | cut -c5-10) case "$dir_mode" in ------) : ;; '') say "note: could not read the permissions of $BACKUP_DIR_REAL." ;; *) say "WARNING: $BACKUP_DIR_REAL is accessible to group or other (mode bits" say " '$dir_mode'). A dump is every row in the database in one file." say " chmod 700 it unless something else is meant to read these." ;; esac # --------------------------------------------------------------------------- # Connection. From the environment, never from this file. # --------------------------------------------------------------------------- # With neither of these set, pg_dump does not fail — it connects to a database # named after the current user. On most hosts that database exists, is empty, # and dumps in half a second into a perfectly valid archive. The MIN_TABLES # check below is the second net under this; refusing here is the first. if [ -n "${DATABASE_URL:-}" ]; then # Describes the SOURCE of the connection, never its contents. This string is # printed; the URL must not be. TARGET_DESC="DATABASE_URL (contents not shown)" say "WARNING: using DATABASE_URL. It reaches pg_dump as a command-line" say " argument, so the password in it is visible to anyone who can" say " run 'ps' on this host. PGHOST/PGUSER/PGDATABASE with a" say " ~/.pgpass keeps the password out of the process table." elif [ -n "${PGDATABASE:-}" ]; then # PGDATABASE and PGHOST are not secrets and naming them is what makes the # log worth reading. PGPASSWORD is never touched by anything here. TARGET_DESC="${PGDATABASE}${PGHOST:+ on ${PGHOST}}" else die "no database configured: set PGDATABASE (with PGHOST/PGUSER as needed, and a ~/.pgpass for the password) or DATABASE_URL in the environment. Without one, pg_dump connects to a database named after the current user and produces a valid, empty archive." fi # --------------------------------------------------------------------------- # Names. Built here so the dry run and the real run cannot describe different # files. # # UTC, always: local time repeats an hour every autumn, which puts two dumps # out of order by name in the one direction that matters — retention reads that # order to decide what is oldest. # # Nothing derived from the connection goes into the filename. A URL in a name # sits in the directory listing forever and is copied offsite with the dumps. # --------------------------------------------------------------------------- STAMP_GLOB='[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T[0-9][0-9][0-9][0-9][0-9][0-9]Z' STAMP=$(date -u +%Y%m%dT%H%M%SZ) || die "cannot read the clock." FINAL="${BACKUP_DIR_REAL}/${BACKUP_NAME}-${STAMP}${DUMP_SUFFIX}" PART="${FINAL}.part" # Only a second run inside the same second reaches either of these, and the # alternative in both cases is destroying a file this script promised to keep: # a dump that has already been verified, or the .part evidence of a run that # failed a moment ago and is about to be looked at. [ ! -e "$FINAL" ] || die "$FINAL already exists. Refusing to overwrite a verified dump." [ ! -e "$PART" ] || die "$PART already exists — it is the unverified remains of a run that failed within this same second. Inspect or remove it before running again." human_size() { local b="${1:-}" # An unmeasurable file is reported as unmeasured. Printing "0 B" for a file # whose size could not be read is the same lie as printing "nothing to # delete" for a directory that could not be listed. case "$b" in ''|*[!0-9]*) printf 'unknown size'; return 0 ;; esac # Integer arithmetic rather than `du -h`, whose output format and rounding # differ between GNU and BSD and which reports blocks allocated rather than # bytes written. if [ "$b" -lt 1024 ]; then printf '%s B' "$b" elif [ "$b" -lt 1048576 ]; then printf '%s.%s KiB' "$((b / 1024))" "$((b * 10 / 1024 % 10))" elif [ "$b" -lt 1073741824 ]; then printf '%s.%s MiB' "$((b / 1048576))" "$((b * 10 / 1048576 % 10))" else printf '%s.%s GiB' "$((b / 1073741824))" "$((b * 10 / 1073741824 % 10))" fi } file_bytes() { # `wc -c` is portable where `stat` is not: GNU wants -c%s and BSD wants -f%z. # BSD pads the number with spaces, hence the tr. # # 2>/dev/null comes BEFORE the input redirect on purpose. Redirections are # applied left to right, so with the other order the shell's own "no such # file" message for a failed open is written before stderr has been silenced — # a raw `backup.sh: line N: …` in the middle of otherwise formatted output. # Callers read the empty result, which is what "unmeasurable" means here. wc -c 2>/dev/null <"$1" | tr -d ' \n' } # Every existing dump of this series, oldest first. Pathname expansion sorts, # and LC_COLLATE=C above makes that sort byte-order, so no external sort is # involved and no filename has to survive a round trip through word splitting. # # The glob is the exact shape this script writes — prefix, UTC stamp, suffix. # Files that merely live in the directory are not candidates for anything: a # `.part` from a failed run, a dump somebody copied in by hand, and another # project's series all fail to match and are never counted or deleted. list_dumps() { printf '%s\n' "${BACKUP_DIR_REAL}/${BACKUP_NAME}-"$STAMP_GLOB"$DUMP_SUFFIX" } # --------------------------------------------------------------------------- # The delete guard. # # Called immediately before every `rm`, on the path about to be removed, and # every check has to pass. It exists so that no configuration value, no symlink # planted in the backup directory and no later edit to the glob can produce a # deletion outside BACKUP_DIR_REAL — it does not trust the list it was given. # --------------------------------------------------------------------------- deletable() { local path="$1" parent base case "$path" in */*) parent="${path%/*}"; base="${path##*/}" ;; *) return 1 ;; esac # Resolved, not string-compared: `.` and `..` components make a text prefix # test agree with a path that is somewhere else entirely. parent=$(cd "$parent" 2>/dev/null && pwd -P) || return 1 [ "$parent" = "$BACKUP_DIR_REAL" ] || return 1 # -f follows symlinks, so a link named like a dump would pass every other # check while pointing at a file the checks were never made about. [ -f "$path" ] || return 1 [ ! -L "$path" ] || return 1 case "$base" in "$BACKUP_NAME"-$STAMP_GLOB"$DUMP_SUFFIX") return 0 ;; *) return 1 ;; esac } # --------------------------------------------------------------------------- # Retention. # # Last, and never fatal: by the time this runs the dump has been written and # verified, and housekeeping must not be able to report that a backup which # succeeded failed. Everything it declines to do, it says out loud instead. # --------------------------------------------------------------------------- prune() { [ -z "$NO_PRUNE" ] || { say "retention: skipped (--no-prune)."; return 0; } if [ ! -r "$BACKUP_DIR_REAL" ] || [ ! -x "$BACKUP_DIR_REAL" ]; then say "retention: SKIPPED — cannot list $BACKUP_DIR_REAL." say " That is 'I could not look', which is not the same answer as" say " 'there is nothing to delete'. Nothing was removed." return 0 fi local -a candidates=() local line while IFS= read -r line; do [ -n "$line" ] && candidates+=("$line") done </dev/null | awk 'NR == 2 { print $4 }') case "$free_kb" in ''|*[!0-9]*) say "note: free space on $BACKUP_DIR_REAL could not be read — not checked." ;; *) if [ -z "$PREV_MEASURED" ] || [ "$PREV_BYTES" -eq 0 ]; then say "note: $(human_size $((free_kb * 1024))) free; no measured previous dump to compare" say " against, so whether that is enough is unknown." elif [ $((free_kb * 1024)) -lt $((PREV_BYTES * 2)) ]; then say "WARNING: $(human_size $((free_kb * 1024))) free, and the last dump was" say " $(human_size "$PREV_BYTES"). This run may not fit." fi ;; esac # --------------------------------------------------------------------------- # --dry-run stops here, having touched nothing. # --------------------------------------------------------------------------- if [ -n "$DRY_RUN" ]; then say "--dry-run: nothing was written or deleted. It would have:" say " dumped ${TARGET_DESC}" say " written ${PART}" say " verified it with: pg_restore --list ${PART}" say " renamed it to ${FINAL}" say "" say "and then applied retention, shown here for real because it deletes:" prune report_parts exit 0 fi # --------------------------------------------------------------------------- # One run at a time. # # mkdir is atomic on every filesystem this will meet, which flock and lock # files are not. Two overlapping runs — a nightly job on a database that now # takes longer than a day to dump — double the load on the server and race each # other's retention pass. # # A stale lock stops backups, so this exits NON-ZERO and names the fix rather # than skipping quietly. A skipped backup that reports success is the exact # failure this whole script exists to prevent, and a lock is not allowed to # reintroduce it. # --------------------------------------------------------------------------- LOCK_DIR="${BACKUP_DIR_REAL}/.${BACKUP_NAME}.lock" LOCK_HELD="" # mkdir's error is captured rather than discarded, because "the lock exists" and # "the lock could not be created" are different failures with different fixes, # and only the first one is about concurrency. A read-only mount, a full # filesystem, or a missing mkdir under cron's PATH all make mkdir fail too; the # directory is what distinguishes them, so it is looked at instead of assumed. LOCK_ERR="" if ! LOCK_ERR=$(mkdir "$LOCK_DIR" 2>&1); then if [ -d "$LOCK_DIR" ]; then say "another run holds the lock at $LOCK_DIR" [ ! -r "$LOCK_DIR/owner" ] || say " owner: $(cat "$LOCK_DIR/owner" 2>/dev/null)" die "if no backup is running, that lock is stale: rmdir '$LOCK_DIR'" fi # No lock directory, so nothing is holding anything. Saying otherwise here # would send somebody to rmdir a path that does not exist, watch that change # nothing, and go looking for a second backup process — while the real cause # sits unread in mkdir's own message and every night's backup stays dead. say "could not create the lock directory $LOCK_DIR, and no lock is present —" say " so this is NOT another run. The directory could not be made at all." [ -z "$LOCK_ERR" ] || say " mkdir said: $LOCK_ERR" die "check that $BACKUP_DIR_REAL is on a writable, non-full filesystem." fi LOCK_HELD="yes" WORK="" # Armed in the same breath as the lock, not after the next command. Anything # that exits between acquiring the lock and installing this trap leaves a stale # lock behind, and a stale lock stops every future run — the trap tolerating an # empty WORK is much cheaper than that. $WORK is quoted-empty-safe because rm # is guarded on it. trap '[ -z "$LOCK_HELD" ] || rm -rf "$LOCK_DIR"; [ -z "$WORK" ] || rm -rf "$WORK"' EXIT printf 'pid %s on %s since %s\n' "$$" "$(hostname 2>/dev/null || echo unknown)" \ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$LOCK_DIR/owner" 2>/dev/null # The .part is deliberately NOT cleaned up by that trap. It is evidence when # verification fails, and the failure paths below print where it is. WORK=$(mktemp -d) || die "cannot create a temporary directory" # --------------------------------------------------------------------------- # Dump. # --------------------------------------------------------------------------- say "dumping ${TARGET_DESC} -> ${PART}" # umask in a subshell so the archive is created 0600 from its first byte. Doing # it with a chmod afterwards leaves a window in which every row in the database # is readable by every user on the host, and that window is the whole dump. if ! ( umask 077; engine_dump "$PART" ); then say "the dump failed (pg_dump's own error is above). Nothing was renamed into" say " place and nothing was deleted." if [ -n "$PREV" ]; then say " The newest verified dump is still $PREV" else say " There is no previously verified dump in $BACKUP_DIR_REAL." fi say " The partial file is kept for inspection: $PART" exit 1 fi [ -f "$PART" ] || die "pg_dump reported success but $PART does not exist. Nothing was renamed." BYTES=$(file_bytes "$PART") [ -n "$BYTES" ] || die "cannot measure $PART. It has NOT been renamed into place." # --------------------------------------------------------------------------- # Verify, before the file is allowed to become the backup. # --------------------------------------------------------------------------- say "verifying ${PART}…" LISTING="$WORK/toc" VERR="$WORK/toc.err" if ! engine_verify "$PART" "$LISTING" "$VERR"; then say "VERIFICATION FAILED — the file cannot be read back as an archive." say " $(human_size "$BYTES") written. The reader said:" sed -n '1,10p' "$VERR" >&2 2>/dev/null say "" say " It has NOT been renamed into place and NOTHING was deleted." say " Kept for inspection: $PART" if [ -n "$PREV" ]; then say " The newest verified dump is still $PREV" else say " There is no previously verified dump in $BACKUP_DIR_REAL." fi exit 1 fi SUMMARY=$(engine_summarise "$LISTING") || SUMMARY="" read -r TABLES DATA_SECTIONS <