Files
beets-setup/scripts/publish-library
2026-05-18 15:33:48 -04:00

378 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# publish-library
#
# Publish the authoritative laptop music library to the music server.
#
# This script:
# 1. Stamps beets/tooling provenance into the Library dataset.
# 2. Creates a ZFS snapshot of the Library dataset.
# 3. Replicates Data/Music/Library -> Music/Library with syncoid.
#
# Snapshot name format:
#
# ${SNAP_PREFIX}_${SNAP_TIMESTAMP_FORMAT}
# ${SNAP_PREFIX}_${SNAP_TIMESTAMP_FORMAT}_${message-slug}
#
# Example:
#
# Data/Music/Library@publish_20260518T165012Z
# Data/Music/Library@publish_20260518T165012Z_added-elton-john
#
# Message slugs are sanitized to contain only:
#
# a-z 0-9 . _ -
#
# Everything else becomes "-".
SOURCE_DATASET="${SOURCE_DATASET:-Data/Music/Library}"
SOURCE_MOUNT="${SOURCE_MOUNT:-/data/Music/Library}"
BEETS_ROOT="${BEETS_ROOT:-/data/Music/Beets}"
TARGET_HOST="${TARGET_HOST:-aargonian@NytegearMusic}"
TARGET_DATASET="${TARGET_DATASET:-Music/Library}"
SYNCOID="${SYNCOID:-/usr/sbin/syncoid}"
ZFS="${ZFS:-/usr/sbin/zfs}"
LOCK_FILE="${LOCK_FILE:-/tmp/publish-library.lock}"
SNAP_PREFIX="${SNAP_PREFIX:-publish}"
SNAP_TIMESTAMP_FORMAT="${SNAP_TIMESTAMP_FORMAT:-+%Y%m%dT%H%M%SZ}"
SNAP_MESSAGE_MAX_LENGTH="${SNAP_MESSAGE_MAX_LENGTH:-80}"
SNAP_MESSAGE=""
SNAP_MESSAGE_SLUG=""
SNAP_NAME=""
DRY_RUN=0
SKIP_SNAPSHOT=0
SKIP_PROVENANCE=0
ALLOW_NON_LAPTOP=0
RECURSIVE=1
usage() {
cat >&2 <<EOF
Usage: ${0##*/} [options]
Options:
-m, --message MESSAGE Attach a sanitized message slug to the snapshot name.
Example: -m "added Elton John"
Snapshot: publish_YYYYMMDDTHHMMSSZ_added-elton-john
--dry-run Print what would happen, but do not snapshot or replicate.
--skip-snapshot Do not create a new snapshot; replicate existing snapshots only.
--skip-provenance Do not stamp config/requirements/git metadata into the Library dataset.
--non-recursive Do not use recursive snapshot/replication.
--allow-non-laptop Allow running from a host other than NytegearArch.
-h, --help Show this help.
Environment overrides:
SOURCE_DATASET Default: Data/Music/Library
SOURCE_MOUNT Default: /data/Music/Library
BEETS_ROOT Default: /data/Music/Beets
TARGET_HOST Default: aargonian@NytegearMusic
TARGET_DATASET Default: Music/Library
SYNCOID Default: /usr/sbin/syncoid
ZFS Default: /usr/sbin/zfs
SNAP_PREFIX Default: publish
SNAP_TIMESTAMP_FORMAT Default: +%Y%m%dT%H%M%SZ
SNAP_MESSAGE_MAX_LENGTH Default: 80
Examples:
${0##*/}
${0##*/} -m "added Elton John"
${0##*/} --message ""
TARGET_HOST=aargonian@192.168.50.52 ${0##*/} -m "fixed cover art"
EOF
}
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2
}
die() {
log "ERROR: $*"
exit 1
}
run() {
if [[ "$DRY_RUN" -eq 1 ]]; then
printf 'DRY-RUN: ' >&2
printf '%q ' "$@" >&2
printf '\n' >&2
else
"$@"
fi
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
zfs_exists() {
"$ZFS" list -H -o name "$1" >/dev/null 2>&1
}
sanitize_snapshot_message() {
local raw="$1"
local slug
# Lowercase, replace unsafe runs with "-", trim leading/trailing "-".
# Keep this conservative. ZFS allows more than this in practice, but these
# characters are portable, readable, shell-safe, and unlikely to surprise tools.
slug="$(
printf '%s' "$raw" |
tr '[:upper:]' '[:lower:]' |
sed -E '
s/[^a-z0-9._-]+/-/g
s/-+/-/g
s/^-+//
s/-+$//
'
)"
if [[ ${#slug} -gt "$SNAP_MESSAGE_MAX_LENGTH" ]]; then
slug="${slug:0:$SNAP_MESSAGE_MAX_LENGTH}"
slug="$(printf '%s' "$slug" | sed -E 's/-+$//')"
fi
printf '%s' "$slug"
}
build_snapshot_name() {
local timestamp
timestamp="$(date -u "$SNAP_TIMESTAMP_FORMAT")"
SNAP_MESSAGE_SLUG="$(sanitize_snapshot_message "$SNAP_MESSAGE")"
if [[ -n "$SNAP_MESSAGE" && -z "$SNAP_MESSAGE_SLUG" ]]; then
die "Snapshot message produced an empty slug after sanitization: '$SNAP_MESSAGE'"
fi
if [[ -n "$SNAP_MESSAGE_SLUG" ]]; then
SNAP_NAME="${SNAP_PREFIX}_${timestamp}_${SNAP_MESSAGE_SLUG}"
else
SNAP_NAME="${SNAP_PREFIX}_${timestamp}"
fi
# Final hard validation. Snapshot component only, not full dataset@snapshot.
if [[ ! "$SNAP_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]*$ ]]; then
die "Generated snapshot name is invalid or unsafe: $SNAP_NAME"
fi
}
check_local_sanity() {
require_cmd date
require_cmd hostname
require_cmd git
require_cmd flock
require_cmd ssh
require_cmd sed
require_cmd tr
[[ -x "$ZFS" ]] || die "zfs executable not found or not executable: $ZFS"
[[ -x "$SYNCOID" ]] || die "syncoid executable not found or not executable: $SYNCOID"
local short_host
short_host="$(hostname -s)"
if [[ "$ALLOW_NON_LAPTOP" -ne 1 && "$short_host" != "NytegearArch" ]]; then
die "Refusing to run from host '$short_host'. Use --allow-non-laptop to override."
fi
zfs_exists "$SOURCE_DATASET" || die "Source dataset does not exist: $SOURCE_DATASET"
[[ -d "$SOURCE_MOUNT" ]] || die "Source mount does not exist: $SOURCE_MOUNT"
[[ -d "$BEETS_ROOT" ]] || die "Beets root does not exist: $BEETS_ROOT"
[[ -f "$SOURCE_MOUNT/musiclibrary.db" ]] || \
die "Missing beets DB: $SOURCE_MOUNT/musiclibrary.db"
[[ -f "$BEETS_ROOT/config/config.yaml" ]] || \
die "Missing active beets config: $BEETS_ROOT/config/config.yaml"
if ! mountpoint -q "$SOURCE_MOUNT"; then
die "Source mount is not a mountpoint: $SOURCE_MOUNT"
fi
}
check_remote_sanity() {
log "Checking remote target ${TARGET_HOST}:${TARGET_DATASET}"
ssh "$TARGET_HOST" "
set -e
command -v zfs >/dev/null
zfs list -H -o name '$TARGET_DATASET' >/dev/null
" || die "Remote target dataset check failed: ${TARGET_HOST}:${TARGET_DATASET}"
}
stamp_beets_provenance() {
local provenance_dir="$SOURCE_MOUNT/.beets/provenance"
local active_config="$BEETS_ROOT/config/config.yaml"
log "Stamping beets provenance into $provenance_dir"
run mkdir -p "$provenance_dir"
run cp -a "$active_config" "$provenance_dir/config.yaml"
if [[ -f "$BEETS_ROOT/requirements.txt" ]]; then
run cp -a "$BEETS_ROOT/requirements.txt" "$provenance_dir/requirements.txt"
fi
if [[ -f "$BEETS_ROOT/requirements.in" ]]; then
run cp -a "$BEETS_ROOT/requirements.in" "$provenance_dir/requirements.in"
fi
if [[ "$DRY_RUN" -eq 0 ]]; then
{
echo "published_at_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "published_from_host=$(hostname -s)"
echo "source_dataset=$SOURCE_DATASET"
echo "source_mount=$SOURCE_MOUNT"
echo "beets_root=$BEETS_ROOT"
echo "target_host=$TARGET_HOST"
echo "target_dataset=$TARGET_DATASET"
echo "snapshot_name=$SNAP_NAME"
echo "snapshot_message=$SNAP_MESSAGE"
echo "snapshot_message_slug=$SNAP_MESSAGE_SLUG"
} > "$provenance_dir/publish-info.txt"
if git -C "$BEETS_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
{
echo "repo=$BEETS_ROOT"
echo "commit=$(git -C "$BEETS_ROOT" rev-parse HEAD)"
echo "branch=$(git -C "$BEETS_ROOT" branch --show-current || true)"
if git -C "$BEETS_ROOT" diff --quiet --ignore-submodules -- &&
git -C "$BEETS_ROOT" diff --cached --quiet --ignore-submodules --; then
echo "dirty=no"
else
echo "dirty=yes"
fi
echo
echo "[status]"
git -C "$BEETS_ROOT" status --short
} > "$provenance_dir/git-revision.txt"
else
{
echo "repo=$BEETS_ROOT"
echo "git_repo=no"
} > "$provenance_dir/git-revision.txt"
fi
else
log "DRY-RUN: would write publish-info.txt and git-revision.txt"
fi
}
create_snapshot() {
local snapshot="${SOURCE_DATASET}@${SNAP_NAME}"
log "Creating ZFS snapshot: $snapshot"
if [[ "$RECURSIVE" -eq 1 ]]; then
run sudo "$ZFS" snapshot -r "$snapshot"
else
run sudo "$ZFS" snapshot "$snapshot"
fi
}
replicate() {
log "Replicating ${SOURCE_DATASET} -> ${TARGET_HOST}:${TARGET_DATASET}"
local args=(
"$SYNCOID"
--no-sync-snap
--create-bookmark
--no-privilege-elevation
--sendoptions="Lce"
--recvoptions="u"
--mbuffer-size=256M
)
if [[ "$RECURSIVE" -eq 1 ]]; then
args+=(--recursive)
fi
# Deliberately do NOT use --delete-target-snapshots.
# Server-side snapshots should not be destroyed by publish.
args+=(
"$SOURCE_DATASET"
"${TARGET_HOST}:${TARGET_DATASET}"
)
run "${args[@]}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
-m|--message)
[[ $# -ge 2 ]] || die "$1 requires a message argument"
SNAP_MESSAGE="$2"
shift
;;
--message=*)
SNAP_MESSAGE="${1#*=}"
;;
--dry-run)
DRY_RUN=1
;;
--skip-snapshot)
SKIP_SNAPSHOT=1
;;
--skip-provenance)
SKIP_PROVENANCE=1
;;
--non-recursive)
RECURSIVE=0
;;
--allow-non-laptop)
ALLOW_NON_LAPTOP=1
;;
-h|--help)
usage
exit 0
;;
*)
usage
die "Unknown option: $1"
;;
esac
shift
done
main() {
exec 9>"$LOCK_FILE"
flock -n 9 || die "Another publish appears to be running: $LOCK_FILE"
build_snapshot_name
log "Snapshot name will be: ${SOURCE_DATASET}@${SNAP_NAME}"
check_local_sanity
check_remote_sanity
if [[ "$SKIP_PROVENANCE" -ne 1 ]]; then
stamp_beets_provenance
else
log "Skipping provenance stamp"
fi
if [[ "$SKIP_SNAPSHOT" -ne 1 ]]; then
create_snapshot
else
log "Skipping snapshot creation"
fi
replicate
log "Publish complete."
}
main "$@"