feat(instance): repair pass for clones made before the tool/auth renames

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 '<slug>' has no tool '<id>'" because
dockerAppRunTool wants app<Ucfirst><Pascal>; `authPersistCfg <type>` 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 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-24 03:01:24 +01:00
parent 11e8142d41
commit 472a63466b
4 changed files with 153 additions and 0 deletions

View File

@ -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
;;

View File

@ -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 ""
}

View File

@ -612,6 +612,135 @@ instanceList() {
done
}
# ---------------------------------------------------------------------------
# Repair instances cloned before _instanceRewriteTools learned three of its
# renames — the infix form (authAdapter_<type>_<method>), 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 '<slug>' has no tool '<id>'", because
# dockerAppRunTool wants app<Ucfirst><Pascal> and the clone kept the type's
# * authPersistCfg <type> 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_<slug>) 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 _<type>_ + 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() {

View File

@ -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 "$@"; }