#!/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 <&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 "$@"