#!/bin/bash # Move an installed app's data to a different storage location. # # The ordering here is the whole design. Data must be quiescent before it is # copied (a live copy of a running Postgres is a corrupt copy), the source must # survive until the destination is verified, and the config must not claim the # new location until the data is actually there. # # 1. resolve + refuse the obvious (same location, not installed, unavailable) # 2. space check with headroom # 3. compose down # 4. pre-move snapshot (default on) # 5. copy as root — app data holds rootless sub-UID files the manager can # neither read nor recreate # 6. verify, THEN remove the source. Never mv semantics that can half-delete # 7. repoint config + index, bring back up, health-check # # Rollback: before step 6 the source is untouched, so failure just restarts the # app where it was. After step 6 the destination is complete, so failure leaves # it there and the config is repointed anyway. storageMoveApp() { local app="$1" local want="$2" local opts="${3:-}" if [[ -z "$app" || -z "$want" ]]; then isError "Usage: storageMoveApp [--no-pre-backup]" return 1 fi isHeader "Move $app to storage location '$want'" # ---- 1. resolve --------------------------------------------------------- local src_dir dest_root dest_dir if ! src_dir=$(appDir "$app"); then isError "$app is on a storage location that is not mounted — mount it first." return 1 fi if [[ ! -d "$src_dir" ]]; then isError "$app is not installed ($src_dir does not exist)." return 1 fi if ! dest_root=$(storageLocationPath "$want"); then isError "No such storage location: $want" isNotice "Known locations: $(storageList 2>/dev/null | awk 'NR>2{print $2}' | paste -sd, -)" return 1 fi if ! storageRootAvailable "$dest_root"; then isError "Storage location '$want' ($dest_root) is not mounted." return 1 fi dest_dir="${dest_root%/}/$app" if [[ "${src_dir%/}" == "${dest_dir%/}" ]]; then isNotice "$app is already on '$want' — nothing to do." return 0 fi if [[ -e "$dest_dir" ]]; then isError "Refusing to move — '$dest_dir' already exists." return 1 fi # A pinned app must not move: other apps reach it by literal path. local pin_key="CFG_${app^^}_STORAGE" if runCfgOp grep -qE "^${pin_key}=.*\*\*READONLY\*\*" "$src_dir/$app.config" 2>/dev/null; then isError "$app is pinned to the primary location — other apps reference it by path." return 1 fi # ---- 2. space, with headroom ------------------------------------------- local need_kb avail_kb need_kb=$(runFileOp du -sk "$src_dir" 2>/dev/null | awk '{print $1}') avail_kb=$(df -Pk "$dest_root" 2>/dev/null | awk 'NR==2 {print $4}') if [[ -n "$need_kb" && -n "$avail_kb" ]]; then local need_hdr=$(( need_kb + need_kb / 10 )) if (( avail_kb < need_hdr )); then isError "Not enough space on '$want': need ~$((need_hdr / 1024)) MiB (10% headroom), have $((avail_kb / 1024)) MiB." return 1 fi isNotice "Moving $((need_kb / 1024)) MiB; $((avail_kb / 1024)) MiB free at the destination." if (( need_kb > 5242880 )); then isNotice "This is a large app — the copy can take a while and $app stays down until it finishes." fi fi # ---- 3. stop ------------------------------------------------------------ isNotice "Stopping $app — data must be quiescent before it is copied." dockerComposeDown "$app" || true # ---- 4. pre-move snapshot ---------------------------------------------- if [[ "$opts" != *--no-pre-backup* ]] && declare -f migratePreBackupDestination >/dev/null 2>&1; then local bidx bidx=$(resticEnabledLocations 2>/dev/null | head -1) if [[ -n "$bidx" ]]; then isNotice "Taking a safety snapshot before moving." migratePreBackupDestination "$app" "$bidx" || \ isNotice "Pre-move snapshot failed — continuing (the source is not deleted until the copy verifies)." else isNotice "No backup location enabled — skipping the safety snapshot." fi fi # ---- 5/6. copy, verify, then remove the source -------------------------- isNotice "Copying $src_dir -> $dest_dir" if ! runOwnership app-move "$app" "$dest_root"; then isError "Copy failed — $app has not been moved. Restarting it where it was." dockerComposeUp "$app" || true return 1 fi # ---- 7. repoint and restart -------------------------------------------- updateConfigOption "$pin_key" "$(storageLocationName "$dest_root")" "$dest_dir/$app.config" >/dev/null 2>&1 || true storageIndexSet "$app" "$dest_root" storageCacheReset local now now=$(appDir "$app") if [[ "${now%/}" != "${dest_dir%/}" ]]; then isError "After the move $app resolves to '$now', not '$dest_dir'. Not starting it — investigate before running anything else." return 1 fi declare -f storageSyncAppComment >/dev/null 2>&1 && storageSyncAppComment "$app" isNotice "Starting $app on its new location." dockerComposeUpdateAndStartApp "$app" || true dockerComposeUp "$app" || true isSuccessful "$app now lives at $dest_dir" return 0 }