Scope app container operations by compose project, not name substring

`docker ps -f name=<app>` is a SUBSTRING match, and instance slugs are
<type>_<id> — so the base app's name is a prefix of every instance of it.
`name=bookstack` also selected bookstack_home, bookstack_test and their -db
containers, which meant start, stop, restart and remove all silently operated on
every instance of an app instead of the one named.

Worst of the four is remove: `libreportal app remove bookstack` ran `docker rm`
against its instances' containers too. Multi-instance made this reachable — the
naming scheme it introduced is exactly what turns the base name into a prefix.

Each app and instance is already its own compose project, named for its
directory, so the project label addresses exactly the containers belonging to
that app. app_install.sh's own post-install check already used this label; the
lifecycle operations did not.

Found while tracing the IP allocation problem: bookstack_work had vanished, and
checking how uninstall selects containers turned this up. To be clear about
attribution — this bug does NOT explain that disappearance. The log shows an
explicit uninstall of bookstack_work, including its own install folder and log,
which container-level over-matching cannot do. I could not attribute that
removal to a specific command and am not going to guess; the instance has been
recreated.

Verified: with the fix, `libreportal app stop bookstack` stops bookstack and
bookstack-db and leaves bookstack_home and bookstack_test running. Before it,
all six went down. All four Bookstack apps and Stoat serve 200 afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-20 01:41:41 +01:00
parent d09b21eec1
commit 0a6ea95b08
5 changed files with 56 additions and 10 deletions

View File

@ -11,9 +11,16 @@ dockerRemoveApp()
isNotice "Stopping and removing Docker containers for '$app_name'. Please wait..."
# Stop and remove containers in one go
local result; result=$(dockerCommandRun "docker ps -aqf name=$app_name | xargs -r docker stop" >/dev/null 2>&1)
# Scope by compose project, NOT by name. `docker ps -f name=<app>` is a
# SUBSTRING match, so with multi-instance apps the base app's name is a prefix
# of every instance of it: `name=bookstack` also matched bookstack_home and
# bookstack_test (and their -db containers), so an operation aimed at one app
# silently hit all of its instances. Each app and instance is its own compose
# project (named for its directory), so the project label addresses exactly
# the containers that belong to this app and nothing else.
local result; result=$(dockerCommandRun "docker ps -aq --filter label=com.docker.compose.project=$app_name | xargs -r docker stop" >/dev/null 2>&1)
checkSuccess "Stopped Docker containers matching '$app_name'"
local result; result=$(dockerCommandRun "docker ps -aqf name=$app_name | xargs -r docker rm" >/dev/null 2>&1)
local result; result=$(dockerCommandRun "docker ps -aq --filter label=com.docker.compose.project=$app_name | xargs -r docker rm" >/dev/null 2>&1)
checkSuccess "Removed Docker containers matching '$app_name'"
}

View File

@ -34,7 +34,14 @@ dockerRestartApp()
isNotice "Restarting Docker containers for '$app_name'. Please wait..."
# Restart containers in one go
local result; result=$(dockerCommandRun "docker ps -aqf name=$app_name | xargs -r docker restart" >/dev/null 2>&1)
# Scope by compose project, NOT by name. `docker ps -f name=<app>` is a
# SUBSTRING match, so with multi-instance apps the base app's name is a prefix
# of every instance of it: `name=bookstack` also matched bookstack_home and
# bookstack_test (and their -db containers), so an operation aimed at one app
# silently hit all of its instances. Each app and instance is its own compose
# project (named for its directory), so the project label addresses exactly
# the containers that belong to this app and nothing else.
local result; result=$(dockerCommandRun "docker ps -aq --filter label=com.docker.compose.project=$app_name | xargs -r docker restart" >/dev/null 2>&1)
checkSuccess "Restarted Docker containers matching '$app_name'"
# App-specific restart hook — host-installed apps define restart<App> to

View File

@ -11,6 +11,13 @@ dockerStartApp()
isNotice "Starting Docker containers for '$app_name'. Please wait..."
# Start containers in one go
local result; result=$(dockerCommandRun "docker ps -aqf name=$app_name | xargs -r docker start" >/dev/null 2>&1)
# Scope by compose project, NOT by name. `docker ps -f name=<app>` is a
# SUBSTRING match, so with multi-instance apps the base app's name is a prefix
# of every instance of it: `name=bookstack` also matched bookstack_home and
# bookstack_test (and their -db containers), so an operation aimed at one app
# silently hit all of its instances. Each app and instance is its own compose
# project (named for its directory), so the project label addresses exactly
# the containers that belong to this app and nothing else.
local result; result=$(dockerCommandRun "docker ps -aq --filter label=com.docker.compose.project=$app_name | xargs -r docker start" >/dev/null 2>&1)
checkSuccess "Started Docker containers matching '$app_name'"
}

View File

