#!/bin/sh
# Hourly one-way copy of the live database and covers to the failover node,
# so a node that dies between handoffs costs at most an hour, not everything.
# Run by home-library-db-sync.timer on BOTH nodes: it is the ACTIVE node's
# job, and which node that is changes, so it runs everywhere and returns at
# once unless this node holds the activity flag. A handoff therefore moves
# the sync with the database, with nothing to edit.
#
# WHERE IT WRITES, AND WHY NOT ONTO library.db
# The copy lands in /var/lib/home-library/standby/ on the peer, never on the
# peer's own library.db. Overwriting that would drive straight through the
# interlock home-library-failover is built around: it refuses to replace a
# copy that a LATER handoff produced, and an hourly job writing behind its
# back could destroy a standby copy that is legitimately newer, or land in
# the middle of a handoff moving the same file. This is a disaster copy, not
# replication: restoring from it is a deliberate act.
#
# ONE-WAY, ALWAYS
# Two SQLite databases cannot be merged after the fact -- AUTOINCREMENT ids
# collide and deletions leave nothing to replay -- so this only ever pushes
# from the node that owns the database. Nothing here ever pulls.
set -eu

CONF=/etc/default/home-library-failover
# shellcheck source=/dev/null
[ -r "$CONF" ] && . "$CONF"

PEER="${PEER:?set PEER in $CONF}"
PEER_PORT="${PEER_PORT:-65432}"
# A key of its own, NOT the failover key: that one is pinned to the failover
# verbs and has no path to rsync. This one is pinned on the far side to
# `rrsync -wo /var/lib/home-library/standby`, which can only write, and only
# inside that directory.
SYNC_KEY="${SYNC_KEY:-/root/id_homelibrary_sync}"
APP_USER=home-library
DATA_DIR=/var/lib/home-library
DB=$DATA_DIR/library.db
COVERS_DIR=$DATA_DIR/covers
ACTIVE_FLAG=/run/home-library-active
STATE_DIR=$DATA_DIR/.sync
LOCK=/run/home-library-db-sync.lock

log() { logger -t home-library-db-sync -- "$@"; }

# Only the node holding the database has anything authoritative to send.
[ -e "$ACTIVE_FLAG" ] || exit 0
[ -f "$DB" ] || exit 0

exec 9>"$LOCK"
flock -n 9 || { log "another sync is still running -- skipping"; exit 0; }

install -d -m 0700 -o "$APP_USER" -g "$APP_USER" "$STATE_DIR"

# The no-edits case, which is most of them: fingerprint the database AND its
# WAL and stop before doing any work. In WAL mode a commit lands in -wal and
# the main file can sit unchanged for a long time.
fingerprint() {
  { sha256sum "$DB"; sha256sum "$DB-wal" 2>/dev/null || true; } | awk '{ print $1 }' | tr -d '\n'
}
now=$(fingerprint)
if [ "$now" = "$(cat "$STATE_DIR/last-synced" 2>/dev/null || echo none)" ]; then
  exit 0
fi

# ssh exits 255 when IT could not connect, as opposed to a remote command's
# own status, so 255 is the honest signal for "offline". That is the one
# failure kept quiet: a standby being down is a known state, not an incident.
# It still reaches the journal.
set +e
ssh -i "$SYNC_KEY" -p "$PEER_PORT" -o BatchMode=yes -o ConnectTimeout=10 \
    -o StrictHostKeyChecking=accept-new "root@$PEER" true >/dev/null 2>&1
rc=$?
set -e
if [ "$rc" -eq 255 ]; then
  log "peer $PEER unreachable or refusing the sync key -- skipping this run"
  exit 0
fi

# rsync cannot be pointed at a live SQLite database: what arrives is a file
# torn across a commit. Snapshot first, as the app's user, and verify it
# before sending, so a corrupt source never becomes a corrupt disaster copy.
SNAP="$STATE_DIR/library.db"
rm -f "$SNAP"
runuser -u "$APP_USER" -- sqlite3 "$DB" ".backup '$SNAP'"
[ "$(runuser -u "$APP_USER" -- sqlite3 "$SNAP" 'PRAGMA integrity_check;')" = ok ] \
  || { log "ERROR: snapshot of $DB failed integrity_check -- not sending"; exit 1; }

# Destinations are relative: rrsync confines them to the directory named in
# the peer's authorized_keys. Past this point a failure is real and noisy.
RSH="ssh -i $SYNC_KEY -p $PEER_PORT -o BatchMode=yes -o ConnectTimeout=10"
rsync -a --checksum -e "$RSH" "$SNAP" "root@$PEER:library.db"
# Covers travel in the same run, and --delete keeps the copy a copy rather
# than a pile of images no row refers to any more.
if [ -d "$COVERS_DIR" ]; then
  rsync -a --delete -e "$RSH" "$COVERS_DIR/" "root@$PEER:covers/"
fi

printf '%s' "$now" > "$STATE_DIR/last-synced"
log "synced $(stat -c %s "$SNAP") bytes + $(find "$COVERS_DIR" -type f 2>/dev/null | wc -l) cover file(s) to $PEER"
