1049 lines
48 KiB
Bash
Executable File
1049 lines
48 KiB
Bash
Executable File
#!/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 `<name>.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" <newest file>
|
|
|
|
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:-$HOME/backups/queue-north-website}"
|
|
|
|
# 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:-queuenorth-leads}"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 — SQLite, inside the running container on the deploy host.
|
|
#
|
|
# The template ships this block as PostgreSQL and says to replace this and
|
|
# nothing else. This is that replacement.
|
|
#
|
|
# ## Where the database actually is
|
|
#
|
|
# Not on this machine. `/app/db/queuenorth.db` lives in the Docker named volume
|
|
# `qn-website-dev_queuenorth-db` on nebula, and the only copy of every lead and
|
|
# support request the site has ever taken is in it.
|
|
#
|
|
# ## Why `.backup()` and not `cp`
|
|
#
|
|
# SQLite is a file, which makes copying it look trivial and makes doing it
|
|
# wrong silent. A plain `cp` of a live database can capture a torn page mid
|
|
# write, and the result opens, reads, and is corrupt in a way nothing announces
|
|
# until the row you need is the missing one. The online backup API takes a
|
|
# consistent snapshot of a database that is being written to, which is exactly
|
|
# the situation here — the site is live and took writes today.
|
|
#
|
|
# There is no `sqlite3` binary in the container (node:20-alpine), so the
|
|
# snapshot is taken by the `better-sqlite3` the application itself already
|
|
# depends on. Its `.backup()` is the online API. `sqlite3` IS present on nebula
|
|
# and on this machine, and that is what verifies the result afterwards.
|
|
#
|
|
# ## The rule this block must not break
|
|
#
|
|
# The script's contract is that nothing is renamed into place until it has been
|
|
# read back and counted. That is kept: `engine_dump` writes a temporary name,
|
|
# `engine_verify` runs `PRAGMA integrity_check` and lists the tables, and
|
|
# `engine_summarise` counts them. A snapshot that cannot be read is refused,
|
|
# and so is one with no tables in it.
|
|
# ===========================================================================
|
|
|
|
DUMP_SUFFIX=".sqlite"
|
|
|
|
# Overridable so this can be pointed at a staging container, or at a local file
|
|
# for a rehearsal, without editing the script.
|
|
BACKUP_SSH_HOST="${BACKUP_SSH_HOST:-nebula}"
|
|
BACKUP_CONTAINER="${BACKUP_CONTAINER:-qn-website-dev}"
|
|
BACKUP_DB_PATH="${BACKUP_DB_PATH:-/app/db/queuenorth.db}"
|
|
|
|
# `docker` is needed on the REMOTE host, not here, so it cannot be checked by
|
|
# the tool test — which only looks at this machine's PATH. ssh and sqlite3 can
|
|
# be, and are: sqlite3 is what verifies, and its absence would otherwise arrive
|
|
# as "the snapshot holds 0 tables", a sentence about the database that would
|
|
# send somebody looking at entirely the wrong thing.
|
|
engine_tools() { printf 'ssh sqlite3'; }
|
|
|
|
engine_dump() {
|
|
local out="$1"
|
|
|
|
# Written to a temporary path INSIDE the container first, then copied out.
|
|
# `docker cp` of a live database file would have exactly the torn-page
|
|
# problem the online API exists to avoid, so the order matters: snapshot
|
|
# first, copy the snapshot second.
|
|
local remote_tmp="/tmp/qn-backup-$$.sqlite"
|
|
|
|
# The node one-liner is passed through ssh and docker exec, so it is
|
|
# single-quoted here and double-quoted there. It opens the live database
|
|
# read-only and asks SQLite itself to produce the snapshot.
|
|
if ! ssh -o BatchMode=yes "$BACKUP_SSH_HOST" \
|
|
"docker exec '$BACKUP_CONTAINER' node -e \"
|
|
const Database = require('better-sqlite3');
|
|
const db = new Database('$BACKUP_DB_PATH', { readonly: true });
|
|
db.backup('$remote_tmp')
|
|
.then(() => { db.close(); process.exit(0); })
|
|
.catch(e => { console.error(e.message); process.exit(1); });
|
|
\"" >&2; then
|
|
ssh -o BatchMode=yes "$BACKUP_SSH_HOST" "docker exec '$BACKUP_CONTAINER' rm -f '$remote_tmp'" >/dev/null 2>&1
|
|
return 1
|
|
fi
|
|
|
|
# Out of the container, onto the host, then down to here. Two hops because
|
|
# `docker cp` cannot write to a remote path and `scp` cannot read from inside
|
|
# a container.
|
|
local host_tmp="/tmp/qn-backup-$$.sqlite"
|
|
if ! ssh -o BatchMode=yes "$BACKUP_SSH_HOST" \
|
|
"docker cp '$BACKUP_CONTAINER:$remote_tmp' '$host_tmp'" >&2; then
|
|
ssh -o BatchMode=yes "$BACKUP_SSH_HOST" "docker exec '$BACKUP_CONTAINER' rm -f '$remote_tmp'" >/dev/null 2>&1
|
|
return 1
|
|
fi
|
|
|
|
# `cat` over ssh rather than scp: the caller passes an exact output path and
|
|
# this keeps the writer and the exit status in one pipeline we control. The
|
|
# redirect is on this side, so a failed transfer leaves a short file — which
|
|
# is precisely what engine_verify below is for.
|
|
if ! ssh -o BatchMode=yes "$BACKUP_SSH_HOST" "cat '$host_tmp'" >"$out"; then
|
|
ssh -o BatchMode=yes "$BACKUP_SSH_HOST" \
|
|
"rm -f '$host_tmp'; docker exec '$BACKUP_CONTAINER' rm -f '$remote_tmp'" >/dev/null 2>&1
|
|
return 1
|
|
fi
|
|
|
|
# Both temporaries, on both sides, whatever happened above. A snapshot of the
|
|
# whole lead table left in /tmp on a shared host is the kind of tidy-up that
|
|
# only looks optional.
|
|
ssh -o BatchMode=yes "$BACKUP_SSH_HOST" \
|
|
"rm -f '$host_tmp'; docker exec '$BACKUP_CONTAINER' rm -f '$remote_tmp'" >/dev/null 2>&1
|
|
|
|
return 0
|
|
}
|
|
|
|
# Reads the snapshot back and writes what it found to $2; anything sqlite3
|
|
# complains about goes to $3, kept separate so it cannot be counted as content.
|
|
#
|
|
# integrity_check FIRST, and its result is what decides. A truncated SQLite
|
|
# file will happily answer some queries — the header and the first pages are
|
|
# intact — so "the table list came back" is not evidence. `integrity_check`
|
|
# walks every page and says `ok` or says why not.
|
|
engine_verify() {
|
|
local dump="$1" toc="$2" err="$3"
|
|
local integrity
|
|
|
|
integrity=$(sqlite3 "file:$dump?mode=ro" 'PRAGMA integrity_check;' 2>"$err") || return 1
|
|
if [ "$integrity" != "ok" ]; then
|
|
printf 'integrity_check did not return ok:\n%s\n' "$integrity" >>"$err"
|
|
return 1
|
|
fi
|
|
|
|
# One line per table, plus its row count, so the summariser can count tables
|
|
# and a human reading the log can see the lead count move. Internal
|
|
# sqlite_% tables are excluded — they are not this project's data.
|
|
sqlite3 "file:$dump?mode=ro" \
|
|
"SELECT 'TABLE ' || name FROM sqlite_master
|
|
WHERE type='table' AND name NOT LIKE 'sqlite\_%' ESCAPE '\'
|
|
ORDER BY name;" >"$toc" 2>>"$err" || return 1
|
|
|
|
return 0
|
|
}
|
|
|
|
# Prints "<table definitions> <data sections>".
|
|
#
|
|
# SQLite has no schema-only/data-only split, so a table and its data are the
|
|
# same object and both columns are the same number. Printing it twice rather
|
|
# than printing a zero is deliberate: the caller treats a zero in either column
|
|
# as "this dump has no data", which would be false here and would refuse every
|
|
# backup this script ever takes.
|
|
engine_summarise() {
|
|
awk '$1 == "TABLE" { t++ } END { printf "%d %d\n", t + 0, t + 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.
|
|
#
|
|
# ENGINE-SPECIFIC, and sitting outside the ENGINE block above because the
|
|
# template's own copy did. Its header says everything engine-specific lives in
|
|
# that one block; this precondition check is the exception, and it is noted here
|
|
# rather than quietly worked around so the next person changing engines knows
|
|
# there are two places, not one.
|
|
#
|
|
# The Postgres version of this refused to run when neither PGDATABASE nor
|
|
# DATABASE_URL was set, because pg_dump with no target connects to a database
|
|
# named after the current user and produces a valid, empty archive. The SQLite
|
|
# equivalent of that accident is different but not better: an `ssh` to the wrong
|
|
# host, or a container name that no longer exists, and this script's job is to
|
|
# tell those apart from an empty database before it writes anything.
|
|
#
|
|
# So all three coordinates are asserted, and the container is asked whether it
|
|
# is actually running. MIN_TABLES below is the second net; this is the first.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
[ -n "$BACKUP_SSH_HOST" ] || die "BACKUP_SSH_HOST is empty. It names the host the database lives on — nebula, unless you are rehearsing against something else."
|
|
[ -n "$BACKUP_CONTAINER" ] || die "BACKUP_CONTAINER is empty. It names the container holding the database."
|
|
[ -n "$BACKUP_DB_PATH" ] || die "BACKUP_DB_PATH is empty. It is the path to the database file INSIDE the container."
|
|
|
|
# Asked, not assumed. A stopped container answers this differently from a
|
|
# missing one, and both answer differently from a host that will not accept the
|
|
# connection — three problems with three fixes, which a single "backup failed"
|
|
# would flatten into one.
|
|
if ! remote_state=$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$BACKUP_SSH_HOST" \
|
|
"docker inspect -f '{{.State.Status}}' '$BACKUP_CONTAINER' 2>/dev/null" 2>/dev/null); then
|
|
die "could not reach '$BACKUP_SSH_HOST' over ssh, or docker there would not answer.
|
|
Nothing was backed up, and nothing is known about the database — this is
|
|
not 'no changes to back up'."
|
|
fi
|
|
|
|
case "$remote_state" in
|
|
running) ;;
|
|
"") die "no container named '$BACKUP_CONTAINER' on '$BACKUP_SSH_HOST'. Check the name
|
|
before assuming the worst — a renamed container and a deleted one look
|
|
identical from here." ;;
|
|
*) die "container '$BACKUP_CONTAINER' on '$BACKUP_SSH_HOST' is '$remote_state', not running.
|
|
The online backup API needs the process alive. Start it, or take the
|
|
snapshot from the volume directly while nothing is writing to it." ;;
|
|
esac
|
|
|
|
TARGET_DESC="${BACKUP_DB_PATH} in ${BACKUP_CONTAINER} on ${BACKUP_SSH_HOST}"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 <<EOF
|
|
$(list_dumps)
|
|
EOF
|
|
|
|
local total=${#candidates[@]}
|
|
local effective=$total
|
|
|
|
# On a dry run the dump does not exist yet, so counting only what is on disk
|
|
# would understate the total by one and under-report the deletions by one.
|
|
# Counting it in is what makes --dry-run's list the list the real run uses.
|
|
[ -z "$DRY_RUN" ] || effective=$((total + 1))
|
|
|
|
if [ "$effective" -le "$KEEP" ]; then
|
|
say "retention: ${effective} dump(s) in the series, keeping ${KEEP} — nothing to remove."
|
|
return 0
|
|
fi
|
|
|
|
local doomed=$((effective - KEEP))
|
|
local i removed=0 refused=0 failed=0 path
|
|
|
|
for (( i = 0; i < doomed; i++ )); do
|
|
path="${candidates[$i]}"
|
|
|
|
if ! deletable "$path"; then
|
|
# Loud, because a candidate that fails the guard means the directory
|
|
# holds something the glob matched and the guard did not recognise —
|
|
# a symlink, or a name shaped like ours pointing elsewhere.
|
|
say " REFUSED $path — not a plain file directly inside $BACKUP_DIR_REAL."
|
|
refused=$((refused + 1))
|
|
continue
|
|
fi
|
|
|
|
if [ -n "$DRY_RUN" ]; then
|
|
say " would delete $path ($(human_size "$(file_bytes "$path")"))"
|
|
removed=$((removed + 1))
|
|
continue
|
|
fi
|
|
|
|
if rm -f -- "$path" && [ ! -e "$path" ]; then
|
|
say " deleted $path"
|
|
removed=$((removed + 1))
|
|
else
|
|
# Said, not counted as done. A retention pass that quietly failed looks
|
|
# exactly like one that had nothing to do, right up until the disk fills.
|
|
say " FAILED to delete $path — left in place."
|
|
failed=$((failed + 1))
|
|
fi
|
|
done
|
|
|
|
if [ -n "$DRY_RUN" ]; then
|
|
say "retention: would delete ${removed}, keeping the newest ${KEEP}."
|
|
else
|
|
say "retention: deleted ${removed}, keeping the newest ${KEEP}."
|
|
fi
|
|
|
|
[ "$refused" -eq 0 ] || say "retention: ${refused} candidate(s) refused by the delete guard, above."
|
|
[ "$failed" -eq 0 ] || say "retention: ${failed} deletion(s) failed — check permissions on $BACKUP_DIR_REAL."
|
|
}
|
|
|
|
# Reported, never deleted. A .part is the wreckage of a run that failed
|
|
# verification and it is usually the only record of why; retention leaves it
|
|
# alone by construction, so somebody has to be told it is there or it stays
|
|
# forever and is eventually mistaken for a backup.
|
|
report_parts() {
|
|
local -a found=( "${BACKUP_DIR_REAL}/${BACKUP_NAME}-"*"${DUMP_SUFFIX}.part" )
|
|
local -a stale=()
|
|
local p
|
|
|
|
# Filtered before it is counted, so the number printed is the number listed.
|
|
for p in ${found[@]+"${found[@]}"}; do
|
|
[ "$p" = "$PART" ] || stale+=("$p")
|
|
done
|
|
|
|
[ "${#stale[@]}" -gt 0 ] || return 0
|
|
|
|
say "note: ${#stale[@]} unverified .part file(s) from earlier failed runs are in"
|
|
say " $BACKUP_DIR_REAL. They are never deleted by retention. Look at them,"
|
|
say " then remove them by hand:"
|
|
|
|
for p in "${stale[@]}"; do
|
|
say " $p ($(human_size "$(file_bytes "$p")"))"
|
|
done
|
|
}
|
|
|
|
# The newest verified dump before this run, for the size comparison and for
|
|
# telling the operator what they still have if this run fails.
|
|
PREV=""
|
|
PREV_BYTES=0
|
|
|
|
# Kept apart from PREV_BYTES because they answer different questions. "There is
|
|
# no previous dump" and "there is one and its size could not be read" are both
|
|
# reasons not to compare sizes, and collapsing the second into PREV_BYTES=0
|
|
# would make the script go on to say "no previous dump in this series" about a
|
|
# file that is sitting right there — the same substitution of absence for
|
|
# measurement it refuses to make about free space and table counts.
|
|
PREV_MEASURED=""
|
|
|
|
while IFS= read -r line; do
|
|
[ -n "$line" ] && PREV="$line"
|
|
done <<EOF
|
|
$(list_dumps)
|
|
EOF
|
|
|
|
if [ -n "$PREV" ]; then
|
|
PREV_BYTES=$(file_bytes "$PREV")
|
|
case "$PREV_BYTES" in
|
|
''|*[!0-9]*) PREV_BYTES=0 ;;
|
|
*) PREV_MEASURED="yes" ;;
|
|
esac
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Free space, checked against something real or not claimed at all.
|
|
#
|
|
# With no previous dump there is no basis for an estimate, and printing a
|
|
# reassuring number derived from nothing is worse than printing none.
|
|
# ---------------------------------------------------------------------------
|
|
free_kb=$(df -Pk "$BACKUP_DIR_REAL" 2>/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: sqlite3 ${PART} 'PRAGMA integrity_check' + a table list"
|
|
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 snapshot failed (the error from ssh/docker/better-sqlite3 is above)."
|
|
say "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 "the snapshot 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 <<EOF
|
|
$SUMMARY
|
|
EOF
|
|
|
|
TABLES="${TABLES:-}"
|
|
DATA_SECTIONS="${DATA_SECTIONS:-}"
|
|
|
|
# engine_summarise is the one thing in this script an engine swap is expected to
|
|
# replace, so its result is checked and not trusted. Two whole numbers or this
|
|
# run does not continue.
|
|
#
|
|
# Defaulting the empties to 0 instead — which is what this used to do — turns
|
|
# every way the count can fail to happen into the sentence "the archive holds 0
|
|
# tables", which is a claim about the database. A missing awk produces it. So
|
|
# does a replacement summariser that prints a warning, or one number, or an
|
|
# error. And a NON-numeric result is worse than misleading: `[ "$TABLES" -gt 0 ]`
|
|
# exits 2 with "integer expression expected", `if` treats that as false, the
|
|
# MIN_TABLES gate below does the same, and the run walks straight past its only
|
|
# real check into the rename — announcing a dump nobody counted as verified, and
|
|
# then deleting older dumps that were.
|
|
SUMMARY_BAD=""
|
|
case "$TABLES" in ''|*[!0-9]*) SUMMARY_BAD="yes" ;; esac
|
|
case "$DATA_SECTIONS" in ''|*[!0-9]*) SUMMARY_BAD="yes" ;; esac
|
|
|
|
if [ -n "$SUMMARY_BAD" ]; then
|
|
say "VERIFICATION INCONCLUSIVE — the archive was read back successfully, but"
|
|
say " counting what is in it did not produce two whole numbers. How much"
|
|
say " this dump contains is therefore UNKNOWN."
|
|
say ""
|
|
say " Unknown is not zero and this is not a statement about the database."
|
|
say " Look at engine_summarise, and at whether awk is on PATH — under cron"
|
|
say " PATH is not the PATH your shell has."
|
|
say " The summariser returned: '${SUMMARY%%$'\n'*}'"
|
|
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
|
|
|
|
# Which number is the real one depends on what kind of dump this is, and saying
|
|
# "0 tables" about a data-only archive that contains every row would be exactly
|
|
# the sort of unmeasured claim this script is built to avoid.
|
|
if [ "$TABLES" -gt 0 ]; then
|
|
COUNT="$TABLES"
|
|
COUNT_KIND="tables"
|
|
else
|
|
COUNT="$DATA_SECTIONS"
|
|
COUNT_KIND="table data sections (the archive has no table definitions — a data-only dump)"
|
|
fi
|
|
|
|
if [ "$COUNT" -lt "$MIN_TABLES" ]; then
|
|
say "VERIFICATION FAILED — the archive is readable but holds ${COUNT} table(s),"
|
|
say " fewer than BACKUP_MIN_TABLES=${MIN_TABLES}."
|
|
say " This is what a dump of the wrong or an empty database looks like:"
|
|
say " valid, small, and worthless. The connection was ${TARGET_DESC} —"
|
|
say " check which database that actually reaches."
|
|
say ""
|
|
say " It has NOT been renamed into place and NOTHING was deleted."
|
|
say " Kept for inspection: $PART"
|
|
exit 1
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rename. Within one directory, so it is rename(2): the final name appears
|
|
# whole or not at all, and no reader can ever see a partial dump under it.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
mv -- "$PART" "$FINAL" || die "could not rename $PART to $FINAL. The verified dump is still at
|
|
$PART — move it into place by hand."
|
|
|
|
[ -f "$FINAL" ] || die "mv reported success but $FINAL is not there."
|
|
|
|
say "wrote ${FINAL}"
|
|
say " $(human_size "$BYTES"), ${COUNT} ${COUNT_KIND}, verified readable."
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Compare against the previous dump. A warning, never a refusal — see SHRINK_PCT.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Three ways there is no comparison to make, said as three different things.
|
|
# Rule of the house: absence is reported as absence, and "I could not measure
|
|
# the file I was comparing against" is not "there was no file".
|
|
if [ -z "$PREV" ]; then
|
|
say "note: no previous dump in this series — size not compared."
|
|
elif [ -z "$PREV_MEASURED" ]; then
|
|
say "note: the previous dump could not be measured, so this dump's size was"
|
|
say " NOT compared against it. Not compared is not the same as unchanged."
|
|
say " Previous: $PREV"
|
|
elif [ "$PREV_BYTES" -eq 0 ]; then
|
|
say "note: the previous dump is zero bytes ($PREV), so there is no ratio to"
|
|
say " compare against. Whatever went wrong went wrong before this run."
|
|
else
|
|
pct=$((BYTES * 100 / PREV_BYTES))
|
|
|
|
if [ "$pct" -lt "$SHRINK_PCT" ]; then
|
|
say "WARNING: this dump is ${pct}% of the previous one"
|
|
say " ($(human_size "$BYTES") against $(human_size "$PREV_BYTES"))."
|
|
say " A dump that shrinks like that is usually a lost permission on"
|
|
say " some schema, a partial dump, or the wrong database. The"
|
|
say " previous file has been kept; compare the two table lists with"
|
|
say " sqlite3 'PRAGMA integrity_check' before relying on this one."
|
|
fi
|
|
fi
|
|
|
|
prune
|
|
report_parts
|
|
|
|
say "note: this wrote a verified dump to THIS machine. It did not copy it"
|
|
say " anywhere else, and a backup that only exists beside the database it"
|
|
say " came from does not survive the disk, the host or the account."
|
|
say " Restoring it has still never been tried; see the header."
|