From 472a63466b1c38cdf013fed53fd4125e081dc495 Mon Sep 17 00:00:00 2001 From: librelad Date: Mon, 24 Aug 2026 03:01:24 +0100 Subject: [PATCH] feat(instance): repair pass for clones made before the tool/auth renames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixing _instanceRewriteTools does nothing for an instance already on disk, and a clone from the old code is broken in ways that never announce themselves: every Tools action answers "App '' has no tool ''" because dockerAppRunTool wants app; `authPersistCfg ` writes the instance's new admin credential into the BASE app's config; and the clone defines the base app's adapter and tool names while its bodies exec against the instance's container, so the loader keeps whichever it sourced last and the base app's user tools can end up administering the instance — decided by nothing but find(1) order. Seen on a live install: the generated manifest resolved [appBookstackListUsers] to bookstack_test's copy. libreportal instance repair [slug] [--dry-run] Rewrites the template dir only — no container is touched, nothing reinstalled, so it does not route through the task system the way create/remove do. Idempotent by construction. Two of the three renames match their own output (appMattermost_teest… still starts with appMattermost), and a clone from the old code is only PARTLY wrong — its suffix hooks were always correct and end at the slug with no trailing underscore, which the infix rule would otherwise read as type + id + () and append the id twice (appSetupComposeTags_nextcloud_family_family). Three sentinels park the already-correct spellings before the rewrite and restore them after, so a healthy instance is a no-op and an interrupted run can just be re-run. Verified against fixtures built with the old rule set for all five multi-instance apps that ship tools: after repair each tree is byte-identical to a fresh clone from the fixed cloner, a second pass reports "already correct", and --dry-run leaves checksums untouched. Co-Authored-By: Claude Opus 5 --- .../instance/cli_instance_commands.sh | 8 ++ .../commands/instance/cli_instance_header.sh | 7 + scripts/instance/instance_create.sh | 129 ++++++++++++++++++ .../source/files/arrays/function_manifest.sh | 9 ++ 4 files changed, 153 insertions(+) diff --git a/scripts/cli/commands/instance/cli_instance_commands.sh b/scripts/cli/commands/instance/cli_instance_commands.sh index f25f176..86dd98d 100644 --- a/scripts/cli/commands/instance/cli_instance_commands.sh +++ b/scripts/cli/commands/instance/cli_instance_commands.sh @@ -68,6 +68,14 @@ cliHandleInstanceCommands() instanceList "$type" # $type optional = filter by app type ;; + "repair") + # Rewrites files in the instance's TEMPLATE dir only — no container is + # touched, nothing is reinstalled — so it does not go through the task + # system the way create/remove do. $type holds the optional slug + # (positional reuse), $name an optional --dry-run. + instanceRepair "$type" "$name" + ;; + *) cliShowInstanceHelp ;; diff --git a/scripts/cli/commands/instance/cli_instance_header.sh b/scripts/cli/commands/instance/cli_instance_header.sh index 7368b28..2d52842 100644 --- a/scripts/cli/commands/instance/cli_instance_header.sh +++ b/scripts/cli/commands/instance/cli_instance_header.sh @@ -24,5 +24,12 @@ cliShowInstanceHelp() echo " CFG_DOMAIN_n is configured." echo " libreportal instance remove [slug*] - Uninstall + remove an instance (e.g. bookstack_blog)" echo " libreportal instance list [type] - List instances (all, or just for one app type)" + echo " libreportal instance repair [slug] [--dry-run]" + echo " - Fix instances cloned before the tool/auth" + echo " renames landed: their Tools actions error with" + echo " 'has no tool', and their admin-password resets" + echo " write to the BASE app's config. Rewrites the" + echo " template only — no container is touched. Safe" + echo " to run repeatedly; healthy instances are a no-op." echo "" } diff --git a/scripts/instance/instance_create.sh b/scripts/instance/instance_create.sh index 5ec10b4..d1ef5e6 100644 --- a/scripts/instance/instance_create.sh +++ b/scripts/instance/instance_create.sh @@ -612,6 +612,135 @@ instanceList() { done } +# --------------------------------------------------------------------------- +# Repair instances cloned before _instanceRewriteTools learned three of its +# renames — the infix form (authAdapter__), the bare app argument +# to the auth helpers, and the ucfirst tool entry point. A clone made by the old +# code is not broken in a way that announces itself: +# +# * every Tools action answered "App '' has no tool ''", because +# dockerAppRunTool wants app and the clone kept the type's +# * authPersistCfg wrote the instance's new admin credential into the +# BASE app's config, so the password on screen did not open either app +# * the clone defined the base app's adapter and tool names while its bodies +# pointed at the instance's container. Both definitions were live and the +# loader kept whichever it sourced last, i.e. whichever order find(1) +# happened to return — so the BASE app's user tools could administer the +# instance's container, silently, on nothing but a filesystem coincidence. +# +# Fixing the generator does nothing for a clone already on disk, hence this. +# +# instanceRepair [slug] [--dry-run] — all instances when no slug is given. +_instanceRepairFile() { + local type="$1" slug="$2" f="$3" + local g=$'\001' + + # Two of the three renames match their own output — appMattermost_teest… + # still starts with appMattermost, and _mattermost_teest_ still contains + # _mattermost_ — so a second pass would append the id half again + # (appMattermost_teest_teestListUsers). Park the already-correct spellings + # behind a sentinel first and restore them after, which makes the whole + # repair idempotent: running it on a healthy instance is a no-op, and a run + # interrupted halfway can simply be run again. \001 never occurs in shell or + # JSON source, and _instanceRewriteTools already uses it for the same reason. + # + # Three spellings, not two. A clone from the old code is only PARTLY wrong — + # its suffix hooks (appSetupComposeTags_) were always renamed correctly, + # and those end at the slug with no trailing underscore, so guard A misses + # them while the infix rule happily reads __ + the id half + () and + # appends the id a second time: appSetupComposeTags_nextcloud_family_family. + # Guard C is what keeps the repair from breaking the half that was fine. + sed -i -E "s/_${slug}_/${g}A${g}/g; s/\bapp${slug^}/${g}B${g}/g; s/_${slug}\(\)/${g}C${g}/g" "$f" + + sed -i -E "s/\b([A-Za-z_][A-Za-z0-9_]*)_${type}_([A-Za-z0-9_]+)(\(\))/\1_${slug}_\2\3/g" "$f" + sed -i -E "s/(\b(authAdapterCall|authAdapterCanDo|authPersistCfg)[[:space:]]+)${type}\b/\1${slug}/g" "$f" + sed -i -E "s/\bapp${type^}([A-Za-z0-9_]*)(\(\))/app${slug^}\1\2/g" "$f" + + sed -i -E "s/${g}A${g}/_${slug}_/g; s/${g}B${g}/app${slug^}/g; s/${g}C${g}/_${slug}()/g" "$f" +} + +instanceRepair() { + local want_slug="" dry="false" a + for a in "$@"; do + case "$a" in + --dry-run|-n) dry="true" ;; + "") ;; + *) want_slug="$a" ;; + esac + done + + local dir folder instance_of cfg f d + local scanned=0 touched=0 files=0 + for dir in "${install_containers_dir%/}"/*/; do + folder="$(basename "$dir")" + [[ -n "$want_slug" && "$folder" != "$want_slug" ]] && continue + cfg="$dir$folder.config" + [[ -f "$cfg" ]] || continue + instance_of=$(grep -E "^CFG_${folder^^}_INSTANCE_OF=" "$cfg" 2>/dev/null | head -n1) + [[ -z "$instance_of" ]] && continue + instance_of="${instance_of#*=}"; instance_of="${instance_of//\"/}"; instance_of="${instance_of//$'\r'/}" + [[ -n "$instance_of" ]] || continue + scanned=$((scanned + 1)) + + local before after changed_here=0 + for d in "$dir/tools" "$dir/scripts"; do + [[ -d "$d" ]] || continue + for f in "$d"/*.sh "$d"/*.json; do + [[ -e "$f" ]] || continue + before="$(cksum < "$f")" + if [[ "$dry" == "true" ]]; then + # Repair a copy so the report is exact without writing. + local tmp; tmp="$(mktemp)" || continue + cp "$f" "$tmp" + _instanceRepairFile "$instance_of" "$folder" "$tmp" + after="$(cksum < "$tmp")" + rm -f "$tmp" + else + _instanceRepairFile "$instance_of" "$folder" "$f" + after="$(cksum < "$f")" + fi + [[ "$before" == "$after" ]] && continue + changed_here=1 + files=$((files + 1)) + isNotice " ${folder}: $(basename "$f")" + done + done + + if (( changed_here )); then + touched=$((touched + 1)) + else + isSuccessful "$folder (instance of $instance_of) — already correct." + fi + done + + if (( scanned == 0 )); then + [[ -n "$want_slug" ]] && { isError "No such instance '$want_slug'."; return 1; } + isNotice "No instances found — nothing to repair." + return 0 + fi + + if (( files == 0 )); then + isSuccessful "Checked $scanned instance(s); all correct." + return 0 + fi + + if [[ "$dry" == "true" ]]; then + isNotice "Dry run — $files file(s) across $touched instance(s) WOULD be rewritten. Nothing was changed." + return 0 + fi + + isSuccessful "Repaired $files file(s) across $touched instance(s)." + # The function names just changed, so anything keyed on them is stale: the + # manifest maps names to files, and the collisions this clears are precisely + # the ones that made the base app's tools reachable under the instance's. + if declare -F lpRegenArrays >/dev/null 2>&1; then + isNotice "Rebuilding the function manifest…" + lpRegenArrays force >/dev/null 2>&1 || \ + isNotice "Manifest rebuild reported an error — run 'libreportal regen arrays --force'." + fi + isNotice "Restart the WebUI/task processor (or wait for the next poll) so the long-running loaders pick up the new names." +} + # Remove an instance: standard uninstall (deployed dir + DB + compose down) then # drop the instance's template clone. Refuses to touch a non-instance app. instanceRemove() { diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index e376820..e0b66c8 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -589,6 +589,8 @@ declare -gA LP_FN_MAP=( [instanceIdPart]="instance/instance_create.sh" [instanceList]="instance/instance_create.sh" [instanceRemove]="instance/instance_create.sh" + [instanceRepair]="instance/instance_create.sh" + [_instanceRepairFile]="instance/instance_create.sh" [_instanceRewriteCompose]="instance/instance_create.sh" [_instanceRewriteConfigPorts]="instance/instance_create.sh" [_instanceRewriteTools]="instance/instance_create.sh" @@ -681,6 +683,7 @@ declare -gA LP_FN_MAP=( [lpNotifyTaskResult]="cli/commands/notify/cli_notify_commands.sh" [lpRegen]="webui/webui_regen.sh" [lpRegenArrays]="webui/webui_regen.sh" + [_lpRegenOrphanedApp]="webui/webui_regen.sh" [_lpRegenStale]="webui/webui_regen.sh" [lpRegenWebui]="webui/webui_regen.sh" [lpReleaseBaseUrl]="source/fetch.sh" @@ -1768,6 +1771,8 @@ declare -gA LP_FN_ROOT=( [instanceIdPart]="scripts" [instanceList]="scripts" [instanceRemove]="scripts" + [instanceRepair]="scripts" + [_instanceRepairFile]="scripts" [_instanceRewriteCompose]="scripts" [_instanceRewriteConfigPorts]="scripts" [_instanceRewriteTools]="scripts" @@ -1860,6 +1865,7 @@ declare -gA LP_FN_ROOT=( [lpNotifyTaskResult]="scripts" [lpRegen]="scripts" [lpRegenArrays]="scripts" + [_lpRegenOrphanedApp]="scripts" [_lpRegenStale]="scripts" [lpRegenWebui]="scripts" [lpReleaseBaseUrl]="scripts" @@ -2984,6 +2990,8 @@ instanceCreate() { unset -f instanceCreate; __lpAutoload "${install_scripts_dir} 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 "$@"; } instanceRemove() { unset -f instanceRemove; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; instanceRemove "$@"; } +instanceRepair() { unset -f instanceRepair; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; instanceRepair "$@"; } +_instanceRepairFile() { unset -f _instanceRepairFile; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; _instanceRepairFile "$@"; } _instanceRewriteCompose() { unset -f _instanceRewriteCompose; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; _instanceRewriteCompose "$@"; } _instanceRewriteConfigPorts() { unset -f _instanceRewriteConfigPorts; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; _instanceRewriteConfigPorts "$@"; } _instanceRewriteTools() { unset -f _instanceRewriteTools; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; _instanceRewriteTools "$@"; } @@ -3076,6 +3084,7 @@ lpNotifySend() { unset -f lpNotifySend; __lpAutoload "${install_scripts_dir}cli/ lpNotifyTaskResult() { unset -f lpNotifyTaskResult; __lpAutoload "${install_scripts_dir}cli/commands/notify/cli_notify_commands.sh"; lpNotifyTaskResult "$@"; } lpRegen() { unset -f lpRegen; __lpAutoload "${install_scripts_dir}webui/webui_regen.sh"; lpRegen "$@"; } lpRegenArrays() { unset -f lpRegenArrays; __lpAutoload "${install_scripts_dir}webui/webui_regen.sh"; lpRegenArrays "$@"; } +_lpRegenOrphanedApp() { unset -f _lpRegenOrphanedApp; __lpAutoload "${install_scripts_dir}webui/webui_regen.sh"; _lpRegenOrphanedApp "$@"; } _lpRegenStale() { unset -f _lpRegenStale; __lpAutoload "${install_scripts_dir}webui/webui_regen.sh"; _lpRegenStale "$@"; } lpRegenWebui() { unset -f lpRegenWebui; __lpAutoload "${install_scripts_dir}webui/webui_regen.sh"; lpRegenWebui "$@"; } lpReleaseBaseUrl() { unset -f lpReleaseBaseUrl; __lpAutoload "${install_scripts_dir}source/fetch.sh"; lpReleaseBaseUrl "$@"; }