Finishes the pass: every script now documents its safeguards, and the deliberate absences in the sourced libraries are recorded so they are not "corrected" later.
281 lines
13 KiB
Bash
Executable File
281 lines
13 KiB
Bash
Executable File
#!/bin/bash
|
|
# ==============================================================================================
|
|
# ============================= share_setup.sh =================================================
|
|
# ==============================================================================================
|
|
#
|
|
# PURPOSE
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Creates missing Unraid shares on the mirror during partnership onboarding.
|
|
# Safe to re-run — existing shares are never modified, only missing ones created.
|
|
#
|
|
# For each path in the owner's sync share lists (daily, weekly, critical,
|
|
# intermediate):
|
|
# - Extracts the top-level Unraid share name
|
|
# - Skips if the remote already has a .cfg for it
|
|
# - Uses the local share .cfg as a template: substitutes the remote's detected
|
|
# appdata pool, clears disk include/exclude (disk layouts differ per server)
|
|
# - Writes the .cfg to remote /boot/config/shares/ and mkdir -p the directory
|
|
# - For sub-paths (e.g. appdata-Fallback/Critical-Data), ensures the subdir
|
|
# exists after the top-level share is in place
|
|
#
|
|
# Pool detection: reads the remote's appdata.cfg to find its cache pool name,
|
|
# so appdata-type shares land on the right pool without hardcoding.
|
|
#
|
|
# Media shares (shareUseCache=no in local .cfg) are created as array-only.
|
|
# Remote admin assigns disk sets from the Unraid UI after onboarding.
|
|
#
|
|
# ==============================================================================================
|
|
# OPERATIONAL MODEL
|
|
# ==============================================================================================
|
|
#
|
|
# 1. Resolve the mirror and its Tailscale IP — unresolvable aborts before any remote call
|
|
# 2. Detect the remote's appdata cache pool from its own appdata.cfg (default: cache)
|
|
# 3. For each path across the owner's daily/weekly/critical/intermediate share lists:
|
|
# a. Extract the top-level Unraid share name
|
|
# b. Remote already has a .cfg for it? → skip, never modify
|
|
# c. Otherwise use the LOCAL .cfg as a template:
|
|
# - substitute the remote's detected pool
|
|
# - clear disk include/exclude (disk layouts differ per server)
|
|
# d. Write the .cfg to remote /boot/config/shares/ and mkdir -p the share directory
|
|
# e. Sub-paths (e.g. appdata-Fallback/Critical-Data) get their subdir created after
|
|
# the top-level share exists
|
|
#
|
|
# ==============================================================================================
|
|
# DESIGN PRINCIPLES
|
|
# ==============================================================================================
|
|
#
|
|
# Create Only, Never Modify
|
|
# An existing remote .cfg is always left alone. The remote admin may have deliberately
|
|
# tuned a share's pool, allocation or disk set — this script has no way to tell an
|
|
# intentional setting from a stale one, so it never overwrites.
|
|
#
|
|
# Disk Layout Is Not Portable
|
|
# Include/exclude lists are cleared rather than copied, because the two servers have
|
|
# different disks. Copying the owner's disk set onto a mirror with a different array
|
|
# would produce a share pointing at disks that do not exist.
|
|
#
|
|
# Detect the Pool, Do Not Assume It
|
|
# The remote's cache pool name is read from its own appdata.cfg rather than hardcoded or
|
|
# copied from local. Pool names differ per server and a wrong one silently lands appdata
|
|
# on the array.
|
|
#
|
|
# Media Shares Land Array-Only
|
|
# Shares with shareUseCache=no are created without a pool assignment. Disk sets are the
|
|
# remote admin's call from the Unraid UI after onboarding.
|
|
#
|
|
# ==============================================================================================
|
|
# OPERATIONAL SAFEGUARDS
|
|
# ==============================================================================================
|
|
#
|
|
# Root Enforcement
|
|
# Reads $SSH_KEY from /root/.ssh and the local /boot/config/shares/*.cfg, and writes share
|
|
# configs onto the mirror as root.
|
|
#
|
|
# Lock Acquisition
|
|
# acquire_lock prevents concurrent runs. Two instances could both observe a share as
|
|
# missing and race to create it.
|
|
#
|
|
# Host Detection
|
|
# detect_hosts() resolves REMOTE_SERVER_NAME — the mirror this script targets.
|
|
#
|
|
# Mirror Resolution Guard
|
|
# Aborts if REMOTE_SERVER_NAME is unset or its Tailscale IP cannot be resolved, before any
|
|
# remote command is attempted.
|
|
#
|
|
# Existing Share Protection
|
|
# A remote .cfg that already exists is never touched — see Create Only above.
|
|
#
|
|
# SSH Timeouts and BatchMode
|
|
# Every remote call uses ConnectTimeout and BatchMode=yes, so an unreachable or
|
|
# password-prompting mirror fails fast instead of hanging the onboarding run.
|
|
#
|
|
# Pool Fallback
|
|
# An undetectable remote pool defaults to "cache" rather than writing an empty pool name
|
|
# into the share config.
|
|
#
|
|
# Dry Run Support
|
|
# --dry-run reports every share it would create and writes nothing.
|
|
#
|
|
# ==============================================================================================
|
|
# CONFIGURATION
|
|
# ==============================================================================================
|
|
#
|
|
# host*.conf (aliased by detect_hosts())
|
|
#
|
|
# HOST*_DAILY_SYNC_SHARES / _WEEKLY_ / _CRITICAL_ / _INTERMEDIATE_
|
|
# The share lists this script reads. Any path appearing in one of these on the owner
|
|
# is a share the mirror is expected to have.
|
|
#
|
|
# SSH_KEY
|
|
# Key used for every remote call. Written by ssh_setup.sh.
|
|
#
|
|
# master.conf
|
|
#
|
|
# HOST* — hostnames, used to resolve the mirror via detect_hosts()
|
|
#
|
|
# ==============================================================================================
|
|
# RUNTIME MODES
|
|
# ==============================================================================================
|
|
#
|
|
# share_setup.sh
|
|
# Create missing shares on the configured mirror. Skips existing.
|
|
#
|
|
# share_setup.sh --dry-run
|
|
# Show what would be created without writing anything.
|
|
#
|
|
# ==============================================================================================
|
|
|
|
set -uo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
source "$SCRIPT_DIR/../load_config.sh"
|
|
parse_args "$@"
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Setup ━━━
|
|
# ==============================================================================================
|
|
# Reads $SSH_KEY from /root/.ssh, reads local /boot/config/shares/*.cfg, and writes share
|
|
# configs onto the mirror as root.
|
|
[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; }
|
|
|
|
# Writes share .cfg files to the mirror. Two concurrent runs could both see a share as
|
|
# missing and race to create it.
|
|
acquire_lock
|
|
|
|
detect_hosts
|
|
|
|
# ── Resolve mirror ────────────────────────────────────────────────────────────────────────────
|
|
|
|
[[ -z "${REMOTE_SERVER_NAME:-}" ]] && { error "Cannot determine mirror hostname — check HOST* in master.conf"; exit 1; }
|
|
MIRROR="$REMOTE_SERVER_NAME"
|
|
|
|
MIRROR_IP=$(resolve_tailscale_ip "$MIRROR")
|
|
[[ -z "$MIRROR_IP" ]] && { error "Cannot resolve $MIRROR Tailscale IP — is Tailscale running?"; exit 1; }
|
|
|
|
_ssh() { ssh -i "$SSH_KEY" -o ConnectTimeout=10 -o BatchMode=yes -o StrictHostKeyChecking=no root@"$MIRROR_IP" "$@"; }
|
|
_scp() { scp -i "$SSH_KEY" -o ConnectTimeout=10 -o BatchMode=yes -o StrictHostKeyChecking=no "$@"; }
|
|
|
|
# ── Detect remote appdata pool ────────────────────────────────────────────────────────────────
|
|
|
|
REMOTE_POOL=$(_ssh "grep -oP '(?<=shareCachePool=\")[^\"]+' /boot/config/shares/appdata.cfg 2>/dev/null | head -1" 2>/dev/null || true)
|
|
REMOTE_POOL="${REMOTE_POOL:-cache}"
|
|
log "Remote appdata pool: $REMOTE_POOL"
|
|
|
|
# ── Collect paths from all sync share arrays ──────────────────────────────────────────────────
|
|
|
|
declare -a ALL_PATHS=()
|
|
|
|
_add_array() {
|
|
local varname="$1"
|
|
local -n _ref="$varname" 2>/dev/null || return 0
|
|
for entry in "${_ref[@]+"${_ref[@]}"}"; do
|
|
[[ -n "$entry" ]] && ALL_PATHS+=("${entry%%|*}")
|
|
done
|
|
}
|
|
|
|
_add_array "${MY_ID}_DAILY_SYNC_SHARES"
|
|
_add_array "${MY_ID}_WEEKLY_SYNC_SHARES"
|
|
_add_array "${MY_ID}_CRITICAL_SYNC_SHARES"
|
|
_add_array "${MY_ID}_INTERMEDIATE_SYNC_SHARES"
|
|
|
|
# ── Separate top-level shares from sub-paths ──────────────────────────────────────────────────
|
|
|
|
declare -A SEEN_TOP=()
|
|
declare -A SEEN_SUB=()
|
|
declare -a TOP_SHARES=()
|
|
declare -a SUB_PATHS=()
|
|
|
|
for path in "${ALL_PATHS[@]+"${ALL_PATHS[@]}"}"; do
|
|
[[ "$path" != /mnt/user/* ]] && continue
|
|
rel="${path#/mnt/user/}"
|
|
top="${rel%%/*}"
|
|
[[ -z "${SEEN_TOP[$top]+_}" ]] && { TOP_SHARES+=("$top"); SEEN_TOP["$top"]=1; }
|
|
if [[ "$rel" == */* && -z "${SEEN_SUB[$path]+_}" ]]; then
|
|
SUB_PATHS+=("$path"); SEEN_SUB["$path"]=1
|
|
fi
|
|
done
|
|
|
|
[[ ${#TOP_SHARES[@]} -eq 0 ]] && { echo "No shares to process."; exit 0; }
|
|
|
|
# ── Build .cfg for a share ────────────────────────────────────────────────────────────────────
|
|
# Uses local .cfg as template: substitutes pool, clears disk sets, drops comment.
|
|
# Falls back to a minimal cfg inferred from share name when no local .cfg exists.
|
|
|
|
_make_cfg() {
|
|
local share_name="$1"
|
|
local local_cfg="/boot/config/shares/${share_name}.cfg"
|
|
|
|
if [[ -f "$local_cfg" ]]; then
|
|
sed \
|
|
-e "s|^shareCachePool=\"[^\"]*\"|shareCachePool=\"${REMOTE_POOL}\"|" \
|
|
-e 's|^shareInclude="[^"]*"|shareInclude=""|' \
|
|
-e 's|^shareExclude="[^"]*"|shareExclude=""|' \
|
|
-e '/^shareComment=/d' \
|
|
"$local_cfg"
|
|
else
|
|
local use_cache="no" pool="" allocator="mostfree"
|
|
case "$share_name" in appdata*|Media_Server*)
|
|
use_cache="yes"; pool="$REMOTE_POOL"; allocator="highwater" ;;
|
|
esac
|
|
printf 'shareUseCache="%s"\nshareCachePool="%s"\nshareCachePool2=""\n' "$use_cache" "$pool"
|
|
printf 'shareCOW="auto"\nshareAllocator="%s"\nshareInclude=""\nshareExclude=""\n' "$allocator"
|
|
printf 'shareFloor="0"\nshareExport="e"\nshareCaseSensitive="auto"\n'
|
|
printf 'shareSecurity="private"\nshareReadList=""\nshareWriteList=""\n'
|
|
printf 'shareVolsizelimit=""\nshareExportNFS="-"\nshareExportNFSFsid="0"\n'
|
|
printf 'shareSecurityNFS="public"\nshareHostListNFS=""\n'
|
|
fi
|
|
}
|
|
|
|
# ── Process each top-level share ──────────────────────────────────────────────────────────────
|
|
|
|
TMPFILE=$(mktemp)
|
|
trap 'rm -f "$TMPFILE"' EXIT
|
|
|
|
CREATED=0 SKIPPED=0
|
|
|
|
for share in "${TOP_SHARES[@]}"; do
|
|
existing=$(_ssh "[[ -f '/boot/config/shares/${share}.cfg' ]] && echo yes || echo no" 2>/dev/null)
|
|
|
|
if [[ "$existing" == "yes" ]]; then
|
|
log " exists: $share"
|
|
(( SKIPPED++ )) || true
|
|
continue
|
|
fi
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
local_cfg="/boot/config/shares/${share}.cfg"
|
|
if [[ -f "$local_cfg" ]]; then
|
|
use_cache=$(grep -oP '(?<=shareUseCache=")[^"]+' "$local_cfg" || echo "?")
|
|
echo " [dry-run] CREATE $share (useCache=$use_cache → pool: $REMOTE_POOL)"
|
|
else
|
|
echo " [dry-run] CREATE $share (no local cfg — minimal fallback)"
|
|
fi
|
|
(( CREATED++ )) || true
|
|
continue
|
|
fi
|
|
|
|
_make_cfg "$share" > "$TMPFILE"
|
|
_scp "$TMPFILE" "root@${MIRROR_IP}:/boot/config/shares/${share}.cfg" >/dev/null
|
|
_ssh "mkdir -p /mnt/user/${share}" >/dev/null
|
|
echo " Created: $share ✅"
|
|
(( CREATED++ )) || true
|
|
done
|
|
|
|
# ── Ensure sub-path directories exist ────────────────────────────────────────────────────────
|
|
|
|
for path in "${SUB_PATHS[@]+"${SUB_PATHS[@]}"}"; do
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
echo " [dry-run] MKDIR $path"
|
|
continue
|
|
fi
|
|
_ssh "mkdir -p '$path'" 2>/dev/null && log " mkdir: $path" || true
|
|
done
|
|
|
|
# ── Summary ───────────────────────────────────────────────────────────────────────────────────
|
|
|
|
echo ""
|
|
echo " Shares: $CREATED created, $SKIPPED already existed on $MIRROR"
|
|
[[ "$DRY_RUN" == true ]] && echo " (dry-run — nothing written)"
|
|
|
|
exit 0
|