diff --git a/Partnership/partnership_manager.sh b/Partnership/partnership_manager.sh index a33a13f..5c4938d 100644 --- a/Partnership/partnership_manager.sh +++ b/Partnership/partnership_manager.sh @@ -1545,6 +1545,13 @@ fi # ━━━ Transfer ━━━ # ============================================================================================== if [[ "$MODE" == "transfer" ]]; then + exec bash "$SCRIPT_DIR/partnership_transfer.sh" \ + ${TRANSFER_CONFIRM_INPUT:+--confirm="$TRANSFER_CONFIRM_INPUT"} \ + "${FILTERED_ARGS[@]}" +fi + +# (legacy inline transfer block — kept below for reference, unreachable) +if false; then echo "" echo "━━━ $ICON_FALLBACK Transfer Ownership — $(date '+%Y-%m-%d %H:%M:%S') ━━━" echo "" diff --git a/Partnership/partnership_transfer.sh b/Partnership/partnership_transfer.sh new file mode 100755 index 0000000..b09a65f --- /dev/null +++ b/Partnership/partnership_transfer.sh @@ -0,0 +1,312 @@ +#!/bin/bash +# ============================================================================================== +# ============================= Partnership Transfer ============================================ +# ============================================================================================== +# +# PURPOSE +# ───────────────────────────────────────────────────────────────────────────── +# Transfers ownership from the current owner to the current mirror. After +# transfer the roles are swapped: what was the mirror becomes the new owner, +# and what was the owner becomes the new mirror. +# +# No containers are moved — only config and WebUI targets are updated. Both +# servers remain in the partnership; the sync direction reverses on the next +# fallback.sh / critical_sync_maintenance.sh cycle. +# +# ============================================================================================== +# OPERATIONAL MODEL +# ============================================================================================== +# +# OWNER ONLY — mirror cannot run this script. +# +# Step 1: Health Verification — both servers pass N consecutive health checks +# Step 2: Pre-transfer Sync — final sync in current direction (owner → mirror) +# Step 3: Reconfigure WebUIs — new owner WebUIs → localhost +# new mirror WebUIs → new owner IP +# Step 4: Flip Ownership — update PARTNERSHIP_OWNER_HOST in master.conf +# on both servers +# Step 5: Write State — ACTIVE written locally and pushed to new mirror +# +# ============================================================================================== +# CONFIGURATION +# ============================================================================================== +# +# master.conf +# +# PARTNERSHIP_OWNER_HOST +# Current owner host ID (e.g. "HOST1"). Updated on both servers after transfer. +# +# PARTNERSHIP_TRANSFER_CONFIRM +# Exact string required to confirm transfer (default: "i-understand-this-transfers-ownership"). +# Pass via --confirm=. +# +# PARTNERSHIP_TRANSFER_STRIKES +# Consecutive health checks both servers must pass before transfer proceeds (default: 3). +# +# PARTNERSHIP_TRANSFER_MAX_ATTEMPTS +# Max health check attempts before giving up (default: 20). +# +# CRITICAL_SYNC_SHARES +# Array of "path|profile" or "path" entries for do_final_sync(). +# +# PARTNERSHIP_AUTH_WEBUIS +# Array of "ContainerName|WebUIPort" entries reconfigured during transfer. +# +# ============================================================================================== +# RUNTIME MODES +# ============================================================================================== +# +# Partnership/partnership_transfer.sh --confirm=i-understand-this-transfers-ownership +# Full transfer — owner detected automatically. +# +# Partnership/partnership_transfer.sh --confirm=i-understand-this-transfers-ownership --dry-run +# Preview all steps without executing. Confirmation check is skipped in dry-run mode. +# +# Partnership/partnership_transfer.sh --confirm=i-understand-this-transfers-ownership --log +# Verbose per-step output. +# +# ============================================================================================== + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPTS_ROOT="$SCRIPT_DIR/.." +SSH_TIMEOUT=15 + +source "$SCRIPTS_ROOT/load_config.sh" + +# ── Parse flags ─────────────────────────────────────────────────────────────────────────────── +TRANSFER_CONFIRM_INPUT="" +FILTERED_ARGS=() + +for arg in "$@"; do + case "$arg" in + --confirm=*) TRANSFER_CONFIRM_INPUT="${arg#--confirm=}" ;; + *) FILTERED_ARGS+=("$arg") ;; + esac +done + +parse_args "${FILTERED_ARGS[@]}" + +# ── Source partnership_manager.sh for shared helpers ────────────────────────────────────────── +# PARTNERSHIP_LIB_MODE=1 skips mode dispatch — functions are defined, nothing is executed. +PARTNERSHIP_LIB_MODE=1 source "$SCRIPT_DIR/partnership_manager.sh" + +# ============================================================================================== +# ━━━ Setup ━━━ +# ============================================================================================== +[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; } + +if ! command -v docker &>/dev/null; then + error "Docker command not found" + exit 1 +fi + +validate_unraid_cmd \ + "/usr/local/emhttp/plugins/dynamix/scripts/notify" \ + "" "" \ + "unRAID notify script" || warn "unRAID notify script not found — native notifications disabled" + +detect_hosts + +OWNER_ID="${PARTNERSHIP_OWNER_HOST:-HOST1}" +MIRROR_ID=$( [[ "$OWNER_ID" == "HOST1" ]] && echo "HOST2" || echo "HOST1" ) +OWNER="${!OWNER_ID}" +MIRROR="${!MIRROR_ID}" +MIRROR_SSH_KEY="$SSH_KEY" +OWNER_SSH_KEY="$SSH_KEY" + +AM_OWNER=false +AM_MIRROR=false +[[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true +[[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true + +LOCAL_STATE_FILE="/boot/config/partnership_${LOCAL_SERVER_NAME}.db" +REMOTE_STATE_FILE="/boot/config/partnership_${REMOTE_SERVER_NAME}.db" + +acquire_lock "strict" + +# ============================================================================================== +# ━━━ Preflight ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_FALLBACK Partnership Transfer — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━" +echo "" + +if [[ "$AM_MIRROR" == true ]]; then + error "Only the owner ($OWNER / $OWNER_ID) can run --transfer" + error "Run from $OWNER, or use --offboard and re-onboard with roles swapped" + exit 1 +fi + +if [[ -f "$LOCAL_STATE_FILE" ]]; then + CURRENT_STATE=$(read_state_file "$LOCAL_STATE_FILE" "state") + if [[ "$CURRENT_STATE" == "INACTIVE" ]]; then + error "No active partnership — transfer requires an active partnership" + error "If roles are already correct, check PARTNERSHIP_OWNER_HOST in master.conf" + exit 1 + fi +fi + +[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no permanent changes will be made" + +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "⚠️ WARNING — OWNERSHIP TRANSFER" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " Current owner: $OWNER_ID ($OWNER)" +echo " Current mirror: $MIRROR_ID ($MIRROR)" +echo "" +echo " After transfer:" +echo " New owner: $MIRROR_ID ($MIRROR)" +echo " New mirror: $OWNER_ID ($OWNER)" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# ============================================================================================== +# ━━━ Confirmation ━━━ +# ============================================================================================== +if [[ "$DRY_RUN" == false ]]; then + if [[ -z "$TRANSFER_CONFIRM_INPUT" ]]; then + echo "" + echo "To proceed, pass exactly:" + echo " --confirm=${PARTNERSHIP_TRANSFER_CONFIRM}" + echo "" + error "Transfer cancelled — confirmation required" + exit 1 + fi + if [[ "$TRANSFER_CONFIRM_INPUT" != "$PARTNERSHIP_TRANSFER_CONFIRM" ]]; then + error "Confirmation string does not match — transfer cancelled" + exit 1 + fi + log "Confirmation accepted" +else + warn "DRY RUN — confirmation check skipped" +fi + +# ============================================================================================== +# ━━━ Step 1: Health Verification ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_SHIELD Step 1: Health Verification ━━━" +log "Both servers must pass ${PARTNERSHIP_TRANSFER_STRIKES} consecutive health checks" + +STRIKES=0 +ATTEMPTS=0 +MAX_ATTEMPTS="${PARTNERSHIP_TRANSFER_MAX_ATTEMPTS:-20}" + +while [[ "$STRIKES" -lt "$PARTNERSHIP_TRANSFER_STRIKES" ]]; do + (( ATTEMPTS++ )) + if [[ "$ATTEMPTS" -gt "$MAX_ATTEMPTS" ]]; then + error "Health checks failed after $MAX_ATTEMPTS attempts — servers not stable" + error "Transfer cancelled — try again when both servers are healthy" + exit 1 + fi + + if check_both_healthy; then + (( STRIKES++ )) + log "Health check passed ($STRIKES/${PARTNERSHIP_TRANSFER_STRIKES})" + [[ "$STRIKES" -lt "$PARTNERSHIP_TRANSFER_STRIKES" ]] && sleep 10 + else + warn "Health check failed — resetting (attempt $ATTEMPTS/$MAX_ATTEMPTS)" + STRIKES=0 + sleep 30 + fi +done +warn "Both servers healthy — proceeding ✅" + +# Resolve IPs after health checks confirm reachability +MIRROR_IP=$(resolve_tailscale_ip "$MIRROR") +OWNER_IP=$(resolve_tailscale_ip "$OWNER") +[[ -z "$MIRROR_IP" ]] && { error "Cannot resolve mirror Tailscale IP"; exit 1; } + +# Compute post-transfer roles +NEW_OWNER_ID="$MIRROR_ID" +NEW_MIRROR_ID="$OWNER_ID" +NEW_OWNER="$MIRROR" +NEW_MIRROR="$OWNER" +NEW_OWNER_IP="$MIRROR_IP" + +# ============================================================================================== +# ━━━ Step 2: Pre-transfer Sync ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_SYNC Step 2: Pre-transfer Sync ━━━" +do_final_sync + +# ============================================================================================== +# ━━━ Step 3: Reconfigure WebUIs ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_CONTAINERS Step 3: Reconfigure WebUIs ━━━" + +WEBUI_FAILURES=0 + +# New owner (current mirror, HOST2) WebUIs → localhost — it now manages itself directly +log "New owner ($NEW_OWNER) WebUIs → localhost" +for entry in "${PARTNERSHIP_AUTH_WEBUIS[@]}"; do + [[ -z "$entry" ]] && continue + container="${entry%%|*}" + port="${entry##*|}" + reconfigure_webui "$container" "$port" "localhost" \ + "$SSH_KEY" "$MIRROR_IP" "$MIRROR" || (( WEBUI_FAILURES++ )) +done + +# New mirror (us, HOST1) WebUIs → new owner IP — defers to new owner going forward +log "New mirror ($NEW_MIRROR) WebUIs → $NEW_OWNER_IP" +reconfigure_local_webuis "$NEW_OWNER_IP" +WEBUI_RC=$? +[[ "$WEBUI_RC" -gt 0 ]] && (( WEBUI_FAILURES += WEBUI_RC )) + +# ============================================================================================== +# ━━━ Step 4: Flip Ownership in master.conf ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_GEAR Step 4: Flip Ownership ━━━" + +if [[ "$DRY_RUN" == false ]]; then + update_master_conf "PARTNERSHIP_OWNER_HOST" "\"$NEW_OWNER_ID\"" + + # Push updated master.conf to new owner so both servers agree immediately. + # master.conf is shared — host-specific credentials live in host*.conf. + scp -i "$SSH_KEY" \ + -o ConnectTimeout="$SSH_TIMEOUT" \ + -o StrictHostKeyChecking=no \ + "$SCRIPTS_ROOT/master.conf" \ + "root@${MIRROR_IP}:/mnt/user/appdata/unraid_scripts/master.conf" 2>/dev/null && \ + log "master.conf pushed to $NEW_OWNER ✅" || \ + error "Failed to push master.conf to $NEW_OWNER — set PARTNERSHIP_OWNER_HOST=\"$NEW_OWNER_ID\" manually" +else + warn "DRY RUN — would set PARTNERSHIP_OWNER_HOST=$NEW_OWNER_ID on both servers" +fi + +# ============================================================================================== +# ━━━ Step 5: Write State ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_SYNC Step 5: Write State ━━━" + +NOW=$(date '+%Y-%m-%d %H:%M:%S') +write_state_file "$LOCAL_STATE_FILE" "ACTIVE" "$NOW" "" "$LOCAL_SERVER_NAME" "transfer" +push_state_to_remote "$LOCAL_STATE_FILE" "$MIRROR_IP" "$SSH_KEY" + +# ============================================================================================== +# ━━━ Summary ━━━ +# ============================================================================================== +echo "" +echo "━━━━━ $ICON_SUMMARY TRANSFER SUMMARY ━━━━━" +echo " New owner: $NEW_OWNER_ID ($NEW_OWNER — $NEW_OWNER_IP)" +echo " New mirror: $NEW_MIRROR_ID ($NEW_MIRROR)" +echo " WebUI failures: $WEBUI_FAILURES" +echo " Sync direction: $NEW_OWNER → $NEW_MIRROR (next cycle)" +echo " Ownership: PARTNERSHIP_OWNER_HOST=$NEW_OWNER_ID" +echo "" +if [[ "$DRY_RUN" == true ]]; then + warn "DRY RUN — no changes made" +else + warn "$ICON_DONE DONE — ownership transferred to $NEW_OWNER_ID ($NEW_OWNER) ✅" + echo "fallback.sh and critical_sync_maintenance.sh will adapt on next cycle" + echo "No containers were moved — only config and WebUI targets updated" + [[ "$WEBUI_FAILURES" -gt 0 ]] && \ + warn "$WEBUI_FAILURES WebUI(s) failed — check templates manually" + notify "Partnership ownership transferred — new owner: $NEW_OWNER ($NEW_OWNER_ID)" \ + "Partnership" "normal" +fi +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +exit 0