1	#!/bin/bash
2	# ==============================================================================================
3	# ============================= Partnership Offboard ===========================================
4	# ==============================================================================================
5	#
6	# PURPOSE
7	# ─────────────────────────────────────────────────────────────────────────────
8	# Cleanly ends a partnership. Role is detected automatically — run on either server.
9	# Owner path runs the full sequence including remote cleanup and final sync.
10	# Mirror path handles the local side and signals the owner to complete its own cleanup.
11	#
12	# ==============================================================================================
13	# OPERATIONAL MODEL
14	# ==============================================================================================
15	#
16	# OWNER PATH (10 steps)
17	#   Step 1:  Stop rsync         — halt any running sync before state changes
18	#   Step 2:  Final sync         — mirror leaves with current Critical-Data state
19	#   Step 3:  Reconfigure WebUIs — mirror's auth WebUIs → localhost
20	#   Step 4:  Disable sync       — CRITICAL_RSYNC_ENABLED=false in master.conf
21	#   Step 5:  Local cleanup      — remove fallback coverage containers + appdata
22	#   Step 6:  Restart own stack  — bring up owner's own parked containers
23	#   Step 7:  Remote cleanup     — remove auth/arr stack + fallback containers from mirror
24	#   Step 8:  Restart mirror     — bring up mirror's own parked containers
25	#   Step 9:  Revocation         — Emby admin, SSH keys
26	#   Step 10: Write state        — INACTIVE locally + pushed to mirror, mirror blocklisted
27	#            Tailscale          — grace window then device removal (after state written)
28	#
29	# MIRROR PATH (8 steps)
30	#   Step 1: Stop rsync          — halt any running sync
31	#   Step 2: Reconfigure WebUIs  — local auth WebUIs → localhost
32	#   Step 3: Remote stack clean  — remove owner-deployed containers locally (auth/arr stack)
33	#   Step 4: Fallback cleanup    — remove fallback coverage containers
34	#   Step 5: Disable sync        — CRITICAL_RSYNC_ENABLED=false in master.conf
35	#   Step 6: Revoke Emby admin   — remove own admin account from local Emby instance
36	#   Step 7: Restart own stack   — bring up own parked containers
37	#   Step 8: SSH revocation      — revoke keys both directions, write state, signal owner
38	#
39	# ==============================================================================================
40	# CONFIGURATION
41	# ==============================================================================================
42	#
43	# host*.conf
44	#
45	#   HOST*_PARTNERSHIP_AUTH_STACK
46	#       Auth container XMLs to push during onboard — used on offboard to identify what
47	#       to remove. Owner's PARTNERSHIP_AUTH_STACK determines which containers get removed
48	#       from the mirror on both owner-initiated and mirror-initiated offboard.
49	#
50	#   HOST*_PARTNERSHIP_ARR_STACK
51	#       Arr container XMLs — same cleanup logic as auth stack.
52	#
53	# ==============================================================================================
54	# RUNTIME MODES
55	# ==============================================================================================
56	#
57	# Partnership/partnership_offboard.sh
58	#     Full offboard — role detected automatically
59	#
60	# Partnership/partnership_offboard.sh --dry-run
61	#     Preview all steps without executing
62	#
63	# Partnership/partnership_offboard.sh --log
64	#     Verbose per-step output
65	#
66	# Partnership/partnership_offboard.sh --reason=<string>
67	#     Tag the offboard reason in state file and blocklist (default: manual)
68	#     Called by partnership_manager.sh --offboard (reason passed through)
69	#
70	# ==============================================================================================
71
72	SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
73	SCRIPTS_ROOT="$SCRIPT_DIR/.."
74	TEMPLATES_DIR="/boot/config/plugins/dockerMan/templates-user"
75	SSH_TIMEOUT=15
76
77	source "$SCRIPTS_ROOT/load_config.sh"
78
79	# ── Parse flags ───────────────────────────────────────────────────────────────────────────────
80	REASON="manual"
81	FILTERED_ARGS=()
82
83	for arg in "$@"; do
84	    case "$arg" in
85	        --reason=*) REASON="${arg#--reason=}" ;;
86	        *)          FILTERED_ARGS+=("$arg") ;;
87	    esac
88	done
89
90	parse_args "${FILTERED_ARGS[@]}"
91
92	# ── Source partnership_manager.sh for shared helpers ──────────────────────────────────────────
93	# PARTNERSHIP_LIB_MODE=1 skips mode dispatch — functions are defined, nothing is executed.
94	PARTNERSHIP_LIB_MODE=1 source "$SCRIPT_DIR/partnership_manager.sh"
95
96	# ==============================================================================================
97	# ━━━ Setup ━━━
98	# ==============================================================================================
99	[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; }
100
101	if ! command -v docker &>/dev/null; then
102	    error "Docker command not found"
103	    exit 1
104	fi
105
106	detect_hosts
107
108	OWNER_ID="${PARTNERSHIP_OWNER_HOST:-HOST1}"
109	MIRROR_ID=$( [[ "$OWNER_ID" == "HOST1" ]] && echo "HOST2" || echo "HOST1" )
110	OWNER="${!OWNER_ID}"
111	MIRROR="${!MIRROR_ID}"
112	MIRROR_SSH_KEY="$SSH_KEY"
113	OWNER_SSH_KEY="$SSH_KEY"
114
115	AM_OWNER=false
116	AM_MIRROR=false
117	[[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true
118	[[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true
119
120	LOCAL_STATE_FILE="${STATE_DIR:-/boot/config}/partnership_${LOCAL_SERVER_NAME}.db"
121	REMOTE_STATE_FILE="${STATE_DIR:-/boot/config}/partnership_${REMOTE_SERVER_NAME}.db"
122	OWNER_STATE_FILE="${STATE_DIR:-/boot/config}/partnership_${OWNER}.db"
123	MIRROR_STATE_FILE="${STATE_DIR:-/boot/config}/partnership_${MIRROR}.db"
124	OFFLINE_COUNTER="${STATE_DIR:-/boot/config}/partnership_offline_days.db"
125
126	acquire_lock "strict"
127
128	trap _pm_trap_restart_stopped EXIT
129
130	# Check already offboarded
131	if [[ -f "$LOCAL_STATE_FILE" ]]; then
132	    CURRENT_STATE=$(read_state_file "$LOCAL_STATE_FILE" "state")
133	    if [[ "$CURRENT_STATE" == "INACTIVE" ]]; then
134	        warn "Partnership already INACTIVE — use partnership_manager.sh --status to verify both servers agree"
135	        exit 0
136	    fi
137	fi
138
139	START=$(date +%s)
140
141	echo ""
142	echo "━━━ $ICON_FALLBACK Partnership Offboard — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
143	echo ""
144	echo "  Role:    $( [[ "$AM_OWNER" == true ]] && echo "OWNER" || echo "MIRROR" )"
145	echo "  This:    $MY_ID ($LOCAL_SERVER_NAME)"
146	echo "  Partner: $( [[ "$AM_OWNER" == true ]] && echo "$MIRROR_ID ($MIRROR)" || echo "$OWNER_ID ($OWNER)" )"
147	echo "  Reason:  $REASON"
148	echo ""
149	[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no permanent changes will be made"
150
151	# ==============================================================================================
152	# ── HELPER: remove owner-deployed containers from a remote host ───────────────────────────────
153	#
154	# Uses PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK arrays (owner's conf) to derive
155	# container names from local XML templates. SSHes to remote to stop, remove, and delete
156	# appdata. Appdata paths are collected via docker inspect before removal so they aren't
157	# lost once the container is gone. Safety gate: only /mnt/*/appdata* paths are deleted.
158	# ==============================================================================================
159	cleanup_deployed_stack_on_remote() {
160	    local remote_ip="$1" ssh_key="$2"
161	    local -a xml_names=()
162	    [[ ${#PARTNERSHIP_AUTH_STACK[@]} -gt 0 ]] && xml_names+=("${PARTNERSHIP_AUTH_STACK[@]}")
163	    [[ ${#PARTNERSHIP_ARR_STACK[@]}  -gt 0 ]] && xml_names+=("${PARTNERSHIP_ARR_STACK[@]}")
164
165	    if [[ ${#xml_names[@]} -eq 0 ]]; then
166	        log "No auth/arr stack arrays configured — skipping deployed stack cleanup"
167	        return 0
168	    fi
169
170	    log "Removing owner-deployed containers (auth/arr stacks) from $MIRROR..."
171	    for xml_name in "${xml_names[@]}"; do
172	        [[ -z "$xml_name" ]] && continue
173	        local xml_file="${TEMPLATES_DIR}/${xml_name}"
174	        if [[ ! -f "$xml_file" ]]; then
175	            warn "  $xml_name not found in local $TEMPLATES_DIR — skipping"
176	            continue
177	        fi
178
179	        local cname
180	        cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
181	        [[ -z "$cname" ]] && continue
182
183	        if [[ "$DRY_RUN" == true ]]; then
184	            warn "  DRY RUN — would stop + rm $cname on $MIRROR"
185	            warn "  DRY RUN — would delete appdata for $cname on $MIRROR"
186	            continue
187	        fi
188
189	        # Collect appdata paths via docker inspect before removal
190	        local appdata_paths
191	        appdata_paths=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
192	            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
193	            "docker inspect --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' '$cname' 2>/dev/null \
194	             | awk -F: '{print \$1}' | grep '^/mnt/.*/appdata'" 2>/dev/null)
195
196	        timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
197	            -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
198	            "docker stop '$cname' >/dev/null 2>&1
199	             docker rm   '$cname' >/dev/null 2>&1 && echo removed" 2>/dev/null | \
200	            grep -q removed && \
201	            log "  $cname removed from $MIRROR ✅" || \
202	            log "  $cname not found on $MIRROR — skipping"
203
204	        while IFS= read -r path; do
205	            [[ -z "$path" ]] && continue
206	            timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
207	                -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
208	                "rm -rf '$path' && echo removed" 2>/dev/null | grep -q removed && \
209	                log "  Appdata removed on $MIRROR: $path ✅" || \
210	                warn "  Failed to remove appdata on $MIRROR: $path"
211	        done <<< "$appdata_paths"
212	    done
213	}
214
215	# ==============================================================================================
216	# ── HELPER: remove owner-deployed containers locally (mirror-initiated offboard) ─────────────
217	#
218	# SSHes to owner to read PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK, then uses the
219	# local templates-user/ copies (SCPed there during onboard) to get container names and
220	# appdata paths. Appdata collected before removal. Skips gracefully if owner unreachable.
221	# ==============================================================================================
222	cleanup_deployed_stack_locally() {
223	    local owner_ip="$1" ssh_key="$2"
224	    local -a xml_names=()
225
226	    if [[ -n "$owner_ip" ]]; then
227	        local -a auth_arr arr_arr
228	        mapfile -t auth_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
229	            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
230	            "source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
231	             detect_hosts 2>/dev/null
232	             printf '%s\n' \"\${PARTNERSHIP_AUTH_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
233	        mapfile -t arr_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
234	            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
235	            "source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
236	             detect_hosts 2>/dev/null
237	             printf '%s\n' \"\${PARTNERSHIP_ARR_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
238	        xml_names=("${auth_arr[@]}" "${arr_arr[@]}")
239	    fi
240
241	    if [[ ${#xml_names[@]} -eq 0 ]]; then
242	        log "Could not read deployed stack from owner — skipping auth/arr cleanup"
243	        return 0
244	    fi
245
246	    log "Removing owner-deployed containers (auth/arr stacks) locally..."
247	    for xml_name in "${xml_names[@]}"; do
248	        [[ -z "$xml_name" ]] && continue
249	        local xml_file="${TEMPLATES_DIR}/${xml_name}"
250	        if [[ ! -f "$xml_file" ]]; then
251	            warn "  $xml_name not found locally — skipping"
252	            continue
253	        fi
254
255	        local cname
256	        cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
257	        [[ -z "$cname" ]] && continue
258
259	        if [[ "$DRY_RUN" == true ]]; then
260	            warn "  DRY RUN — would stop + rm $cname"
261	            warn "  DRY RUN — would delete appdata for $cname"
262	            continue
263	        fi
264
265	        local appdata_paths=""
266	        if timeout "${DOCKER_TIMEOUT:-30}" docker inspect "$cname" >/dev/null 2>&1; then
267	            appdata_paths=$(docker inspect \
268	                --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' \
269	                "$cname" 2>/dev/null | awk -F: '{print $1}' | grep '^/mnt/.*/appdata')
270	            timeout "${DOCKER_TIMEOUT:-30}" docker stop "$cname" >/dev/null 2>&1 || true
271	            _PM_TRAP_STOPPED+=("$cname")
272	            timeout "${DOCKER_TIMEOUT:-30}" docker rm   "$cname" >/dev/null 2>&1 && \
273	                log "  $cname removed ✅" || warn "  $cname rm failed"
274	        else
275	            log "  $cname not found locally — skipping"
276	        fi
277
278	        while IFS= read -r path; do
279	            [[ -z "$path" ]] && continue
280	            rm -rf "$path" && log "  Appdata removed: $path ✅" || warn "  Failed to remove: $path"
281	        done <<< "$appdata_paths"
282	    done
283	}
284
285	# ==============================================================================================
286	# ── HELPER: revoke own admin account from local Emby instance ────────────────────────────────
287	#
288	# Mirror-initiated path only. Called before start_own_stack so Emby is still running.
289	# Uses local EMBY_API_KEY and the mirror's own short name as the username to delete.
290	# ==============================================================================================
291	revoke_local_emby_admin() {
292	    local emby_port="${PARTNERSHIP_EMBY_PORT:-8096}"
293	    local emby_url="http://127.0.0.1:${emby_port}"
294
295	    echo ""
296	    echo "━━━ $ICON_EMBY Emby Admin Revocation ━━━"
297
298	    if [[ "${PARTNERSHIP_PROVISION_EMBY_ADMIN:-false}" != true ]]; then
299	        log "PARTNERSHIP_PROVISION_EMBY_ADMIN=false — skipping"
300	        return 0
301	    fi
302
303	    if [[ -z "${EMBY_API_KEY:-}" ]]; then
304	        warn "EMBY_API_KEY not set — skipping local Emby admin revocation"
305	        return 1
306	    fi
307
308	    # The account to revoke is this server's own short name (the mirror user's account)
309	    local username="${PARTNERSHIP_EMBY_ADMIN_USER:-$(derive_short_name "$LOCAL_SERVER_NAME")}"
310
311	    if [[ "$DRY_RUN" == true ]]; then
312	        warn "DRY RUN — would delete Emby admin '$username' at $emby_url"
313	        return 0
314	    fi
315
316	    log "Looking up Emby user '$username' at $emby_url..."
317
318	    local users_json user_id
319	    users_json=$(curl -sf --max-time 15 \
320	        -H "X-Emby-Authorization: MediaBrowser Token=\"$EMBY_API_KEY\"" \
321	        "${emby_url}/Users" 2>/dev/null)
322
323	    user_id=$(echo "$users_json" | \
324	        grep -o "\"Id\":\"[^\"]*\"[^}]*\"Name\":\"${username}\"" | \
325	        grep -o '"Id":"[^"]*"' | cut -d'"' -f4 | head -1)
326
327	    if [[ -z "$user_id" ]]; then
328	        warn "Emby user '$username' not found at $emby_url — may already be removed"
329	        return 0
330	    fi
331
332	    local del_code
333	    del_code=$(curl -sf --max-time 15 -w "%{http_code}" -o /dev/null \
334	        -X DELETE \
335	        -H "X-Emby-Authorization: MediaBrowser Token=\"$EMBY_API_KEY\"" \
336	        "${emby_url}/Users/${user_id}" 2>/dev/null)
337
338	    if [[ "$del_code" == "200" ]] || [[ "$del_code" == "204" ]] || [[ "$del_code" == "404" ]]; then
339	        log "Emby admin '$username' removed ✅"
340	    else
341	        warn "Failed to delete Emby user '$username' (HTTP $del_code) — remove manually"
342	    fi
343	}
344
345	# ==============================================================================================
346	# ── MIRROR PATH ───────────────────────────────────────────────────────────────────────────────
347	# ==============================================================================================
348	if [[ "$AM_MIRROR" == true ]]; then
349	    warn "$MIRROR_ID ($MIRROR) is initiating offboard"
350	    warn "Owner ($OWNER) will see INACTIVE state on its next --check cycle and finalize"
351
352	    if [[ "$DRY_RUN" == false ]]; then
353	        echo ""
354	        echo "You have 10 seconds to cancel (Ctrl+C)..."
355	        sleep 10
356	    fi
357
358	    OWNER_IP=$(resolve_tailscale_ip "$OWNER")
359	    OWNER_REACHABLE=false
360	    [[ -n "$OWNER_IP" ]] && OWNER_REACHABLE=true
361
362	    STEP_STOP_RSYNC_OK=true
363	    STEP_WEBUI_OK=true
364	    STEP_STACK_CLEANUP_OK=true
365	    STEP_FALLBACK_CLEANUP_OK=true
366	    STEP_DISABLE_RSYNC_OK=true
367	    STEP_EMBY_OK=true
368	    SSH_REVOKE_REMOTE_OK=false
369	    SSH_REVOKE_LOCAL_OK=false
370
371	    # ── Step 1: Stop rsync ────────────────────────────────────────────────────────────────────
372	    echo ""
373	    echo "━━━ $ICON_STOP Step 1/8 — Stop Rsync ━━━"
374
375	    if [[ "$DRY_RUN" == false ]]; then
376	        bash "$SCRIPTS_ROOT/Rsync/rsync_stop.sh" --rsync-only 2>/dev/null || true
377	        log "Rsync stopped ✅"
378	    else
379	        warn "DRY RUN — would stop rsync"
380	    fi
381
382	    # ── Step 2: Reconfigure local WebUIs → localhost ──────────────────────────────────────────
383	    echo ""
384	    echo "━━━ $ICON_CONTAINERS Step 2/8 — Reconfigure Local WebUIs → localhost ━━━"
385
386	    reconfigure_local_webuis "localhost" || STEP_WEBUI_OK=false
387
388	    # ── Step 3: Remove owner-deployed containers (auth/arr stack) locally ─────────────────────
389	    echo ""
390	    echo "━━━ $ICON_CONTAINERS Step 3/8 — Remove Owner-Deployed Containers ━━━"
391
392	    if [[ "$OWNER_REACHABLE" == true ]]; then
393	        cleanup_deployed_stack_locally "$OWNER_IP" "$OWNER_SSH_KEY" || STEP_STACK_CLEANUP_OK=false
394	    else
395	        warn "Owner unreachable — cannot read deployed stack list"
396	        warn "Auth/arr containers will remain — remove manually or re-run when owner is reachable"
397	        STEP_STACK_CLEANUP_OK=false
398	    fi
399
400	    # ── Step 4: Remove fallback coverage containers ───────────────────────────────────────────
401	    echo ""
402	    echo "━━━ $ICON_CONTAINERS Step 4/8 — Fallback Container Cleanup ━━━"
403
404	    PARTNER_FOLDER_NAME=$(derive_partner_folder_name "$OWNER")
405	    cleanup_partner_containers "$PARTNER_FOLDER_NAME" || STEP_FALLBACK_CLEANUP_OK=false
406
407	    # ── Step 5: Disable critical sync ─────────────────────────────────────────────────────────
408	    echo ""
409	    echo "━━━ $ICON_GEAR Step 5/8 — Disable Critical Sync ━━━"
410
411	    if [[ "$DRY_RUN" == false ]]; then
412	        update_master_conf "CRITICAL_RSYNC_ENABLED" "false" && \
413	            warn "CRITICAL_RSYNC_ENABLED=false ✅" || \
414	            { warn "Failed to update CRITICAL_RSYNC_ENABLED"; STEP_DISABLE_RSYNC_OK=false; }
415	    else
416	        warn "DRY RUN — would set CRITICAL_RSYNC_ENABLED=false"
417	    fi
418
419	    # ── Step 6: Revoke Emby admin locally ─────────────────────────────────────────────────────
420	    revoke_local_emby_admin || STEP_EMBY_OK=false
421
422	    # ── Step 7: Restart own stack ─────────────────────────────────────────────────────────────
423	    start_own_stack
424
425	    # ── Step 8: SSH key revocation, write state, signal owner ────────────────────────────────
426	    echo ""
427	    echo "━━━ $ICON_SHIELD Step 8/8 — SSH Revocation + State ━━━"
428
429	    do_ssh_key_revocation "${OWNER_IP:-}"
430
431	    NOW=$(date '+%Y-%m-%d %H:%M:%S')
432	    if [[ "$DRY_RUN" == false ]]; then
433	        write_state_file "$LOCAL_STATE_FILE" \
434	            "INACTIVE" "" "$NOW" "$LOCAL_SERVER_NAME" "$REASON"
435	        echo "Local state: INACTIVE ✅"
436	        add_to_blocklist "$OWNER" "$REASON"
437	    else
438	        warn "DRY RUN — would write INACTIVE state and blocklist $OWNER"
439	    fi
440
441	    if [[ "$OWNER_REACHABLE" == true ]]; then
442	        push_state_to_remote "$LOCAL_STATE_FILE" "$OWNER_IP" "$OWNER_SSH_KEY"
443	        notify "Partnership offboard requested by $MIRROR — $OWNER will finalise on next check" \
444	            "Partnership" "normal"
445	    else
446	        warn "$OWNER unreachable — state written locally, owner will see it when reachable"
447	    fi
448
449	    # ── Summary ───────────────────────────────────────────────────────────────────────────────
450	    END=$(date +%s)
451	    echo ""
452	    echo "━━━━━ $ICON_SUMMARY OFFBOARD SUMMARY (Mirror) ━━━━━"
453	    echo "  Mirror:  $MY_ID ($LOCAL_SERVER_NAME)"
454	    echo "  Owner:   $OWNER_ID ($OWNER)"
455	    echo "  Reason:  $REASON"
456	    echo "  Duration: $(format_duration $(( END - START )))"
457	    echo ""
458	    _ok()   { [[ "$1" == true ]] && echo "✅" || echo "❌"; }
459	    _skip() { [[ "$1" == true ]] && echo "skipped" || echo "$(_ok "$2")"; }
460	    _revoke_status() {
461	        if   [[ "${SSH_REVOKE_REMOTE_OK:-false}" == true && "${SSH_REVOKE_LOCAL_OK:-false}" == true ]]; then
462	            echo "both directions ✅"
463	        elif [[ "${SSH_REVOKE_LOCAL_OK:-false}" == true ]]; then
464	            echo "local only ✅ — remote failed (revoke manually on $OWNER)"
465	        else
466	            echo "⚠  failed — check warnings above"
467	        fi
468	    }
469
470	    echo "  Step 1 — Stop rsync:       $(_ok "$STEP_STOP_RSYNC_OK")"
471	    echo "  Step 2 — WebUIs:           $(_ok "$STEP_WEBUI_OK")"
472	    echo "  Step 3 — Stack cleanup:    $(_ok "$STEP_STACK_CLEANUP_OK")"
473	    echo "  Step 4 — Fallback cleanup: $(_ok "$STEP_FALLBACK_CLEANUP_OK")"
474	    echo "  Step 5 — Disable sync:     $(_ok "$STEP_DISABLE_RSYNC_OK")"
475	    echo "  Step 6 — Emby revoke:      $(_ok "$STEP_EMBY_OK")"
476	    echo "  Step 7 — Own stack:        started"
477	    echo "  Step 8 — Keys revoked:     $(_revoke_status)"
478	    echo ""
479	    echo "  State:    INACTIVE ✅"
480	    echo "  Blocklist: $OWNER blocked ✅"
481	    echo "  Owner:    will finalise + final sync on next --check"
482	    echo ""
483	    [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
484	        warn "$ICON_DONE DONE — mirror separation complete ✅"
485	    echo "━━━━━━━━━━━━━━━━━━━━━━━"
486	    exit 0
487	fi
488
489	# ==============================================================================================
490	# ── OWNER PATH ────────────────────────────────────────────────────────────────────────────────
491	# ==============================================================================================
492	warn "Offboarding $MIRROR_ID ($MIRROR) from partnership"
493	warn "Final sync will run — mirror leaves with current state"
494
495	if [[ "$DRY_RUN" == false ]]; then
496	    echo ""
497	    echo "You have 10 seconds to cancel (Ctrl+C)..."
498	    sleep 10
499	    echo "Proceeding..."
500	fi
501
502	WEBUI_FAILURES=0
503	STEP_STOP_OK=true
504	STEP_SYNC_OK=true
505	SSH_REVOKE_REMOTE_OK=false
506	SSH_REVOKE_LOCAL_OK=false
507
508	# ── Step 1: Stop rsync ────────────────────────────────────────────────────────────────────────
509	echo ""
510	echo "━━━ $ICON_STOP Step 1/10 — Stop Rsync ━━━"
511
512	if [[ "$DRY_RUN" == false ]]; then
513	    bash "$SCRIPTS_ROOT/Rsync/rsync_stop.sh" --rsync-only 2>/dev/null || STEP_STOP_OK=false
514	    log "Rsync stopped ✅"
515	else
516	    warn "DRY RUN — would stop rsync"
517	fi
518
519	# ── Step 2: Final sync ────────────────────────────────────────────────────────────────────────
520	echo ""
521	echo "━━━ $ICON_SYNC Step 2/10 — Final Sync ━━━"
522
523	do_final_sync || STEP_SYNC_OK=false
524
525	# ── Step 3: Reconfigure mirror WebUIs → localhost ─────────────────────────────────────────────
526	echo ""
527	echo "━━━ $ICON_CONTAINERS Step 3/10 — Reconfigure Mirror WebUIs → localhost ━━━"
528
529	MIRROR_IP=$(resolve_tailscale_ip "$MIRROR")
530	MIRROR_REACHABLE=false
531	[[ -n "$MIRROR_IP" ]] && MIRROR_REACHABLE=true
532
533	if [[ "$MIRROR_REACHABLE" == true ]]; then
534	    for entry in "${PARTNERSHIP_AUTH_WEBUIS[@]}"; do
535	        [[ -z "$entry" ]] && continue
536	        container="${entry%%|*}"
537	        port="${entry##*|}"
538	        reconfigure_webui "$container" "$port" "localhost" \
539	            "$MIRROR_SSH_KEY" "$MIRROR_IP" "$MIRROR" || (( WEBUI_FAILURES++ ))
540	    done
541	else
542	    warn "$MIRROR unreachable — WebUI reconfiguration skipped"
543	    warn "$MIRROR will reconfigure its own WebUIs when it sees INACTIVE state on --check"
544	    (( WEBUI_FAILURES++ ))
545	fi
546
547	# ── Step 4: Disable critical sync ─────────────────────────────────────────────────────────────
548	echo ""
549	echo "━━━ $ICON_GEAR Step 4/10 — Disable Critical Sync ━━━"
550
551	if [[ "$DRY_RUN" == false ]]; then
552	    update_master_conf "CRITICAL_RSYNC_ENABLED" "false"
553	    warn "CRITICAL_RSYNC_ENABLED=false ✅"
554	else
555	    warn "DRY RUN — would set CRITICAL_RSYNC_ENABLED=false"
556	fi
557
558	# ── Step 5: Local container cleanup ───────────────────────────────────────────────────────────
559	echo ""
560	echo "━━━ $ICON_CONTAINERS Step 5/10 — Local Container Cleanup ━━━"
561
562	PARTNER_FOLDER_NAME=$(derive_partner_folder_name "$MIRROR")
563	cleanup_partner_containers "$PARTNER_FOLDER_NAME"
564
565	# ── Step 6: Restart own stack ─────────────────────────────────────────────────────────────────
566	start_own_stack
567
568	# ── Step 7: Remote container cleanup ──────────────────────────────────────────────────────────
569	echo ""
570	echo "━━━ $ICON_CONTAINERS Step 7/10 — Remote Container Cleanup ━━━"
571
572	if [[ "$MIRROR_REACHABLE" == true ]]; then
573	    # Remove auth/arr stack containers deployed during onboard (by config array)
574	    cleanup_deployed_stack_on_remote "$MIRROR_IP" "$MIRROR_SSH_KEY"
575	    # Remove fallback coverage containers (by *-owner_short naming pattern)
576	    cleanup_owner_containers_on_mirror "$MIRROR_IP"
577	    # Remove mirror's FolderView3 fallback folder (owner's containers were hosted there)
578	    if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]]; then
579	        OWNER_FOLDER_ON_MIRROR=$(derive_partner_folder_name "$OWNER")
580	        log "Removing FolderView3 folder '$OWNER_FOLDER_ON_MIRROR' from $MIRROR..."
581	        if [[ "$DRY_RUN" == false ]]; then
582	            timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
583	                -o ConnectTimeout="$SSH_TIMEOUT" root@"$MIRROR_IP" \
584	                "fv3='/boot/config/plugins/folder.view3/docker.json'
585	                 [[ -f \"\$fv3\" ]] && command -v jq >/dev/null 2>&1 && \
586	                 jq --arg n '$OWNER_FOLDER_ON_MIRROR' \
587	                    'with_entries(select(.value.name != \$n))' \
588	                    \"\$fv3\" > \"\${fv3}.tmp\" && \
589	                 mv \"\${fv3}.tmp\" \"\$fv3\" && echo removed" 2>/dev/null | \
590	                grep -q removed && \
591	                log "FolderView3 '$OWNER_FOLDER_ON_MIRROR' removed from $MIRROR ✅" || \
592	                warn "FolderView3 folder not found on $MIRROR or jq unavailable — skipping"
593	        else
594	            warn "DRY RUN — would remove FolderView3 folder '$OWNER_FOLDER_ON_MIRROR' from $MIRROR"
595	        fi
596	    fi
597	else
598	    warn "$MIRROR unreachable — remote container cleanup skipped"
599	    warn "Run 'partnership_offboard.sh' on $MIRROR to clean up manually"
600	fi
601
602	# ── Step 8: Restart mirror's own stack ────────────────────────────────────────────────────────
603	echo ""
604	echo "━━━ $ICON_START Step 8/10 — Restart Mirror Stack ━━━"
605
606	[[ "$MIRROR_REACHABLE" == true ]] && start_mirror_own_stack "$MIRROR_IP"
607
608	# ── Step 9: Revocation (Emby + SSH) ──────────────────────────────────────────────────────────
609	echo ""
610	echo "━━━ $ICON_SHIELD Step 9/10 — Revocation ━━━"
611
612	# Emby admin — before SSH key revocation while Emby still reachable
613	[[ "$MIRROR_REACHABLE" == true ]] && revoke_emby_admin "$MIRROR_IP"
614
615	# SSH key revocation — mutual, both directions; must run while Tailscale still active
616	do_ssh_key_revocation "${MIRROR_IP:-}"
617
618	# ── Step 10: Write state, push to mirror, blocklist ───────────────────────────────────────────
619	# State is written after container cleanup and SSH revocation so that:
620	#   • Re-running after a crash between steps 5–9 restarts from scratch (no early-exit on INACTIVE)
621	#   • --check sees INACTIVE during the Tailscale grace sleep and does not re-trigger offboard
622	echo ""
623	echo "━━━ $ICON_GEAR Step 10/10 — Write State ━━━"
624
625	NOW=$(date '+%Y-%m-%d %H:%M:%S')
626
627	if [[ "$DRY_RUN" == false ]]; then
628	    write_state_file "$LOCAL_STATE_FILE" \
629	        "INACTIVE" "" "$NOW" "$LOCAL_SERVER_NAME" "$REASON"
630	    log "Local state: INACTIVE ✅"
631	    add_to_blocklist "$MIRROR" "$REASON"
632	    [[ "$MIRROR_REACHABLE" == true ]] && \
633	        push_state_to_remote "$LOCAL_STATE_FILE" "$MIRROR_IP" "$MIRROR_SSH_KEY"
634	else
635	    warn "DRY RUN — would write INACTIVE state, blocklist $MIRROR, push to remote"
636	fi
637
638	# Tailscale removal — after state written so --check does not re-trigger offboard during grace sleep
639	if [[ "${PARTNERSHIP_REMOVE_TAILSCALE:-true}" == true ]]; then
640	    echo ""
641	    echo "━━━ $ICON_NET Tailscale Separation ━━━"
642	    if [[ "$MIRROR_REACHABLE" == true ]]; then
643	        grace_seconds=$(( ${PARTNERSHIP_GRACE_HOURS:-6} * 3600 ))
644	        warn "Waiting ${PARTNERSHIP_GRACE_HOURS:-6}hr grace — mirror can collect backups..."
645	        if [[ "$DRY_RUN" == false ]]; then
646	            trap 'warn "Offboard interrupted during grace sleep"; exit 0' SIGTERM SIGINT
647	            sleep "$grace_seconds"
648	            trap - SIGTERM SIGINT
649	        fi
650	    fi
651	    remove_tailscale_device "$MIRROR"
652	fi
653
654	# Backup handover notification
655	if [[ ${#PARTNERSHIP_MIRROR_BACKUPS[@]} -gt 0 ]]; then
656	    echo ""
657	    echo "━━━ $ICON_DISK Backup Handover ━━━"
658	    echo "Backups available for $MIRROR:"
659	    for path in "${PARTNERSHIP_MIRROR_BACKUPS[@]}"; do
660	        [[ -z "$path" ]] && continue
661	        echo "  $path"
662	    done
663	    notify "$MIRROR offboard complete — backups available for ${PARTNERSHIP_GRACE_HOURS:-6}hr. Tailscale access expires then." \
664	        "Partnership" "warning"
665	fi
666
667	# ── Summary ───────────────────────────────────────────────────────────────────────────────────
668	END=$(date +%s)
669	echo ""
670	echo "━━━━━ $ICON_SUMMARY OFFBOARD SUMMARY (Owner) ━━━━━"
671	echo "  Owner:    $MY_ID ($LOCAL_SERVER_NAME)"
672	echo "  Mirror:   $MIRROR_ID ($MIRROR)"
673	echo "  Reason:   $REASON"
674	echo "  Duration: $(format_duration $(( END - START )))"
675	echo ""
676	_ok()   { [[ "$1" == true ]] && echo "✅" || echo "❌"; }
677	_revoke_status() {
678	    if   [[ "${SSH_REVOKE_REMOTE_OK:-false}" == true && "${SSH_REVOKE_LOCAL_OK:-false}" == true ]]; then
679	        echo "both directions ✅"
680	    elif [[ "${SSH_REVOKE_LOCAL_OK:-false}" == true ]]; then
681	        echo "local only ✅ — remote failed (revoke manually on $MIRROR)"
682	    else
683	        echo "⚠  failed — check warnings above"
684	    fi
685	}
686
687	echo "  Step 1  — Stop rsync:     $(_ok "$STEP_STOP_OK")"
688	echo "  Step 2  — Final sync:     $(_ok "$STEP_SYNC_OK")"
689	echo "  Step 3  — WebUI failures: $WEBUI_FAILURES"
690	echo "  Step 4  — Disable sync:   ✅"
691	echo "  Step 5  — Local cleanup:  ✅"
692	echo "  Step 6  — Own stack:      started"
693	echo "  Step 7  — Remote cleanup: $( [[ "$MIRROR_REACHABLE" == true ]] && echo "✅" || echo "skipped (unreachable)" )"
694	echo "  Step 8  — Mirror stack:   $( [[ "$MIRROR_REACHABLE" == true ]] && echo "started" || echo "skipped (unreachable)" )"
695	echo "  Step 9  — Keys revoked:   $(_revoke_status)"
696	echo "  Step 10 — State:          INACTIVE ✅"
697	echo ""
698	echo "  Blocklist: $MIRROR blocked — re-onboard to permit access again ✅"
699	[[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]] && \
700	    echo "  FolderView3: ${PARTNER_FOLDER_NAME:-} (local) + mirror remote cleaned ✅"
701	[[ "${PARTNERSHIP_REMOVE_TAILSCALE:-true}" == true ]] && \
702	    echo "  Tailscale:   $MIRROR removed ✅"
703	echo ""
704	echo "  $MIRROR leaves with:"
705	echo "    ✓ Current auth config (final sync)"
706	echo "    ✓ Auth WebUIs → localhost"
707	echo "    ✓ ${PARTNERSHIP_GRACE_HOURS:-6}hr to collect backups"
708	echo ""
709	[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
710	    warn "$ICON_DONE DONE — clean separation complete ✅"
711	echo "━━━━━━━━━━━━━━━━━━━━━━━"
712	exit 0
713

#!/bin/bash
# ==============================================================================================
# ============================= Partnership Offboard ===========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Cleanly ends a partnership. Role is detected automatically — run on either server.
# Owner path runs the full sequence including remote cleanup and final sync.
# Mirror path handles the local side and signals the owner to complete its own cleanup.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# OWNER PATH (10 steps)
#   Step 1:  Stop rsync         — halt any running sync before state changes
#   Step 2:  Final sync         — mirror leaves with current Critical-Data state
#   Step 3:  Reconfigure WebUIs — mirror's auth WebUIs → localhost
#   Step 4:  Disable sync       — CRITICAL_RSYNC_ENABLED=false in master.conf
#   Step 5:  Local cleanup      — remove fallback coverage containers + appdata
#   Step 6:  Restart own stack  — bring up owner's own parked containers
#   Step 7:  Remote cleanup     — remove auth/arr stack + fallback containers from mirror
#   Step 8:  Restart mirror     — bring up mirror's own parked containers
#   Step 9:  Revocation         — Emby admin, SSH keys
#   Step 10: Write state        — INACTIVE locally + pushed to mirror, mirror blocklisted
#            Tailscale          — grace window then device removal (after state written)
#
# MIRROR PATH (8 steps)
#   Step 1: Stop rsync          — halt any running sync
#   Step 2: Reconfigure WebUIs  — local auth WebUIs → localhost
#   Step 3: Remote stack clean  — remove owner-deployed containers locally (auth/arr stack)
#   Step 4: Fallback cleanup    — remove fallback coverage containers
#   Step 5: Disable sync        — CRITICAL_RSYNC_ENABLED=false in master.conf
#   Step 6: Revoke Emby admin   — remove own admin account from local Emby instance
#   Step 7: Restart own stack   — bring up own parked containers
#   Step 8: SSH revocation      — revoke keys both directions, write state, signal owner
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
#   HOST*_PARTNERSHIP_AUTH_STACK
#       Auth container XMLs to push during onboard — used on offboard to identify what
#       to remove. Owner's PARTNERSHIP_AUTH_STACK determines which containers get removed
#       from the mirror on both owner-initiated and mirror-initiated offboard.
#
#   HOST*_PARTNERSHIP_ARR_STACK
#       Arr container XMLs — same cleanup logic as auth stack.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# Partnership/partnership_offboard.sh
#     Full offboard — role detected automatically
#
# Partnership/partnership_offboard.sh --dry-run
#     Preview all steps without executing
#
# Partnership/partnership_offboard.sh --log
#     Verbose per-step output
#
# Partnership/partnership_offboard.sh --reason=<string>
#     Tag the offboard reason in state file and blocklist (default: manual)
#     Called by partnership_manager.sh --offboard (reason passed through)
#
# ==============================================================================================

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
TEMPLATES_DIR="/boot/config/plugins/dockerMan/templates-user"
SSH_TIMEOUT=15

source "$SCRIPTS_ROOT/load_config.sh"

# ── Parse flags ───────────────────────────────────────────────────────────────────────────────
REASON="manual"
FILTERED_ARGS=()

for arg in "$@"; do
    case "$arg" in
        --reason=*) REASON="${arg#--reason=}" ;;
        *)          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

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="${STATE_DIR:-/boot/config}/partnership_${LOCAL_SERVER_NAME}.db"
REMOTE_STATE_FILE="${STATE_DIR:-/boot/config}/partnership_${REMOTE_SERVER_NAME}.db"
OWNER_STATE_FILE="${STATE_DIR:-/boot/config}/partnership_${OWNER}.db"
MIRROR_STATE_FILE="${STATE_DIR:-/boot/config}/partnership_${MIRROR}.db"
OFFLINE_COUNTER="${STATE_DIR:-/boot/config}/partnership_offline_days.db"

acquire_lock "strict"

trap _pm_trap_restart_stopped EXIT

# Check already offboarded
if [[ -f "$LOCAL_STATE_FILE" ]]; then
    CURRENT_STATE=$(read_state_file "$LOCAL_STATE_FILE" "state")
    if [[ "$CURRENT_STATE" == "INACTIVE" ]]; then
        warn "Partnership already INACTIVE — use partnership_manager.sh --status to verify both servers agree"
        exit 0
    fi
fi

START=$(date +%s)

echo ""
echo "━━━ $ICON_FALLBACK Partnership Offboard — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo ""
echo "  Role:    $( [[ "$AM_OWNER" == true ]] && echo "OWNER" || echo "MIRROR" )"
echo "  This:    $MY_ID ($LOCAL_SERVER_NAME)"
echo "  Partner: $( [[ "$AM_OWNER" == true ]] && echo "$MIRROR_ID ($MIRROR)" || echo "$OWNER_ID ($OWNER)" )"
echo "  Reason:  $REASON"
echo ""
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no permanent changes will be made"

# ==============================================================================================
# ── HELPER: remove owner-deployed containers from a remote host ───────────────────────────────
#
# Uses PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK arrays (owner's conf) to derive
# container names from local XML templates. SSHes to remote to stop, remove, and delete
# appdata. Appdata paths are collected via docker inspect before removal so they aren't
# lost once the container is gone. Safety gate: only /mnt/*/appdata* paths are deleted.
# ==============================================================================================
cleanup_deployed_stack_on_remote() {
    local remote_ip="$1" ssh_key="$2"
    local -a xml_names=()
    [[ ${#PARTNERSHIP_AUTH_STACK[@]} -gt 0 ]] && xml_names+=("${PARTNERSHIP_AUTH_STACK[@]}")
    [[ ${#PARTNERSHIP_ARR_STACK[@]}  -gt 0 ]] && xml_names+=("${PARTNERSHIP_ARR_STACK[@]}")

    if [[ ${#xml_names[@]} -eq 0 ]]; then
        log "No auth/arr stack arrays configured — skipping deployed stack cleanup"
        return 0
    fi

    log "Removing owner-deployed containers (auth/arr stacks) from $MIRROR..."
    for xml_name in "${xml_names[@]}"; do
        [[ -z "$xml_name" ]] && continue
        local xml_file="${TEMPLATES_DIR}/${xml_name}"
        if [[ ! -f "$xml_file" ]]; then
            warn "  $xml_name not found in local $TEMPLATES_DIR — skipping"
            continue
        fi

        local cname
        cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
        [[ -z "$cname" ]] && continue

        if [[ "$DRY_RUN" == true ]]; then
            warn "  DRY RUN — would stop + rm $cname on $MIRROR"
            warn "  DRY RUN — would delete appdata for $cname on $MIRROR"
            continue
        fi

        # Collect appdata paths via docker inspect before removal
        local appdata_paths
        appdata_paths=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
            "docker inspect --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' '$cname' 2>/dev/null \
             | awk -F: '{print \$1}' | grep '^/mnt/.*/appdata'" 2>/dev/null)

        timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
            "docker stop '$cname' >/dev/null 2>&1
             docker rm   '$cname' >/dev/null 2>&1 && echo removed" 2>/dev/null | \
            grep -q removed && \
            log "  $cname removed from $MIRROR ✅" || \
            log "  $cname not found on $MIRROR — skipping"

        while IFS= read -r path; do
            [[ -z "$path" ]] && continue
            timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
                -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
                "rm -rf '$path' && echo removed" 2>/dev/null | grep -q removed && \
                log "  Appdata removed on $MIRROR: $path ✅" || \
                warn "  Failed to remove appdata on $MIRROR: $path"
        done <<< "$appdata_paths"
    done
}

# ==============================================================================================
# ── HELPER: remove owner-deployed containers locally (mirror-initiated offboard) ─────────────
#
# SSHes to owner to read PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK, then uses the
# local templates-user/ copies (SCPed there during onboard) to get container names and
# appdata paths. Appdata collected before removal. Skips gracefully if owner unreachable.
# ==============================================================================================
cleanup_deployed_stack_locally() {
    local owner_ip="$1" ssh_key="$2"
    local -a xml_names=()

    if [[ -n "$owner_ip" ]]; then
        local -a auth_arr arr_arr
        mapfile -t auth_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
            "source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
             detect_hosts 2>/dev/null
             printf '%s\n' \"\${PARTNERSHIP_AUTH_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
        mapfile -t arr_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
            "source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
             detect_hosts 2>/dev/null
             printf '%s\n' \"\${PARTNERSHIP_ARR_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
        xml_names=("${auth_arr[@]}" "${arr_arr[@]}")
    fi

    if [[ ${#xml_names[@]} -eq 0 ]]; then
        log "Could not read deployed stack from owner — skipping auth/arr cleanup"
        return 0
    fi

    log "Removing owner-deployed containers (auth/arr stacks) locally..."
    for xml_name in "${xml_names[@]}"; do
        [[ -z "$xml_name" ]] && continue
        local xml_file="${TEMPLATES_DIR}/${xml_name}"
        if [[ ! -f "$xml_file" ]]; then
            warn "  $xml_name not found locally — skipping"
            continue
        fi

        local cname
        cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
        [[ -z "$cname" ]] && continue

        if [[ "$DRY_RUN" == true ]]; then
            warn "  DRY RUN — would stop + rm $cname"
            warn "  DRY RUN — would delete appdata for $cname"
            continue
        fi

        local appdata_paths=""
        if timeout "${DOCKER_TIMEOUT:-30}" docker inspect "$cname" >/dev/null 2>&1; then
            appdata_paths=$(docker inspect \
                --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' \
                "$cname" 2>/dev/null | awk -F: '{print $1}' | grep '^/mnt/.*/appdata')
            timeout "${DOCKER_TIMEOUT:-30}" docker stop "$cname" >/dev/null 2>&1 || true
            _PM_TRAP_STOPPED+=("$cname")
            timeout "${DOCKER_TIMEOUT:-30}" docker rm   "$cname" >/dev/null 2>&1 && \
                log "  $cname removed ✅" || warn "  $cname rm failed"
        else
            log "  $cname not found locally — skipping"
        fi

        while IFS= read -r path; do
            [[ -z "$path" ]] && continue
            rm -rf "$path" && log "  Appdata removed: $path ✅" || warn "  Failed to remove: $path"
        done <<< "$appdata_paths"
    done
}

# ==============================================================================================
# ── HELPER: revoke own admin account from local Emby instance ────────────────────────────────
#
# Mirror-initiated path only. Called before start_own_stack so Emby is still running.
# Uses local EMBY_API_KEY and the mirror's own short name as the username to delete.
# ==============================================================================================
revoke_local_emby_admin() {
    local emby_port="${PARTNERSHIP_EMBY_PORT:-8096}"
    local emby_url="http://127.0.0.1:${emby_port}"

    echo ""
    echo "━━━ $ICON_EMBY Emby Admin Revocation ━━━"

    if [[ "${PARTNERSHIP_PROVISION_EMBY_ADMIN:-false}" != true ]]; then
        log "PARTNERSHIP_PROVISION_EMBY_ADMIN=false — skipping"
        return 0
    fi

    if [[ -z "${EMBY_API_KEY:-}" ]]; then
        warn "EMBY_API_KEY not set — skipping local Emby admin revocation"
        return 1
    fi

    # The account to revoke is this server's own short name (the mirror user's account)
    local username="${PARTNERSHIP_EMBY_ADMIN_USER:-$(derive_short_name "$LOCAL_SERVER_NAME")}"

    if [[ "$DRY_RUN" == true ]]; then
        warn "DRY RUN — would delete Emby admin '$username' at $emby_url"
        return 0
    fi

    log "Looking up Emby user '$username' at $emby_url..."

    local users_json user_id
    users_json=$(curl -sf --max-time 15 \
        -H "X-Emby-Authorization: MediaBrowser Token=\"$EMBY_API_KEY\"" \
        "${emby_url}/Users" 2>/dev/null)

    user_id=$(echo "$users_json" | \
        grep -o "\"Id\":\"[^\"]*\"[^}]*\"Name\":\"${username}\"" | \
        grep -o '"Id":"[^"]*"' | cut -d'"' -f4 | head -1)

    if [[ -z "$user_id" ]]; then
        warn "Emby user '$username' not found at $emby_url — may already be removed"
        return 0
    fi

    local del_code
    del_code=$(curl -sf --max-time 15 -w "%{http_code}" -o /dev/null \
        -X DELETE \
        -H "X-Emby-Authorization: MediaBrowser Token=\"$EMBY_API_KEY\"" \
        "${emby_url}/Users/${user_id}" 2>/dev/null)

    if [[ "$del_code" == "200" ]] || [[ "$del_code" == "204" ]] || [[ "$del_code" == "404" ]]; then
        log "Emby admin '$username' removed ✅"
    else
        warn "Failed to delete Emby user '$username' (HTTP $del_code) — remove manually"
    fi
}

# ==============================================================================================
# ── MIRROR PATH ───────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
if [[ "$AM_MIRROR" == true ]]; then
    warn "$MIRROR_ID ($MIRROR) is initiating offboard"
    warn "Owner ($OWNER) will see INACTIVE state on its next --check cycle and finalize"

    if [[ "$DRY_RUN" == false ]]; then
        echo ""
        echo "You have 10 seconds to cancel (Ctrl+C)..."
        sleep 10
    fi

    OWNER_IP=$(resolve_tailscale_ip "$OWNER")
    OWNER_REACHABLE=false
    [[ -n "$OWNER_IP" ]] && OWNER_REACHABLE=true

    STEP_STOP_RSYNC_OK=true
    STEP_WEBUI_OK=true
    STEP_STACK_CLEANUP_OK=true
    STEP_FALLBACK_CLEANUP_OK=true
    STEP_DISABLE_RSYNC_OK=true
    STEP_EMBY_OK=true
    SSH_REVOKE_REMOTE_OK=false
    SSH_REVOKE_LOCAL_OK=false

    # ── Step 1: Stop rsync ────────────────────────────────────────────────────────────────────
    echo ""
    echo "━━━ $ICON_STOP Step 1/8 — Stop Rsync ━━━"

    if [[ "$DRY_RUN" == false ]]; then
        bash "$SCRIPTS_ROOT/Rsync/rsync_stop.sh" --rsync-only 2>/dev/null || true
        log "Rsync stopped ✅"
    else
        warn "DRY RUN — would stop rsync"
    fi

    # ── Step 2: Reconfigure local WebUIs → localhost ──────────────────────────────────────────
    echo ""
    echo "━━━ $ICON_CONTAINERS Step 2/8 — Reconfigure Local WebUIs → localhost ━━━"

    reconfigure_local_webuis "localhost" || STEP_WEBUI_OK=false

    # ── Step 3: Remove owner-deployed containers (auth/arr stack) locally ─────────────────────
    echo ""
    echo "━━━ $ICON_CONTAINERS Step 3/8 — Remove Owner-Deployed Containers ━━━"

    if [[ "$OWNER_REACHABLE" == true ]]; then
        cleanup_deployed_stack_locally "$OWNER_IP" "$OWNER_SSH_KEY" || STEP_STACK_CLEANUP_OK=false
    else
        warn "Owner unreachable — cannot read deployed stack list"
        warn "Auth/arr containers will remain — remove manually or re-run when owner is reachable"
        STEP_STACK_CLEANUP_OK=false
    fi

    # ── Step 4: Remove fallback coverage containers ───────────────────────────────────────────
    echo ""
    echo "━━━ $ICON_CONTAINERS Step 4/8 — Fallback Container Cleanup ━━━"

    PARTNER_FOLDER_NAME=$(derive_partner_folder_name "$OWNER")
    cleanup_partner_containers "$PARTNER_FOLDER_NAME" || STEP_FALLBACK_CLEANUP_OK=false

    # ── Step 5: Disable critical sync ─────────────────────────────────────────────────────────
    echo ""
    echo "━━━ $ICON_GEAR Step 5/8 — Disable Critical Sync ━━━"

    if [[ "$DRY_RUN" == false ]]; then
        update_master_conf "CRITICAL_RSYNC_ENABLED" "false" && \
            warn "CRITICAL_RSYNC_ENABLED=false ✅" || \
            { warn "Failed to update CRITICAL_RSYNC_ENABLED"; STEP_DISABLE_RSYNC_OK=false; }
    else
        warn "DRY RUN — would set CRITICAL_RSYNC_ENABLED=false"
    fi

    # ── Step 6: Revoke Emby admin locally ─────────────────────────────────────────────────────
    revoke_local_emby_admin || STEP_EMBY_OK=false

    # ── Step 7: Restart own stack ─────────────────────────────────────────────────────────────
    start_own_stack

    # ── Step 8: SSH key revocation, write state, signal owner ────────────────────────────────
    echo ""
    echo "━━━ $ICON_SHIELD Step 8/8 — SSH Revocation + State ━━━"

    do_ssh_key_revocation "${OWNER_IP:-}"

    NOW=$(date '+%Y-%m-%d %H:%M:%S')
    if [[ "$DRY_RUN" == false ]]; then
        write_state_file "$LOCAL_STATE_FILE" \
            "INACTIVE" "" "$NOW" "$LOCAL_SERVER_NAME" "$REASON"
        echo "Local state: INACTIVE ✅"
        add_to_blocklist "$OWNER" "$REASON"
    else
        warn "DRY RUN — would write INACTIVE state and blocklist $OWNER"
    fi

    if [[ "$OWNER_REACHABLE" == true ]]; then
        push_state_to_remote "$LOCAL_STATE_FILE" "$OWNER_IP" "$OWNER_SSH_KEY"
        notify "Partnership offboard requested by $MIRROR — $OWNER will finalise on next check" \
            "Partnership" "normal"
    else
        warn "$OWNER unreachable — state written locally, owner will see it when reachable"
    fi

    # ── Summary ───────────────────────────────────────────────────────────────────────────────
    END=$(date +%s)
    echo ""
    echo "━━━━━ $ICON_SUMMARY OFFBOARD SUMMARY (Mirror) ━━━━━"
    echo "  Mirror:  $MY_ID ($LOCAL_SERVER_NAME)"
    echo "  Owner:   $OWNER_ID ($OWNER)"
    echo "  Reason:  $REASON"
    echo "  Duration: $(format_duration $(( END - START )))"
    echo ""
    _ok()   { [[ "$1" == true ]] && echo "✅" || echo "❌"; }
    _skip() { [[ "$1" == true ]] && echo "skipped" || echo "$(_ok "$2")"; }
    _revoke_status() {
        if   [[ "${SSH_REVOKE_REMOTE_OK:-false}" == true && "${SSH_REVOKE_LOCAL_OK:-false}" == true ]]; then
            echo "both directions ✅"
        elif [[ "${SSH_REVOKE_LOCAL_OK:-false}" == true ]]; then
            echo "local only ✅ — remote failed (revoke manually on $OWNER)"
        else
            echo "⚠  failed — check warnings above"
        fi
    }

    echo "  Step 1 — Stop rsync:       $(_ok "$STEP_STOP_RSYNC_OK")"
    echo "  Step 2 — WebUIs:           $(_ok "$STEP_WEBUI_OK")"
    echo "  Step 3 — Stack cleanup:    $(_ok "$STEP_STACK_CLEANUP_OK")"
    echo "  Step 4 — Fallback cleanup: $(_ok "$STEP_FALLBACK_CLEANUP_OK")"
    echo "  Step 5 — Disable sync:     $(_ok "$STEP_DISABLE_RSYNC_OK")"
    echo "  Step 6 — Emby revoke:      $(_ok "$STEP_EMBY_OK")"
    echo "  Step 7 — Own stack:        started"
    echo "  Step 8 — Keys revoked:     $(_revoke_status)"
    echo ""
    echo "  State:    INACTIVE ✅"
    echo "  Blocklist: $OWNER blocked ✅"
    echo "  Owner:    will finalise + final sync on next --check"
    echo ""
    [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
        warn "$ICON_DONE DONE — mirror separation complete ✅"
    echo "━━━━━━━━━━━━━━━━━━━━━━━"
    exit 0
fi

# ==============================================================================================
# ── OWNER PATH ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
warn "Offboarding $MIRROR_ID ($MIRROR) from partnership"
warn "Final sync will run — mirror leaves with current state"

if [[ "$DRY_RUN" == false ]]; then
    echo ""
    echo "You have 10 seconds to cancel (Ctrl+C)..."
    sleep 10
    echo "Proceeding..."
fi

WEBUI_FAILURES=0
STEP_STOP_OK=true
STEP_SYNC_OK=true
SSH_REVOKE_REMOTE_OK=false
SSH_REVOKE_LOCAL_OK=false

# ── Step 1: Stop rsync ────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_STOP Step 1/10 — Stop Rsync ━━━"

if [[ "$DRY_RUN" == false ]]; then
    bash "$SCRIPTS_ROOT/Rsync/rsync_stop.sh" --rsync-only 2>/dev/null || STEP_STOP_OK=false
    log "Rsync stopped ✅"
else
    warn "DRY RUN — would stop rsync"
fi

# ── Step 2: Final sync ────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_SYNC Step 2/10 — Final Sync ━━━"

do_final_sync || STEP_SYNC_OK=false

# ── Step 3: Reconfigure mirror WebUIs → localhost ─────────────────────────────────────────────
echo ""
echo "━━━ $ICON_CONTAINERS Step 3/10 — Reconfigure Mirror WebUIs → localhost ━━━"

MIRROR_IP=$(resolve_tailscale_ip "$MIRROR")
MIRROR_REACHABLE=false
[[ -n "$MIRROR_IP" ]] && MIRROR_REACHABLE=true

if [[ "$MIRROR_REACHABLE" == true ]]; then
    for entry in "${PARTNERSHIP_AUTH_WEBUIS[@]}"; do
        [[ -z "$entry" ]] && continue
        container="${entry%%|*}"
        port="${entry##*|}"
        reconfigure_webui "$container" "$port" "localhost" \
            "$MIRROR_SSH_KEY" "$MIRROR_IP" "$MIRROR" || (( WEBUI_FAILURES++ ))
    done
else
    warn "$MIRROR unreachable — WebUI reconfiguration skipped"
    warn "$MIRROR will reconfigure its own WebUIs when it sees INACTIVE state on --check"
    (( WEBUI_FAILURES++ ))
fi

# ── Step 4: Disable critical sync ─────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_GEAR Step 4/10 — Disable Critical Sync ━━━"

if [[ "$DRY_RUN" == false ]]; then
    update_master_conf "CRITICAL_RSYNC_ENABLED" "false"
    warn "CRITICAL_RSYNC_ENABLED=false ✅"
else
    warn "DRY RUN — would set CRITICAL_RSYNC_ENABLED=false"
fi

# ── Step 5: Local container cleanup ───────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_CONTAINERS Step 5/10 — Local Container Cleanup ━━━"

PARTNER_FOLDER_NAME=$(derive_partner_folder_name "$MIRROR")
cleanup_partner_containers "$PARTNER_FOLDER_NAME"

# ── Step 6: Restart own stack ─────────────────────────────────────────────────────────────────
start_own_stack

# ── Step 7: Remote container cleanup ──────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_CONTAINERS Step 7/10 — Remote Container Cleanup ━━━"

if [[ "$MIRROR_REACHABLE" == true ]]; then
    # Remove auth/arr stack containers deployed during onboard (by config array)
    cleanup_deployed_stack_on_remote "$MIRROR_IP" "$MIRROR_SSH_KEY"
    # Remove fallback coverage containers (by *-owner_short naming pattern)
    cleanup_owner_containers_on_mirror "$MIRROR_IP"
    # Remove mirror's FolderView3 fallback folder (owner's containers were hosted there)
    if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]]; then
        OWNER_FOLDER_ON_MIRROR=$(derive_partner_folder_name "$OWNER")
        log "Removing FolderView3 folder '$OWNER_FOLDER_ON_MIRROR' from $MIRROR..."
        if [[ "$DRY_RUN" == false ]]; then
            timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
                -o ConnectTimeout="$SSH_TIMEOUT" root@"$MIRROR_IP" \
                "fv3='/boot/config/plugins/folder.view3/docker.json'
                 [[ -f \"\$fv3\" ]] && command -v jq >/dev/null 2>&1 && \
                 jq --arg n '$OWNER_FOLDER_ON_MIRROR' \
                    'with_entries(select(.value.name != \$n))' \
                    \"\$fv3\" > \"\${fv3}.tmp\" && \
                 mv \"\${fv3}.tmp\" \"\$fv3\" && echo removed" 2>/dev/null | \
                grep -q removed && \
                log "FolderView3 '$OWNER_FOLDER_ON_MIRROR' removed from $MIRROR ✅" || \
                warn "FolderView3 folder not found on $MIRROR or jq unavailable — skipping"
        else
            warn "DRY RUN — would remove FolderView3 folder '$OWNER_FOLDER_ON_MIRROR' from $MIRROR"
        fi
    fi
else
    warn "$MIRROR unreachable — remote container cleanup skipped"
    warn "Run 'partnership_offboard.sh' on $MIRROR to clean up manually"
fi

# ── Step 8: Restart mirror's own stack ────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_START Step 8/10 — Restart Mirror Stack ━━━"

[[ "$MIRROR_REACHABLE" == true ]] && start_mirror_own_stack "$MIRROR_IP"

# ── Step 9: Revocation (Emby + SSH) ──────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_SHIELD Step 9/10 — Revocation ━━━"

# Emby admin — before SSH key revocation while Emby still reachable
[[ "$MIRROR_REACHABLE" == true ]] && revoke_emby_admin "$MIRROR_IP"

# SSH key revocation — mutual, both directions; must run while Tailscale still active
do_ssh_key_revocation "${MIRROR_IP:-}"

# ── Step 10: Write state, push to mirror, blocklist ───────────────────────────────────────────
# State is written after container cleanup and SSH revocation so that:
#   • Re-running after a crash between steps 5–9 restarts from scratch (no early-exit on INACTIVE)
#   • --check sees INACTIVE during the Tailscale grace sleep and does not re-trigger offboard
echo ""
echo "━━━ $ICON_GEAR Step 10/10 — Write State ━━━"

NOW=$(date '+%Y-%m-%d %H:%M:%S')

if [[ "$DRY_RUN" == false ]]; then
    write_state_file "$LOCAL_STATE_FILE" \
        "INACTIVE" "" "$NOW" "$LOCAL_SERVER_NAME" "$REASON"
    log "Local state: INACTIVE ✅"
    add_to_blocklist "$MIRROR" "$REASON"
    [[ "$MIRROR_REACHABLE" == true ]] && \
        push_state_to_remote "$LOCAL_STATE_FILE" "$MIRROR_IP" "$MIRROR_SSH_KEY"
else
    warn "DRY RUN — would write INACTIVE state, blocklist $MIRROR, push to remote"
fi

# Tailscale removal — after state written so --check does not re-trigger offboard during grace sleep
if [[ "${PARTNERSHIP_REMOVE_TAILSCALE:-true}" == true ]]; then
    echo ""
    echo "━━━ $ICON_NET Tailscale Separation ━━━"
    if [[ "$MIRROR_REACHABLE" == true ]]; then
        grace_seconds=$(( ${PARTNERSHIP_GRACE_HOURS:-6} * 3600 ))
        warn "Waiting ${PARTNERSHIP_GRACE_HOURS:-6}hr grace — mirror can collect backups..."
        if [[ "$DRY_RUN" == false ]]; then
            trap 'warn "Offboard interrupted during grace sleep"; exit 0' SIGTERM SIGINT
            sleep "$grace_seconds"
            trap - SIGTERM SIGINT
        fi
    fi
    remove_tailscale_device "$MIRROR"
fi

# Backup handover notification
if [[ ${#PARTNERSHIP_MIRROR_BACKUPS[@]} -gt 0 ]]; then
    echo ""
    echo "━━━ $ICON_DISK Backup Handover ━━━"
    echo "Backups available for $MIRROR:"
    for path in "${PARTNERSHIP_MIRROR_BACKUPS[@]}"; do
        [[ -z "$path" ]] && continue
        echo "  $path"
    done
    notify "$MIRROR offboard complete — backups available for ${PARTNERSHIP_GRACE_HOURS:-6}hr. Tailscale access expires then." \
        "Partnership" "warning"
fi

# ── Summary ───────────────────────────────────────────────────────────────────────────────────
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY OFFBOARD SUMMARY (Owner) ━━━━━"
echo "  Owner:    $MY_ID ($LOCAL_SERVER_NAME)"
echo "  Mirror:   $MIRROR_ID ($MIRROR)"
echo "  Reason:   $REASON"
echo "  Duration: $(format_duration $(( END - START )))"
echo ""
_ok()   { [[ "$1" == true ]] && echo "✅" || echo "❌"; }
_revoke_status() {
    if   [[ "${SSH_REVOKE_REMOTE_OK:-false}" == true && "${SSH_REVOKE_LOCAL_OK:-false}" == true ]]; then
        echo "both directions ✅"
    elif [[ "${SSH_REVOKE_LOCAL_OK:-false}" == true ]]; then
        echo "local only ✅ — remote failed (revoke manually on $MIRROR)"
    else
        echo "⚠  failed — check warnings above"
    fi
}

echo "  Step 1  — Stop rsync:     $(_ok "$STEP_STOP_OK")"
echo "  Step 2  — Final sync:     $(_ok "$STEP_SYNC_OK")"
echo "  Step 3  — WebUI failures: $WEBUI_FAILURES"
echo "  Step 4  — Disable sync:   ✅"
echo "  Step 5  — Local cleanup:  ✅"
echo "  Step 6  — Own stack:      started"
echo "  Step 7  — Remote cleanup: $( [[ "$MIRROR_REACHABLE" == true ]] && echo "✅" || echo "skipped (unreachable)" )"
echo "  Step 8  — Mirror stack:   $( [[ "$MIRROR_REACHABLE" == true ]] && echo "started" || echo "skipped (unreachable)" )"
echo "  Step 9  — Keys revoked:   $(_revoke_status)"
echo "  Step 10 — State:          INACTIVE ✅"
echo ""
echo "  Blocklist: $MIRROR blocked — re-onboard to permit access again ✅"
[[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]] && \
    echo "  FolderView3: ${PARTNER_FOLDER_NAME:-} (local) + mirror remote cleaned ✅"
[[ "${PARTNERSHIP_REMOVE_TAILSCALE:-true}" == true ]] && \
    echo "  Tailscale:   $MIRROR removed ✅"
echo ""
echo "  $MIRROR leaves with:"
echo "    ✓ Current auth config (final sync)"
echo "    ✓ Auth WebUIs → localhost"
echo "    ✓ ${PARTNERSHIP_GRACE_HOURS:-6}hr to collect backups"
echo ""
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
    warn "$ICON_DONE DONE — clean separation complete ✅"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0


Now I have the full picture. Let me check if `Plugin/unraid/Partnership/` already exists and plan the extract.


Bash

Check Plugin/unraid directory structure


System_Essentials/
Tools/
Varaverk.page
VaraverkSettings.page
Watchdogs/
adapter.sh*
api/
css/
event/
icons/
include/
js/
pages/
run_job.sh*
user_script_plug-in.sh*


Clear picture now. Here's the split:

**What moves to `Plugin/unraid/Partnership/containers.sh`:**
- `TEMPLATES_DIR` (Unraid's dockerMan path)
- `deploy_container_from_xml()` — XML template parsing is Unraid CA format
- `deploy_xml_stack()` + the `_STACK_DEPLOYED/FAILED` globals
- `wait_for_container_healthy()` — called by deploy_xml_stack, moves with it
- Step 2 plugin install → `platform_install_plugins()`
- `cleanup_deployed_stack_on_remote()` and `cleanup_deployed_stack_locally()` — both reference TEMPLATES_DIR
- FolderView3 folder removal → `platform_remove_folderview3_on_remote()`

**What stays in main scripts:**
- All orchestration, step flow, SSH, conf push, arr sync
- `stop_mirror_stack()` — docker over SSH, generic enough

Both scripts `source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"` after `load_config.sh`. Functions use caller scope (DRY_RUN, MIRROR, MIRROR_IP, etc.) since sourced.


Create Plugin/unraid/Partnership directory


(Bash completed with no output)


Write

#!/bin/bash
# ==============================================================================================
# ====================== Partnership — Unraid Container Adapter ================================
# ==============================================================================================
#
# Sourced by partnership_onboard.sh and partnership_offboard.sh via:
#   source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"
#
# Provides container deploy/cleanup functions specific to the Unraid platform:
#   - Docker container deployment from Unraid CA XML templates
#   - Community Applications plugin install via `plugin install`
#   - FolderView3 docker.json manipulation
#
# Functions use variables from the calling script's scope (sourced, not exec'd):
#   MIRROR, MIRROR_IP, MIRROR_SSH_KEY, SSH_TIMEOUT, DRY_RUN, SCRIPTS_ROOT
#
# ==============================================================================================

TEMPLATES_DIR="/boot/config/plugins/dockerMan/templates-user"

_STACK_DEPLOYED=0
_STACK_FAILED=0

# ==============================================================================================
# ── Install Unraid plugins on the mirror ──────────────────────────────────────────────────────
#
# Installs FolderView3 via `plugin install` — Unraid Community Applications command.
# Checks if already installed before attempting. Returns 1 if install fails.
# ==============================================================================================
platform_install_plugins() {
    local remote_ip="$1" ssh_key="$2"

    if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" != true ]] || [[ -z "${PARTNERSHIP_FOLDERVIEW3_URL:-}" ]]; then
        log "FolderView3 not configured — skipping"
        return 0
    fi

    local fv3_present
    fv3_present=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
        -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
        "test -d /usr/local/emhttp/plugins/folder.view3 && echo yes" 2>/dev/null)

    if [[ "$fv3_present" == "yes" ]]; then
        log "FolderView3 already installed on $MIRROR ✅"
        return 0
    fi

    if [[ "$DRY_RUN" == true ]]; then
        warn "DRY RUN — would install FolderView3 on $MIRROR"
        return 0
    fi

    log "Installing FolderView3 on $MIRROR..."
    if timeout 60 ssh -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
        "plugin install '$PARTNERSHIP_FOLDERVIEW3_URL' 2>/dev/null && echo installed" \
        2>/dev/null | grep -q installed; then
        log "FolderView3 installed ✅"
        return 0
    else
        warn "FolderView3 install failed — install manually from Community Applications"
        return 1
    fi
}

# ==============================================================================================
# ── Wait for a container on the remote to be healthy/running ─────────────────────────────────
#
# Polls docker inspect on the remote. Prefers the health status if a healthcheck is defined;
# falls back to the running state. Non-fatal after timeout — some containers take time to
# fully initialize but the deploy itself succeeded.
# ==============================================================================================
wait_for_container_healthy() {
    local name="$1" remote_ip="$2" ssh_key="$3"
    local max_wait=60 interval=5 elapsed=0

    [[ "$DRY_RUN" == true ]] && return 0

    log "  Waiting for $name to be ready..."
    while (( elapsed < max_wait )); do
        local status
        status=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
            "h=\$(docker inspect --format '{{.State.Health.Status}}' '$name' 2>/dev/null)
             r=\$(docker inspect --format '{{.State.Running}}' '$name' 2>/dev/null)
             echo \${h:-\$r}" 2>/dev/null)

        case "$status" in
            healthy|true)
                log "  $name ready ✅"
                return 0
                ;;
            *)
                sleep "$interval"
                (( elapsed += interval ))
                ;;
        esac
    done

    warn "  $name not confirmed healthy after ${max_wait}s — continuing (may affect dependents)"
    return 0
}

# ==============================================================================================
# ── Deploy a container from a local Unraid CA XML template to a remote host ──────────────────
#
# Parses Port / Path / Variable Config entries from the Unraid XML, SCPs the template and a
# self-contained deploy script to the remote, executes it, then cleans up both sides.
# Credentials are never passed as SSH command-line args — they stay in the SCPed script.
# ==============================================================================================
deploy_container_from_xml() {
    local xml_file="$1" remote_ip="$2" ssh_key="$3"
    local xml_name
    xml_name=$(basename "$xml_file")

    local name repo network extra privileged
    name=$(       awk 'match($0,/<Name>([^<]+)<\/Name>/,        a){print a[1];exit}' "$xml_file")
    repo=$(       awk 'match($0,/<Repository>([^<]+)<\/Repository>/,a){print a[1];exit}' "$xml_file")
    network=$(    awk 'match($0,/<Network>([^<]+)<\/Network>/,   a){print a[1];exit}' "$xml_file")
    extra=$(      awk 'match($0,/<ExtraParams>([^<]*)<\/ExtraParams>/,a){print a[1];exit}' "$xml_file")
    privileged=$( awk 'match($0,/<Privileged>([^<]+)<\/Privileged>/,a){print a[1];exit}' "$xml_file")

    if [[ -z "$name" || -z "$repo" ]]; then
        warn "  Cannot parse Name/Repository from $xml_name — skipping"
        return 1
    fi

    log "Deploying $name..."

    if [[ "$DRY_RUN" == false ]]; then
        timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
            "$xml_file" "root@${remote_ip}:${TEMPLATES_DIR}/${xml_name}" 2>/dev/null || {
            warn "  SCP failed for $xml_name — skipping $name"
            return 1
        }
    else
        warn "  DRY RUN — would SCP $xml_name → $MIRROR:${TEMPLATES_DIR}/"
    fi

    local tmp_script
    tmp_script=$(mktemp /tmp/deploy_XXXXXX.sh)
    chmod 600 "$tmp_script"

    {
        echo "#!/bin/bash"
        echo "set -e"
        echo ""
        printf "docker pull %q 2>/dev/null || true\n" "$repo"
        printf "docker stop %q 2>/dev/null || true\n" "$name"
        printf "docker rm   %q 2>/dev/null || true\n" "$name"
        echo ""
        printf "docker create --name %q --restart=unless-stopped" "$name"
        [[ -n "$network"           ]] && printf " --network=%q"  "$network"
        [[ "$privileged" == "true" ]] && printf " --privileged"
        [[ -n "$extra"             ]] && printf " %s" "$extra"

        # Port mappings → -p host:container/proto
        awk '/Type="Port"/ {
            match($0, /Target="([^"]+)"/, t)
            match($0, /Mode="([^"]+)"/,   m)
            match($0, />([^<]+)<\/Config>/, v)
            if (t[1] != "" && v[1] != "") {
                proto = (m[1] == "udp") ? "udp" : "tcp"
                printf " -p %s:%s/%s", v[1], t[1], proto
            }
        }' "$xml_file"

        # Volume mappings → -v 'host:container:mode'
        awk 'BEGIN{q=sprintf("%c",39)} /Type="Path"/ {
            match($0, /Target="([^"]+)"/, t)
            match($0, /Mode="([^"]+)"/,   m)
            match($0, />([^<]+)<\/Config>/, v)
            if (t[1] != "" && v[1] != "") {
                mode = (m[1] == "ro") ? "ro" : "rw"
                printf " -v %s%s:%s:%s%s", q, v[1], t[1], mode, q
            }
        }' "$xml_file"

        # Environment variables → -e 'KEY=VALUE'  (single-quoted to protect $ and special chars)
        awk 'BEGIN{q=sprintf("%c",39)} /Type="Variable"/ {
            match($0, /Target="([^"]+)"/, t)
            match($0, />([^<]+)<\/Config>/, v)
            if (t[1] != "" && v[1] != "") {
                printf " -e %s%s=%s%s", q, t[1], v[1], q
            }
        }' "$xml_file"

        printf " %q\n" "$repo"
        echo ""
        printf "docker start %q && echo 'deployed:%s'\n" "$name" "$name"
    } > "$tmp_script"

    if [[ "$DRY_RUN" == true ]]; then
        warn "  DRY RUN — would deploy $name on $MIRROR"
        rm -f "$tmp_script"
        return 0
    fi

    local remote_script="/tmp/deploy_${name//[^a-zA-Z0-9_]/_}.sh"

    if timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
        "$tmp_script" "root@${remote_ip}:${remote_script}" 2>/dev/null && \
       timeout 120 ssh -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
        "bash '$remote_script' 2>&1; rc=\$?; rm -f '$remote_script'; exit \$rc" 2>/dev/null | \
        grep -q "deployed:${name}"; then
        log "  $name deployed ✅"
        rm -f "$tmp_script"
        return 0
    else
        warn "  $name deployment failed — check $MIRROR manually"
        rm -f "$tmp_script"
        return 1
    fi
}

# ==============================================================================================
# ── Deploy a stack of Unraid CA XMLs to the mirror ───────────────────────────────────────────
#
# Sets globals _STACK_DEPLOYED and _STACK_FAILED rather than printing to stdout.
# Health-checks database deps (Mariadb/Redis/Postgres) between batches so dependents
# (e.g. Authelia) start cleanly.
# ==============================================================================================
deploy_xml_stack() {
    local -n xml_array_ref="$1"
    _STACK_DEPLOYED=0
    _STACK_FAILED=0

    for xml_name in "${xml_array_ref[@]}"; do
        local xml_file="${TEMPLATES_DIR}/${xml_name}"
        if [[ ! -f "$xml_file" ]]; then
            warn "$xml_name not found in $TEMPLATES_DIR — skipping"
            (( _STACK_FAILED++ ))
            continue
        fi

        local cname
        cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")

        if deploy_container_from_xml "$xml_file" "$MIRROR_IP" "$MIRROR_SSH_KEY"; then
            (( _STACK_DEPLOYED++ ))
            if [[ -n "$cname" ]] && echo "$cname" | grep -qiE 'mariadb|redis|postgres|mysql'; then
                wait_for_container_healthy "$cname" "$MIRROR_IP" "$MIRROR_SSH_KEY"
            fi
        else
            (( _STACK_FAILED++ ))
        fi
    done
}

# ==============================================================================================
# ── Remove owner-deployed containers from a remote host ──────────────────────────────────────
#
# Uses PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK (owner's conf) to derive container
# names from local XML templates. SSHes to remote to stop, remove, and delete appdata.
# Appdata paths collected via docker inspect before removal. Safety gate: only
# /mnt/*/appdata* paths are deleted.
# ==============================================================================================
cleanup_deployed_stack_on_remote() {
    local remote_ip="$1" ssh_key="$2"
    local -a xml_names=()
    [[ ${#PARTNERSHIP_AUTH_STACK[@]} -gt 0 ]] && xml_names+=("${PARTNERSHIP_AUTH_STACK[@]}")
    [[ ${#PARTNERSHIP_ARR_STACK[@]}  -gt 0 ]] && xml_names+=("${PARTNERSHIP_ARR_STACK[@]}")

    if [[ ${#xml_names[@]} -eq 0 ]]; then
        log "No auth/arr stack arrays configured — skipping deployed stack cleanup"
        return 0
    fi

    log "Removing owner-deployed containers (auth/arr stacks) from $MIRROR..."
    for xml_name in "${xml_names[@]}"; do
        [[ -z "$xml_name" ]] && continue
        local xml_file="${TEMPLATES_DIR}/${xml_name}"
        if [[ ! -f "$xml_file" ]]; then
            warn "  $xml_name not found in local $TEMPLATES_DIR — skipping"
            continue
        fi

        local cname
        cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
        [[ -z "$cname" ]] && continue

        if [[ "$DRY_RUN" == true ]]; then
            warn "  DRY RUN — would stop + rm $cname on $MIRROR"
            warn "  DRY RUN — would delete appdata for $cname on $MIRROR"
            continue
        fi

        local appdata_paths
        appdata_paths=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
            "docker inspect --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' '$cname' 2>/dev/null \
             | awk -F: '{print \$1}' | grep '^/mnt/.*/appdata'" 2>/dev/null)

        timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
            "docker stop '$cname' >/dev/null 2>&1
             docker rm   '$cname' >/dev/null 2>&1 && echo removed" 2>/dev/null | \
            grep -q removed && \
            log "  $cname removed from $MIRROR ✅" || \
            log "  $cname not found on $MIRROR — skipping"

        while IFS= read -r path; do
            [[ -z "$path" ]] && continue
            timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
                -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
                "rm -rf '$path' && echo removed" 2>/dev/null | grep -q removed && \
                log "  Appdata removed on $MIRROR: $path ✅" || \
                warn "  Failed to remove appdata on $MIRROR: $path"
        done <<< "$appdata_paths"
    done
}

# ==============================================================================================
# ── Remove owner-deployed containers locally (mirror-initiated offboard) ─────────────────────
#
# SSHes to owner to read PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK, then uses the
# local templates-user/ copies (SCPed there during onboard) to get container names and
# appdata paths. Appdata collected before removal. Skips gracefully if owner unreachable.
# ==============================================================================================
cleanup_deployed_stack_locally() {
    local owner_ip="$1" ssh_key="$2"
    local -a xml_names=()

    if [[ -n "$owner_ip" ]]; then
        local -a auth_arr arr_arr
        mapfile -t auth_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
            "source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
             detect_hosts 2>/dev/null
             printf '%s\n' \"\${PARTNERSHIP_AUTH_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
        mapfile -t arr_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
            "source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
             detect_hosts 2>/dev/null
             printf '%s\n' \"\${PARTNERSHIP_ARR_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
        xml_names=("${auth_arr[@]}" "${arr_arr[@]}")
    fi

    if [[ ${#xml_names[@]} -eq 0 ]]; then
        log "Could not read deployed stack from owner — skipping auth/arr cleanup"
        return 0
    fi

    log "Removing owner-deployed containers (auth/arr stacks) locally..."
    for xml_name in "${xml_names[@]}"; do
        [[ -z "$xml_name" ]] && continue
        local xml_file="${TEMPLATES_DIR}/${xml_name}"
        if [[ ! -f "$xml_file" ]]; then
            warn "  $xml_name not found locally — skipping"
            continue
        fi

        local cname
        cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
        [[ -z "$cname" ]] && continue

        if [[ "$DRY_RUN" == true ]]; then
            warn "  DRY RUN — would stop + rm $cname"
            warn "  DRY RUN — would delete appdata for $cname"
            continue
        fi

        local appdata_paths=""
        if timeout "${DOCKER_TIMEOUT:-30}" docker inspect "$cname" >/dev/null 2>&1; then
            appdata_paths=$(docker inspect \
                --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' \
                "$cname" 2>/dev/null | awk -F: '{print $1}' | grep '^/mnt/.*/appdata')
            timeout "${DOCKER_TIMEOUT:-30}" docker stop "$cname" >/dev/null 2>&1 || true
            _PM_TRAP_STOPPED+=("$cname")
            timeout "${DOCKER_TIMEOUT:-30}" docker rm   "$cname" >/dev/null 2>&1 && \
                log "  $cname removed ✅" || warn "  $cname rm failed"
        else
            log "  $cname not found locally — skipping"
        fi

        while IFS= read -r path; do
            [[ -z "$path" ]] && continue
            rm -rf "$path" && log "  Appdata removed: $path ✅" || warn "  Failed to remove: $path"
        done <<< "$appdata_paths"
    done
}

# ==============================================================================================
# ── Remove a FolderView3 folder from a remote host ───────────────────────────────────────────
#
# Uses jq on the remote to delete the named folder from docker.json.
# No-op if PARTNERSHIP_FOLDERVIEW3 is not enabled or jq is unavailable on remote.
# ==============================================================================================
platform_remove_folderview3_on_remote() {
    local remote_ip="$1" ssh_key="$2" folder_name="$3" server_label="${4:-remote}"

    [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" != true ]] && return 0

    log "Removing FolderView3 folder '$folder_name' from $server_label..."
    if [[ "$DRY_RUN" == false ]]; then
        timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
            "fv3='/boot/config/plugins/folder.view3/docker.json'
             [[ -f \"\$fv3\" ]] && command -v jq >/dev/null 2>&1 && \
             jq --arg n '$folder_name' \
                'with_entries(select(.value.name != \$n))' \
                \"\$fv3\" > \"\${fv3}.tmp\" && \
             mv \"\${fv3}.tmp\" \"\$fv3\" && echo removed" 2>/dev/null | \
            grep -q removed && \
            log "FolderView3 '$folder_name' removed from $server_label ✅" || \
            warn "FolderView3 folder not found on $server_label or jq unavailable — skipping"
    else
        warn "DRY RUN — would remove FolderView3 folder '$folder_name' from $server_label"
    fi
}