feat(switcher): hands-off data migration across a docker mode switch

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 <old_mode>: 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 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-24 13:26:24 +01:00
parent 5cb8b7c95a
commit e7659926db

View File

@ -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
}