LibrePortal/scripts/docker/command/stranded_containers.sh
librelad 2b77c8dfdd fix(install): sweep stranded containers before the daemon can resurrect them
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 <app>.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 <librelad@digitalangels.vip>
2026-07-21 20:10:12 +01:00

59 lines
2.5 KiB
Bash

#!/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 <app>.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
}