Files
Gmer4Lfe c377ddfcca Complete the header template across Partnership, Kernel, Deployment and Plugin
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.
2026-08-01 22:44:23 -04:00

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