378 lines
10 KiB
Bash
Executable File
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 "$@"
|