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. #