diff --git a/scripts/docker/app/docker/remove_app.sh b/scripts/docker/app/docker/remove_app.sh index 0da127b..38b2395 100755 --- a/scripts/docker/app/docker/remove_app.sh +++ b/scripts/docker/app/docker/remove_app.sh @@ -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=` 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'" } diff --git a/scripts/docker/app/docker/restart_app.sh b/scripts/docker/app/docker/restart_app.sh index 8217c92..02a031e 100755 --- a/scripts/docker/app/docker/restart_app.sh +++ b/scripts/docker/app/docker/restart_app.sh @@ -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=` 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 to diff --git a/scripts/docker/app/docker/start_app.sh b/scripts/docker/app/docker/start_app.sh index 7d22c23..6bc3cd7 100755 --- a/scripts/docker/app/docker/start_app.sh +++ b/scripts/docker/app/docker/start_app.sh @@ -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=` 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'" } diff --git a/scripts/docker/app/docker/stop_app.sh b/scripts/docker/app/docker/stop_app.sh index e69f4c3..24c7aa8 100755 --- a/scripts/docker/app/docker/stop_app.sh +++ b/scripts/docker/app/docker/stop_app.sh @@ -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=` 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 to stop diff --git a/scripts/webui/data/generators/updater/webui_updater_scan.sh b/scripts/webui/data/generators/updater/webui_updater_scan.sh index 35f3d3d..ad353d9 100644 --- a/scripts/webui/data/generators/updater/webui_updater_scan.sh +++ b/scripts/webui/data/generators/updater/webui_updater_scan.sh @@ -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