From e7659926db5e1810efdf24c12dcc286726f64c7b Mon Sep 17 00:00:00 2001 From: librelad Date: Sun, 24 May 2026 13:26:24 +0100 Subject: [PATCH] feat(switcher): hands-off data migration across a docker mode switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching rooted<->rootless re-maps every container's on-disk UIDs (rootless offsets them by the subuid base), so a stateful app's data no longer lines up in the new mode and a chown can't carry it. The portable carry is backup (old mode) -> switch -> restore (new mode): restoreAppStart wipes and re-lays each tree and re-owns it to the new mode's install user, which is exactly the remap needed. Wire that into dockerSwitcherSwap: - switchMigrateBackupApps : before the switch, back up every installed app except libreportal (reconcile already carries the control plane). CFG_DOCKER_INSTALL_TYPE is already the target mode by the time the switcher runs, so force it (and the resolved install user) back to the old mode for the backups, else backupAppStart would talk to the not-yet-running new daemon. Any backup failure aborts the switch before the daemon is touched (nothing changed). No backup location enabled -> skip and keep the manual warning. - switchMigrateRestoreApps: after the new daemon is up, restore each captured app best-effort, re-resolving the install user first so data is owned correctly; failures are reported per app rather than blocking. Subject to the existing backup-completeness limitation (restic-as-libreportal can't read files owned by other UIDs unless the app declares container-side file capture) — same caveat as the manual procedure this automates. Co-Authored-By: Claude Opus 4.7 Signed-off-by: librelad --- .../docker/type_switcher/swap_docker_type.sh | 111 +++++++++++++++++- 1 file changed, 105 insertions(+), 6 deletions(-) diff --git a/scripts/docker/type_switcher/swap_docker_type.sh b/scripts/docker/type_switcher/swap_docker_type.sh index 5ad0410..ccfcf9f 100755 --- a/scripts/docker/type_switcher/swap_docker_type.sh +++ b/scripts/docker/type_switcher/swap_docker_type.sh @@ -24,13 +24,17 @@ dockerSwitcherSwap() isHeader "Docker Root/Rootless Switcher" # Switching modes re-maps container UIDs (rootless shifts them by the - # subuid base), so a stateful app's existing data won't line up in the - # new mode. LibrePortal's own control plane is reconciled automatically - # (reconcileDockerOwnership), but app data is not touched — the safe way - # to carry stateful apps across is backup-before / restore-after. + # subuid base), so a stateful app's on-disk data won't line up in the + # new mode and a chown can't carry it. LibrePortal's control plane is + # reconciled automatically (reconcileDockerOwnership); third-party app + # data is carried by an automatic backup-before / restore-after when a + # backup location is enabled. isNotice "Switching Docker mode re-maps container ownership." - isNotice "Back up any stateful apps (databases etc.) BEFORE switching and restore them AFTER." - isNotice "App data is intentionally left untouched here." + if [[ -n "$(resticEnabledLocations)" ]]; then + isNotice "Stateful apps will be backed up now and restored automatically after the switch." + else + isNotice "No backup location enabled — back up stateful apps by hand BEFORE switching and restore AFTER." + fi if [[ $CFG_DOCKER_INSTALL_TYPE == "rooted" ]]; then if [[ $flag != "cli" ]]; then @@ -50,6 +54,11 @@ dockerSwitcherSwap() fi if [[ "$switch_rooted_choice" == [yY] ]]; then isNotice "Switching to the Rooted Docker now..." + # Back up every app under the old (still-running) mode first; a + # failure aborts the switch before anything changes. + if ! switchMigrateBackupApps "$docker_type"; then + return 1 + fi # Looking at the Rootless Install if [[ $docker_rootless_found == "true" ]]; then dockerComposeDownAllApps rootless; @@ -65,6 +74,7 @@ dockerSwitcherSwap() reconcileDockerOwnership "$CFG_DOCKER_INSTALL_TYPE"; dockerStartAllApps; databaseOptionInsert "docker_type" $CFG_DOCKER_INSTALL_TYPE; + switchMigrateRestoreApps; fi fi fi @@ -87,6 +97,11 @@ dockerSwitcherSwap() fi if [[ "$switch_rootless_choice" == [yY] ]]; then isNotice "Switching to the Rootless Docker now..." + # Back up every app under the old (still-running) mode first; a + # failure aborts the switch before anything changes. + if ! switchMigrateBackupApps "$docker_type"; then + return 1 + fi # Looking at the Rooted Install if [[ $docker_rooted_found == "true" ]]; then dockerComposeDownAllApps rooted; @@ -97,9 +112,93 @@ dockerSwitcherSwap() reconcileDockerOwnership "$CFG_DOCKER_INSTALL_TYPE"; dockerStartAllApps; databaseOptionInsert "docker_type" $CFG_DOCKER_INSTALL_TYPE; + switchMigrateRestoreApps; fi fi elif [[ $flag == "cli" ]]; then isSuccessful "Docker type is already setup for "$CFG_DOCKER_INSTALL_TYPE" no changes needed..." fi } + +# Apps captured by switchMigrateBackupApps, consumed by switchMigrateRestoreApps. +# Both run inside the same dockerSwitcherSwap call, on either side of the daemon +# switch. +switch_migrated_apps=() + +# Back up every installed app (except LibrePortal's own control plane, which the +# reconcile already carries) under the OLD, still-running mode so it can be +# restored under the new mode afterwards. backupAppStart talks to the daemon via +# CFG_DOCKER_INSTALL_TYPE, which the switcher has already advanced to the target +# mode, so force it (and the resolved install user) back to old_mode for the +# duration, then hand it back. Returns non-zero if any backup fails so the +# caller can abort the switch with nothing changed. +switchMigrateBackupApps() +{ + local old_mode="$1" + + switch_migrated_apps=() + + if [[ -z "$(resticEnabledLocations)" ]]; then + isNotice "No backup location enabled — skipping automatic data migration." + return 0 + fi + + local subdirectories=($(find "$containers_dir" -mindepth 1 -maxdepth 1 -type d)) + + local saved_type="$CFG_DOCKER_INSTALL_TYPE" + CFG_DOCKER_INSTALL_TYPE="$old_mode" + resolveDockerInstallUser + + local failed=() + local dir app_name + for dir in "${subdirectories[@]}"; do + app_name=$(basename "$dir") + [[ "$app_name" == "libreportal" ]] && continue + if backupAppStart "$app_name"; then + switch_migrated_apps+=("$app_name") + else + failed+=("$app_name") + fi + done + + CFG_DOCKER_INSTALL_TYPE="$saved_type" + resolveDockerInstallUser + + if [[ ${#failed[@]} -gt 0 ]]; then + isError "Pre-switch backup failed for: ${failed[*]}" + isNotice "Aborting the switch — nothing changed, apps remain on $old_mode." + return 1 + fi + + isSuccessful "Backed up ${#switch_migrated_apps[@]} app(s) before switching." + return 0 +} + +# Restore each app captured by switchMigrateBackupApps under the NEW mode (new +# daemon up, CFG_DOCKER_INSTALL_TYPE already the target). restoreAppStart wipes +# and re-lays each tree and re-owns it to the new mode's install user — the UID +# remap a chown can't do. Best-effort: a failed app is reported and the rest +# continue, since the daemon has already switched. +switchMigrateRestoreApps() +{ + [[ ${#switch_migrated_apps[@]} -eq 0 ]] && return 0 + + resolveDockerInstallUser + + local failed=() + local app_name + for app_name in "${switch_migrated_apps[@]}"; do + if ! restoreAppStart "$app_name" "latest"; then + failed+=("$app_name") + fi + done + + if [[ ${#failed[@]} -gt 0 ]]; then + isError "Post-switch restore failed for: ${failed[*]}" + isNotice "The switch finished, but these apps need a manual restore: ${failed[*]}" + return 1 + fi + + isSuccessful "Restored ${#switch_migrated_apps[@]} app(s) into $CFG_DOCKER_INSTALL_TYPE." + return 0 +}