From 2b77c8dfdd08e4a2605ec0096bf8d78aa7b0cd39 Mon Sep 17 00:00:00 2001 From: librelad Date: Tue, 21 Jul 2026 20:10:12 +0100 Subject: [PATCH] fix(install): sweep stranded containers before the daemon can resurrect them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh install creates an empty containers root but leaves the rootless daemon's own container state untouched. Restarting the daemon then runs its container-restore pass, which resurrects the previous install's containers — and Docker materialises each missing bind-mount source first, creating an empty DIRECTORY even where the mount is a file. That is the trigger behind the .config stub directories: at 19:38:59 the daemon re-created every missing mount source for a container built 40 minutes earlier, runc then failed with "not a directory: Are you trying to mount a directory onto a file", and the abandoned stubs collided with the install's own copies 18 seconds later. Add dockerRemoveStrandedContainers, run right after the rootless daemon restart: remove containers whose compose project directory no longer exists, so the next restart has nothing to resurrect. Scoped to project directories under the LibrePortal containers root, so unrelated containers on the host are never touched, and gated on the daemon answering. Signed-off-by: librelad --- scripts/docker/command/stranded_containers.sh | 58 +++++++++++++++++++ .../install/rootless/rootless_docker.sh | 6 ++ scripts/source/files/arrays/files_docker.sh | 1 + .../source/files/arrays/function_manifest.sh | 3 + 4 files changed, 68 insertions(+) create mode 100644 scripts/docker/command/stranded_containers.sh diff --git a/scripts/docker/command/stranded_containers.sh b/scripts/docker/command/stranded_containers.sh new file mode 100644 index 0000000..96c2f37 --- /dev/null +++ b/scripts/docker/command/stranded_containers.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +# A containers-root wipe (fresh install, or a relocation of the containers root) +# removes an app's project directory but NOT the container the daemon still holds +# in its own state. Those containers are stranded: their compose file, config and +# bind-mount sources are gone, so they can never start correctly again. +# +# They are not inert. `restart: unless-stopped` makes the daemon retry them on +# every restart, and Docker materialises each missing bind-mount source before +# handing off to runc — creating an empty DIRECTORY even where the mount is a +# file. That is how a fresh install ends up with .config as a directory: the +# install restarts the rootless daemon, the daemon resurrects the previous +# install's container, and the stubs it leaves behind collide with the install's +# own file copies moments later. +# +# Sweep them once the daemon is up, so the next restart has nothing to resurrect. +# Scoped to containers whose project directory sits under the LibrePortal +# containers root — unrelated containers on the host are never touched. +dockerRemoveStrandedContainers() +{ + local silent_flag="${1:-silent}" + + [[ -d "$containers_dir" ]] || return 0 + + # `systemctl restart` returns once the unit is active, which can be before + # dockerd finishes initialising. Give the socket a bounded moment to answer. + local attempt=0 + until runFileOp docker info >/dev/null 2>&1; do + attempt=$((attempt + 1)) + if [[ "$attempt" -ge 15 ]]; then + [[ "$silent_flag" == "loud" ]] && isNotice "Docker not responding — skipping stranded container sweep." + return 0 + fi + sleep 2 + done + + local listing + listing=$(runFileOp docker ps -a --format '{{.Names}} {{.Label "com.docker.compose.project.working_dir"}}' 2>/dev/null) + [[ -z "$listing" ]] && return 0 + + local removed=0 name work_dir + while IFS=$'\t' read -r name work_dir; do + [[ -z "$name" || -z "$work_dir" ]] && continue + # Only LibrePortal's own container tree, and only when the project + # directory is provably gone. + [[ "$work_dir" == "$containers_dir"* ]] || continue + [[ -d "$work_dir" ]] && continue + + if runFileOp docker rm -f "$name" >/dev/null 2>&1; then + removed=$((removed + 1)) + [[ "$silent_flag" == "loud" ]] && isNotice "Removed stranded container '$name' (project dir $work_dir is gone)." + fi + done <<< "$listing" + + if [[ "$removed" -gt 0 ]]; then + isSuccessful "Removed $removed stranded container(s) left without a project directory" + fi +} diff --git a/scripts/docker/install/rootless/rootless_docker.sh b/scripts/docker/install/rootless/rootless_docker.sh index da427e6..c54ec54 100755 --- a/scripts/docker/install/rootless/rootless_docker.sh +++ b/scripts/docker/install/rootless/rootless_docker.sh @@ -183,6 +183,12 @@ EOL" local result; result=$(dockerCommandRunInstallUser "systemctl --user restart docker") checkSuccess "Reload the systemd user docker service" + # The restart above runs the daemon's container-restore pass, which + # resurrects containers left over from a previous install whose project + # directory this install already wiped. Clear them now so the next + # restart can't re-create their bind-mount sources as stub directories. + dockerRemoveStrandedContainers "loud" + local result; result=$(sudo cp $sysctl $sysctl.bak) checkSuccess "Backing up sysctl file" diff --git a/scripts/source/files/arrays/files_docker.sh b/scripts/source/files/arrays/files_docker.sh index 26685fa..9424e56 100755 --- a/scripts/source/files/arrays/files_docker.sh +++ b/scripts/source/files/arrays/files_docker.sh @@ -31,6 +31,7 @@ docker_scripts=( "docker/command/docker_run_install.sh" "docker/command/docker_run.sh" "docker/command/run_privileged.sh" + "docker/command/stranded_containers.sh" "docker/compose/copy_build_context.sh" "docker/compose/restart_after_update.sh" "docker/compose/setup_compose_yml.sh" diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 2ba6287..8ca5345 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -411,6 +411,7 @@ declare -gA LP_FN_MAP=( [dockerPruneAppNetworks]="docker/network/network_prune.sh" [dockerRemoveApp]="docker/app/docker/remove_app.sh" [dockerRemoveAppImages]="docker/app/uninstall/remove_images.sh" + [dockerRemoveStrandedContainers]="docker/command/stranded_containers.sh" [dockerRestartApp]="docker/app/docker/restart_app.sh" [dockerRestartAppViaInstall]="docker/app/functions/function_restart_app.sh" [dockerServiceStart]="docker/service/start_docker.sh" @@ -1416,6 +1417,7 @@ declare -gA LP_FN_ROOT=( [dockerPruneAppNetworks]="scripts" [dockerRemoveApp]="scripts" [dockerRemoveAppImages]="scripts" + [dockerRemoveStrandedContainers]="scripts" [dockerRestartApp]="scripts" [dockerRestartAppViaInstall]="scripts" [dockerServiceStart]="scripts" @@ -2454,6 +2456,7 @@ dockerInstallApp() { unset -f dockerInstallApp; __lpAutoload "${install_scripts_ dockerPruneAppNetworks() { unset -f dockerPruneAppNetworks; __lpAutoload "${install_scripts_dir}docker/network/network_prune.sh"; dockerPruneAppNetworks "$@"; } dockerRemoveApp() { unset -f dockerRemoveApp; __lpAutoload "${install_scripts_dir}docker/app/docker/remove_app.sh"; dockerRemoveApp "$@"; } dockerRemoveAppImages() { unset -f dockerRemoveAppImages; __lpAutoload "${install_scripts_dir}docker/app/uninstall/remove_images.sh"; dockerRemoveAppImages "$@"; } +dockerRemoveStrandedContainers() { unset -f dockerRemoveStrandedContainers; __lpAutoload "${install_scripts_dir}docker/command/stranded_containers.sh"; dockerRemoveStrandedContainers "$@"; } dockerRestartApp() { unset -f dockerRestartApp; __lpAutoload "${install_scripts_dir}docker/app/docker/restart_app.sh"; dockerRestartApp "$@"; } dockerRestartAppViaInstall() { unset -f dockerRestartAppViaInstall; __lpAutoload "${install_scripts_dir}docker/app/functions/function_restart_app.sh"; dockerRestartAppViaInstall "$@"; } dockerServiceStart() { unset -f dockerServiceStart; __lpAutoload "${install_scripts_dir}docker/service/start_docker.sh"; dockerServiceStart "$@"; }