From 166acb9b7ceecc89b7f9a96024f96ed975394287 Mon Sep 17 00:00:00 2001 From: librelad Date: Wed, 19 Aug 2026 04:51:04 +0100 Subject: [PATCH] Make instance hooks target their own container and directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of per-app hooks/tools found 19 of 33 apps whose helpers would have operated on the BASE app after cloning. Two general causes, both fixed by rewriting classes rather than patching apps: - Container references escaped the rewrite whenever a flag sat between the docker verb and the target (`docker exec -u git gitea-service …`), since the old rule only matched a name immediately after the verb — and the hyphenated form missed the `_` rule too. Hook trees now get the same discovered identity rename the compose does, reading names from the TYPE's compose since the clone has already been rewritten by then. Safe to apply broadly: the compose pass runs first and aborts for any app whose identities aren't -prefixed, so a bare word like stoat's `api` never reaches it. - Hooks that build the deployed path as "${containers_dir}/..." instead of "$containers_dir$app_name/..." read and WROTE the base app's files — adguard's auth adapter edits AdGuardHome.yaml, so an instance would have rewritten the original's config. The trailing slash is optional in the match: dashy tests [[ -d "${containers_dir}dashy" ]] and gluetun cds into it, both ending at the quote. Only the first path component is touched, so ${containers_dir}prometheus/prometheus/... keeps its inner segment. Re-audit: all 33 apps with hook trees are clean. Stoat still leaks, but it is refused at the compose stage and never reaches this code. Volumes audited too, and need no changes: no app uses named volumes, so the ./relative bind mounts every app uses resolve inside each instance's own deployed dir. The absolute sources that exist are host or in-container paths correctly shared read-only (/etc/localtime, /sys, /etc/ssl/certs). Jitsi's ${CONFIG} is set per-app by its own hook to $containers_dir$app_name/... and so follows the slug. Bookstack's rewritten tool tree is byte-identical to the live instance's across all 8 files, so the running instances are unaffected. Co-Authored-By: Claude Opus 5 --- .../commands/updater/cli_updater_ladder.sh | 67 +++++++++++++-- scripts/instance/instance_create.sh | 82 +++++++++++++++++-- .../source/files/arrays/function_manifest.sh | 3 + .../generators/updater/webui_updater_scan.sh | 20 ----- 4 files changed, 138 insertions(+), 34 deletions(-) diff --git a/scripts/cli/commands/updater/cli_updater_ladder.sh b/scripts/cli/commands/updater/cli_updater_ladder.sh index 1227bd5..514e356 100644 --- a/scripts/cli/commands/updater/cli_updater_ladder.sh +++ b/scripts/cli/commands/updater/cli_updater_ladder.sh @@ -46,6 +46,55 @@ updaterTagExists() { [ "$code" = "200" ] } +# Bump the Nth (0-based) numeric component of a tag by one and zero every +# component after it, so a bump means what a version bump means: +# "v1.158.2", 1 -> "v1.159.0" "31-fpm-alpine", 0 -> "32-fpm-alpine" +# Shape is preserved by construction (only digits change), which is what lets +# callers compare shapes to reject nonsense candidates. +updaterTagBumpAt() { + printf '%s' "$1" | awk -v idx="$2" '{ + out=""; n=0; s=$0 + while (match(s, /[0-9]+/)) { + pre = substr(s, 1, RSTART-1) + num = substr(s, RSTART, RLENGTH) + 0 + s = substr(s, RSTART+RLENGTH) + if (n == idx) num = num + 1; else if (n > idx) num = 0 + out = out pre num + n++ + } + print out s + }' +} + +# The immediately-next PUBLISHED version above $1 in repo $2, or "" if there is +# none. THE step primitive: everything else here is built on it being right. +# +# It exists because bumping only the last component cannot cross a component +# boundary. v1.158.0 -> v1.158.1 -> v1.158.2 … never arrives at v1.159.0, so a +# ladder built that way gave up on the single most common versioning scheme +# there is, and Synapse — which publishes v1.159.0 and no v1.158.1 at all — +# could not be climbed one rung. +# +# So consider a bump of EVERY component (major, minor, patch), keep only the +# candidates that actually exist upstream, and take the SMALLEST of those. That +# is the next release by definition, whether it lands in the patch position or +# crosses into a new major. Candidates that change the tag's shape are dropped, +# so 31-fpm-alpine never becomes 31-apache. Costs one lookup per component. +updaterNextRung() { + local cur="$1" repo="$2" + local shape; shape="$(updaterTagShape "$cur")" + local ncomp; ncomp="$(printf '%s' "$cur" | grep -oE '[0-9]+' | wc -l | tr -d ' ')" + [ "${ncomp:-0}" -gt 0 ] 2>/dev/null || return 0 + local best="" i cand + for ((i=0; i v4.3, 31-fpm-alpine # -> 32-fpm-alpine, v0.16 -> v0.17. updaterTagIncrement() { @@ -90,13 +139,19 @@ updaterVersionLadder() { updaterTagGreater "$target" "$cur" || return 0 # never downgrade local -a rungs=() - local probe="$cur" i + local probe="$cur" i next for ((i=0; i<64; i++)); do # bounded: no runaway - probe="$(updaterTagIncrement "$probe")" - updaterTagGreater "$probe" "$target" && break # overshot - if updaterTagExists "$repo" "$probe"; then - rungs+=("$probe") - fi + # Step to the next version that EXISTS, rather than incrementing blindly + # and testing. Same guarantee as before — every rung is probed, so a + # release missing from any listing can still never be skipped — but it + # can now cross a component boundary, which blind incrementing could + # not: v1.158.0 -> v1.159.0 was unreachable and the whole ladder failed + # closed on it. + next="$(updaterNextRung "$probe" "$repo")" + [ -n "$next" ] || break # nothing further published + updaterTagGreater "$next" "$target" && break # overshot + rungs+=("$next") + probe="$next" [ "$probe" = "$target" ] && break done diff --git a/scripts/instance/instance_create.sh b/scripts/instance/instance_create.sh index e647dfa..47533f0 100644 --- a/scripts/instance/instance_create.sh +++ b/scripts/instance/instance_create.sh @@ -136,6 +136,24 @@ _instanceSetLocalOnly() { done } +# Every container identity a compose declares: its service keys (from the +# SERVICE_TAG_ markers) plus its container_name values, deduped. +# +# Commented-out lines are excluded. Several templates ship an optional sidecar +# parked behind `#` (adguard-exporter, pihole-exporter, …); harvesting those +# renames a service that isn't running AND mangles the image name in the same +# block, leaving a trap for whoever uncomments it later. A live service line +# carries its SERVICE_TAG mid-line, so dropping lines that *start* with # keeps +# every real one. +_instanceComposeIdentities() { + local f="$1" + [[ -f "$f" ]] || return 0 + grep -v '^[[:space:]]*#' "$f" 2>/dev/null \ + | grep -oE '#LIBREPORTAL\|SERVICE_TAG_[0-9]+\|[^[:space:]]+|container_name:[[:space:]]*[^[:space:]]+' \ + | sed -E 's/.*SERVICE_TAG_[0-9]+\|//; s/container_name:[[:space:]]*//' \ + | sed '/^$/d' | sort -u +} + # Rewrite identity-bearing tokens in the cloned compose so the instance's # containers, Traefik routers and backup labels are unique. image: lines are # deliberately left untouched (rule 2/3 anchor on their line prefix; the @@ -163,12 +181,7 @@ _instanceRewriteCompose() { # it later. A real service line carries its SERVICE_TAG mid-line, so # dropping lines that *start* with # keeps every live one. local -a tokens=() - mapfile -t tokens < <( - grep -v '^[[:space:]]*#' "$f" 2>/dev/null | { - grep -oE '#LIBREPORTAL\|SERVICE_TAG_[0-9]+\|[^[:space:]]+|container_name:[[:space:]]*[^[:space:]]+' \ - | sed -E 's/.*SERVICE_TAG_[0-9]+\|//; s/container_name:[[:space:]]*//' - } | sed '/^$/d' | sort -u - ) + mapfile -t tokens < <(_instanceComposeIdentities "$f") # Split into compound ( + separator + suffix) and bare ( exactly). # Only compound names are safe to rewrite everywhere: the bare app name also @@ -249,9 +262,36 @@ _instanceRewriteCompose() { # its own container and don't collide (by function name) with the type's. This is # best-effort: it keeps the tool tree internally consistent, but apps with unusual # tool wiring may need review before their flag is flipped. +# +# src_compose is the TYPE's original compose — the identities are read from there, +# not from the clone, because the clone has already been rewritten to the new names +# by the time this runs. _instanceRewriteTools() { - local type="$1" slug="$2" dir="$3" + local type="$1" slug="$2" dir="$3" src_compose="$4" local d f base + + # Container identities as they appear in hook/tool code. The per-token rename + # below is what catches `docker exec -u git gitea-service …`: the docker-verb + # rule further down only matches a container name sitting immediately after the + # verb, so any intervening flag (-u, -i, -e VAR=…) hid the target from it, and + # the hyphenated form escaped the `_` rule as well. An instance's auth + # adapter then administered the BASE app. + # + # Safe to apply broadly here: _instanceRewriteCompose runs first and aborts the + # whole create for any app whose identities aren't -prefixed, so a bare + # word like stoat's `api` or `web` can never reach this rewrite. + local -a ids=() olds=() news=() + if [[ -n "$src_compose" && -f "$src_compose" ]]; then + mapfile -t ids < <(_instanceComposeIdentities "$src_compose" \ + | awk '{print length"\t"$0}' | sort -rn | cut -f2-) + local t t_norm type_norm="${type//-/_}" + for t in "${ids[@]}"; do + t_norm="${t//-/_}" + [[ "$t_norm" == "$type_norm" ]] && continue # bare name: rules below + [[ "$t_norm" == "${type_norm}_"* ]] || continue # unreachable, see above + olds+=("$t"); news+=("${slug}${t:${#type}}") + done + fi for d in "$dir/tools" "$dir/scripts"; do [[ -d "$d" ]] || continue for f in "$d/${type}_"*.sh "$d/${type}.tools.json"; do @@ -277,6 +317,32 @@ _instanceRewriteTools() { # assignment forms — a blanket bare- rewrite would hit image # names and prose. sed -i -E "s/(\b(container|container_name)=\")${type}(\")/\1${slug}\3/g" "$f" + + # The app's own deployed directory. Hooks that build it from + # "${containers_dir}/..." instead of "$containers_dir$app_name/..." + # read and WRITE the base app's files — adguard's auth adapter edits + # ${containers_dir}adguard/conf/AdGuardHome.yaml, so an instance would + # have rewritten the original's config. Anchored on containers_dir so a + # bare mention of the app name in prose is left alone. + # Trailing / is NOT required: dashy tests [[ -d "${containers_dir}dashy" ]] + # and gluetun does (cd "${containers_dir}gluetun" && …), both ending at + # the quote. Only the first path component is touched, so a data subdir + # that repeats the app name (${containers_dir}prometheus/prometheus/…) + # keeps its inner segment. + sed -i -E "s@(\\\$\{containers_dir\})${type}([^A-Za-z0-9_-]|$)@\1${slug}\2@g" "$f" + sed -i -E "s@(\\\$\{install_containers_dir\}/?)${type}([^A-Za-z0-9_-]|$)@\1${slug}\2@g" "$f" + + # Compose identities (longest-first, via placeholders — same reasoning + # as the compose rewrite: a short name must not match inside a longer). + local i ph + for i in "${!olds[@]}"; do + ph=$'\001'"LPTOOL${i}"$'\001' + sed -i -E "s/\b${olds[$i]}\b/${ph}/g" "$f" + done + for i in "${!olds[@]}"; do + ph=$'\001'"LPTOOL${i}"$'\001' + sed -i "s|${ph}|${news[$i]}|g" "$f" + done done done } @@ -378,7 +444,7 @@ instanceCreate() { return 1 fi _instanceRewriteConfigPorts "$type" "$slug" "$cfg" - _instanceRewriteTools "$type" "$slug" "$inst_dir" + _instanceRewriteTools "$type" "$slug" "$inst_dir" "$type_dir/docker-compose.yml" if [[ "$local_only" == "true" ]]; then _instanceSetLocalOnly "$slug_u" "$cfg" diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index a43a4c0..267a217 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -578,6 +578,7 @@ declare -gA LP_FN_MAP=( [installSwapfile]="install/install_swapfile.sh" [installUFW]="install/install_ufw.sh" [installUFWDocker]="install/install_ufwd.sh" + [_instanceComposeIdentities]="instance/instance_create.sh" [instanceCreate]="instance/instance_create.sh" [instanceIdPart]="instance/instance_create.sh" [instanceList]="instance/instance_create.sh" @@ -1728,6 +1729,7 @@ declare -gA LP_FN_ROOT=( [installSwapfile]="scripts" [installUFW]="scripts" [installUFWDocker]="scripts" + [_instanceComposeIdentities]="scripts" [instanceCreate]="scripts" [instanceIdPart]="scripts" [instanceList]="scripts" @@ -2914,6 +2916,7 @@ installSSLCertificate() { unset -f installSSLCertificate; __lpAutoload "${instal installSwapfile() { unset -f installSwapfile; __lpAutoload "${install_scripts_dir}install/install_swapfile.sh"; installSwapfile "$@"; } installUFW() { unset -f installUFW; __lpAutoload "${install_scripts_dir}install/install_ufw.sh"; installUFW "$@"; } installUFWDocker() { unset -f installUFWDocker; __lpAutoload "${install_scripts_dir}install/install_ufwd.sh"; installUFWDocker "$@"; } +_instanceComposeIdentities() { unset -f _instanceComposeIdentities; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; _instanceComposeIdentities "$@"; } instanceCreate() { unset -f instanceCreate; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; instanceCreate "$@"; } instanceIdPart() { unset -f instanceIdPart; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; instanceIdPart "$@"; } instanceList() { unset -f instanceList; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; instanceList "$@"; } diff --git a/scripts/webui/data/generators/updater/webui_updater_scan.sh b/scripts/webui/data/generators/updater/webui_updater_scan.sh index 4c08995..f8ae189 100644 --- a/scripts/webui/data/generators/updater/webui_updater_scan.sh +++ b/scripts/webui/data/generators/updater/webui_updater_scan.sh @@ -223,26 +223,6 @@ updaterNewerVersionByList() { printf '%s' "$best" } -# Bump the Nth (0-based) numeric component of a tag by one and zero every -# component after it, so a bump means what a version bump means: -# "v1.158.2", 1 -> "v1.159.0" "31-fpm-alpine", 0 -> "32-fpm-alpine" -# Shape is preserved by construction (only the digits change), which is what -# lets the caller compare shapes to reject nonsense candidates. -updaterTagBumpAt() { - printf '%s' "$1" | awk -v idx="$2" '{ - out=""; n=0; s=$0 - while (match(s, /[0-9]+/)) { - pre = substr(s, 1, RSTART-1) - num = substr(s, RSTART, RLENGTH) + 0 - s = substr(s, RSTART+RLENGTH) - if (n == idx) num = num + 1; else if (n > idx) num = 0 - out = out pre num - n++ - } - print out s - }' -} - # The newest tag reachable from $1 by PROBING for exact tags, most-significant # component first, climbing each as far as it goes. #