#!/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 # # 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 # ============================================================================================== # ── 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>/, a){print a[1];exit}' "$xml_file") repo=$( awk 'match($0,/([^<]+)<\/Repository>/,a){print a[1];exit}' "$xml_file") network=$( awk 'match($0,/([^<]+)<\/Network>/, a){print a[1];exit}' "$xml_file") extra=$( awk 'match($0,/([^<]*)<\/ExtraParams>/,a){print a[1];exit}' "$xml_file") privileged=$( awk 'match($0,/([^<]+)<\/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>/,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[@]}") [[ ${#PARTNERSHIP_SERVICES_STACK[@]} -gt 0 ]] && xml_names+=("${PARTNERSHIP_SERVICES_STACK[@]}") if [[ ${#xml_names[@]} -eq 0 ]]; then log "No auth/arr/services stack arrays configured — skipping deployed stack cleanup" return 0 fi log "Removing owner-deployed containers (auth/arr/services 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>/,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 '^$') local -a svc_arr mapfile -t svc_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_SERVICES_STACK[@]:-}\"" 2>/dev/null | grep -v '^$') xml_names=("${auth_arr[@]}" "${arr_arr[@]}" "${svc_arr[@]}") fi if [[ ${#xml_names[@]} -eq 0 ]]; then log "Could not read deployed stack from owner — skipping auth/arr/services cleanup" return 0 fi log "Removing owner-deployed containers (auth/arr/services 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>/,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 }