@ -11,7 +11,14 @@ dockerStopApp()
isNotice "Stopping Docker containers for '$app_name'. Please wait..."
# Stop containers in one go
local result; result=$(dockerCommandRun "docker ps -aq --filter name=${app_name} | xargs -r docker stop" >/dev/null 2>&1)
# Scope by compose project, NOT by name. `docker ps -f name=<app>` is a
# SUBSTRING match, so with multi-instance apps the base app's name is a prefix
# of every instance of it: `name=bookstack` also matched bookstack_home and
# bookstack_test (and their -db containers), so an operation aimed at one app
# silently hit all of its instances. Each app and instance is its own compose
# project (named for its directory), so the project label addresses exactly
# the containers that belong to this app and nothing else.
local result; result=$(dockerCommandRun "docker ps -aq --filter label=com.docker.compose.project=${app_name} | xargs -r docker stop" >/dev/null 2>&1)
checkSuccess "Stopped Docker containers matching '$app_name'"
# App-specific stop hook — host-installed apps define stop<App> to stop

View File

@ -393,6 +393,20 @@ webuiUpdaterScan() {
local compose="$containers_dir/$app/docker-compose.yml"
[ -f "$compose" ] || continue
local anchor; anchor="$(updaterPrimaryImage "$app" "$compose")"
# Every reuse below carries a fact the registry told us about the
# ANCHOR. If the anchor has since become a different image, those facts
# describe the old one and must not be reused. This is not theoretical:
# fixing stoat's anchor moved it from mongo:8.0 to ghcr.io/stoatchat/api,
# and its "last rebuilt" date stayed MongoDB's — byte-identical to
# library/mongo's — so the unmaintained check was judging Stoat by
# MongoDB's release cadence. Reuse is for a throttled scan, not for an
# app whose identity changed underneath it.
local prev_anchor="" prev_same=0
if [ "$have_jq" = "1" ] && [ -f "$prev_json" ]; then
prev_anchor="$(jq -r --arg n "$app" '(.apps[]?|select(.name==$n)|.current_image)//""' "$prev_json" 2>/dev/null)"
[ "$prev_anchor" = "$anchor" ] && prev_same=1
fi
local channel; channel="$(updaterTagOf "$anchor")"; [ -n "$channel" ] || channel="latest"
local vtype; vtype="$(updaterClassifyTag "$channel")"
@ -413,10 +427,14 @@ webuiUpdaterScan() {
# manual-only, so an explicit "never check on its own" is honoured.
local app_registry="$do_registry"
if [ "$app_registry" = "0" ] && [ "$reg_interval" != "0" ] && [ "$have_jq" = "1" ]; then
# An anchor that has CHANGED image counts as never-answered too:
# the stored digest belongs to the image we no longer run, so there
# is nothing valid to reuse and waiting out the window would leave
# the app described by its predecessor.
local _seen=""
[ -f "$prev_json" ] && _seen="$(jq -r --arg n "$app" '(.apps[]?|select(.name==$n)|.available_digest)//""' "$prev_json" 2>/dev/null)"
[ "$prev_same" = "1" ] && _seen="$(jq -r --arg n "$app" '(.apps[]?|select(.name==$n)|.available_digest)//""' "$prev_json" 2>/dev/null)"
[ "$_seen" = "null" ] && _seen=""
[ -z "$_seen" ] && app_registry=1 # never answered (new app, or first scan)
[ -z "$_seen" ] && app_registry=1 # never answered, or a new anchor
fi
# available (registry) digest for the anchor's channel
@ -425,7 +443,7 @@ webuiUpdaterScan() {
avail_dig="$(updaterRegistryDigest "$(updaterRepoTag "$anchor")")"
fi
# reuse the prior value when we didn't (or couldn't) reach the registry
if [ -z "$avail_dig" ] && [ "$have_jq" = "1" ] && [ -f "$prev_json" ]; then
if [ -z "$avail_dig" ] && [ "$prev_same" = "1" ]; then
avail_dig="$(jq -r --arg n "$app" '(.apps[]?|select(.name==$n)|.available_digest)//""' "$prev_json" 2>/dev/null)"
fi
@ -439,7 +457,7 @@ webuiUpdaterScan() {
if [ "$app_registry" = "1" ]; then
img_updated="$(updaterTagLastUpdated "$(updaterRepoTag "$anchor")" "$channel")"
fi
if [ -z "$img_updated" ] && [ "$have_jq" = "1" ] && [ -f "$prev_json" ]; then
if [ -z "$img_updated" ] && [ "$prev_same" = "1" ]; then
img_updated="$(jq -r --arg n "$app" '(.apps[]?|select(.name==$n)|.image_updated_at)//""' "$prev_json" 2>/dev/null)"
[ "$img_updated" = "null" ] && img_updated=""
fi
@ -453,7 +471,7 @@ webuiUpdaterScan() {
if [ "$app_registry" = "1" ]; then
newer_ver="$(updaterNewerVersionTag "$channel" "$(updaterRepoTag "$anchor")")"
newer_ver="${newer_ver%%:*}"
elif [ "$have_jq" = "1" ] && [ -f "$prev_json" ]; then
elif [ "$prev_same" = "1" ]; then
newer_ver="$(jq -r --arg n "$app" '(.apps[]?|select(.name==$n)|.newer_version)//""' "$prev_json" 2>/dev/null)"
[ "$newer_ver" = "null" ] && newer_ver=""
fi