LibrePortal/scripts/instance/instance_create.sh
librelad f82237da36 fix(instance): make an instance's own functions reachable
Four defects that all reduce to "a function an instance defines is invisible
to the code that dispatches it". Reported as `mattermost_teest has no upgrade
verifier`, for an app whose verifier was on disk the whole time.

* generate_function_manifest.sh shipped 0664. lpRegenArrays invokes it as an
  executable, so it died rc=126 on every call and `|| true` swallowed it — the
  manifest was never rebuilt on any live system, only laid down at deploy.
  Its sibling generate_arrays.sh is 0775, which is why the files_*.sh arrays
  looked current while the manifest was byte-identical to the shipped copy.

* lpRegenArrays now runs both generators through bash rather than depending on
  the exec bit, reports a manifest failure instead of hiding it, and treats a
  new containers/<app> dir as stale — the one event on a live box that adds
  functions was the one the scripts/-only mtime check could not see.

* updaterHasVerifier consults the disk before answering no. The CLI runs
  LP_LAZY=1, where the container scan is skipped and every function must come
  from the build-time manifest, so an app created after the build reads as
  having no verifier. GATE 1 then refuses an upgrade that is fully verifiable,
  and updaterUpgradeAuto's `|| continue` drops the app in silence for good.
  Self-healing regardless of manifest staleness, which matters because a
  self-update restores the shipped manifest and drops instance entries again.

* _instanceRewriteTools gains three renames. authAdapter_<type>_<method>() was
  caught by neither the prefix rule (no word boundary before _<type>) nor the
  suffix rule (needs () right after the type), so the clone defined the base
  app's adapter name while pointing at its own container — every instance user
  tool answered "does not implement", and which definition survived came down
  to find(1) order. Bare-app arguments to authAdapterCall/authPersistCfg went
  unrewritten too, so an instance's password reset wrote the credential into
  the base app's config. And dockerAppRunTool wants app<Ucfirst><Pascal>, which
  no rule produced, so every tool on every instance was unreachable. The infix
  rename runs before the suffix rename: the reverse order appends the id half
  twice (appSetupComposeTags_nextcloud_work_work).

Verified on a live install: the upgrade ladder now plans mattermost_teest
11.9 -> 11.10, and `regen arrays --force` indexes the instance hooks.

Also carries in-flight instance-removal regen work from a concurrent session
on the same worktree (_lpRegenOrphanedApp, instanceRemove's WebUI refresh).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:51:18 +01:00

666 lines
34 KiB
Bash

#!/bin/bash
# Multi-instance support.
#
# Some apps are worth running more than once on a single box — e.g. two
# WordPress/Bookstack sites, or a "family" and a "work" Nextcloud kept in
# separate trust/blast-radius/backup domains. Internal multi-tenancy answers
# logical separation; this answers instance-level isolation (independent data,
# version cadence, admin, restore granularity).
#
# The model: an instance is just another app. It gets its own slug
# (<type>_<id>), its own CFG_<SLUG>_* namespace, its own deployed dir, DB row,
# IP/port allocation, subdomain and backups — so the entire downstream pipeline
# (scan, install, services, routing, updater, backups) treats it like any other
# app with ZERO changes. Everything instance-specific happens here, on a cloned
# copy of the type's template, leaving the shipped template and the core engine
# untouched.
#
# Only apps that opt in via CFG_<TYPE>_MULTI_INSTANCE=true can be instanced;
# structurally-singleton apps (Traefik, DNS, VPN, the *arr stack, LibrePortal
# itself) never get the flag.
# Read a CFG_<TYPE>_<KEY> value straight from a type's template config, without
# relying on it already being sourced.
instanceTypeCfg() {
local type="$1" key="$2"
local cfg="${install_containers_dir%/}/$type/$type.config"
[[ -f "$cfg" ]] || return 1
local line
line=$(grep -E "^CFG_${type^^}_${key}=" "$cfg" | head -n1)
[[ -z "$line" ]] && return 1
line="${line#*=}"
line="${line//$'\r'/}"
line="${line#\"}"
line="${line%\"}"
printf '%s' "$line"
}
# Turn a user-supplied instance name into the <id> half of the slug. App configs
# are SOURCED, so the slug (uppercased) must be a valid shell identifier — that
# means [a-z0-9] only (underscores are fine, hyphens are not). Hostname-safety is
# handled separately by the subdomain, which may contain hyphens.
instanceIdPart() {
local raw="${1,,}"
raw="${raw//[^a-z0-9]/}"
printf '%s' "$raw"
}
# Upsert a single CFG line in a config file (append if absent, else update).
_instanceSetCfg() {
local key="$1" val="$2" file="$3"
if grep -qE "^${key}=" "$file"; then
updateConfigOption "$key" "$val" "$file" >/dev/null
else
echo "${key}=\"${val}\"" >> "$file"
fi
}
# Rewrite field 10 (the subdomain column) of the instance's primary webui port so
# the instance routes to its own host instead of inheriting the type's. Empty
# subdomain would otherwise resolve to <slug>.<domain> — and the slug carries an
# underscore, which isn't valid in a hostname.
_instanceSetSubdomain() {
local slug_u="$1" subdomain="$2" file="$3"
local key="CFG_${slug_u}_PORT_1"
local line
line=$(grep -E "^${key}=" "$file" | head -n1)
[[ -z "$line" ]] && return 0
local val="${line#*=}"
val="${val//$'\r'/}"
val="${val#\"}"
val="${val%\"}"
local IFS='|'
local -a f=($val)
while [[ ${#f[@]} -lt 11 ]]; do f+=(""); done
f[10]="$subdomain"
local newval="${f[*]}"
updateConfigOption "$key" "$newval" "$file" >/dev/null
}
# Rewrite the parent-service column (field 1) of every PORT_<n> row in the cloned
# config so it names the service _instanceRewriteCompose actually stamped into the
# compose.
#
# portAllocate stores this value as the port row's `parent_service`, and the WebUI
# joins ports to an app's Docker services on it (webui_services.sh,
# db_list_installed_app.sh) — matching against service names read back from the
# compose's SERVICE_TAG_<n>. The config re-namespace only rewrites KEYS, so an
# instance kept the TYPE's service name in the value while its compose moved on:
# the join found nothing and the instance rendered in the WebUI with no port, no
# URL and no login row, despite being up and reachable.
#
# MUST mirror rule 1 of _instanceRewriteCompose — same two token forms, so the
# config and the compose can't drift apart. Scoped to PORT_ lines so a bare app
# name elsewhere in the config (titles, descriptions, the subdomain column) is
# left alone.
_instanceRewriteConfigPorts() {
local type="$1" slug="$2" file="$3"
[[ -f "$file" ]] || return 0
sed -i -E "/^CFG_[A-Z0-9_]+_PORT_[0-9]+=/{
s/\b${type}-service\b/${slug}-service/g
s/\b${type}_db\b/${slug}_db/g
}" "$file"
}
# Force every one of the instance's ports to LAN-only: access `private`, Traefik
# off. Used by `instance create --local`, for a second copy that should be reached
# at http://<ip>:<port> and never given a router or a certificate.
#
# `disabled` ports are left as-is — the user opted them out of host exposure
# entirely, and flipping them to private would start publishing them.
_instanceSetLocalOnly() {
local slug_u="$1" file="$2"
local key line val newval
local -a f
local n
for n in {1..20}; do
key="CFG_${slug_u}_PORT_${n}"
line=$(grep -E "^${key}=" "$file" | head -n1)
[[ -z "$line" ]] && continue
val="${line#*=}"
val="${val//$'\r'/}"
val="${val#\"}"
val="${val%\"}"
local IFS='|'
f=($val)
unset IFS
while [[ ${#f[@]} -lt 11 ]]; do f+=(""); done
[[ "${f[3]}" == "disabled" ]] && continue
f[3]="private" # access
f[6]="false" # traefik
local IFS='|'
newval="${f[*]}"
unset IFS
updateConfigOption "$key" "$newval" "$file" >/dev/null
done
}
# Refuse an app whose ports are pinned to a fixed host port.
#
# The identity checks below make names unique, but a second instance still has to
# bind its own ports, and `8201:80` is the same 8201 for every copy — the second
# container just fails to start at compose-up. `random:80` is what makes an app
# instanceable: portAllocate hands each instance its own host port from the pool.
#
# This is a property of the app, not of the instance, so it runs before anything
# is cloned. Ports the maintainer marked `disabled` are skipped — they publish
# nothing, so they cannot collide.
_instanceCheckPortsInstanceable() {
local type="$1"
local cfg="${install_containers_dir%/}/$type/$type.config"
[[ -f "$cfg" ]] || return 0
local line val ext access
local -a fixed=()
while IFS= read -r line; do
val="${line#*=}"; val="${val//$'\r'/}"; val="${val#\"}"; val="${val%\"}"
local IFS='|'
local -a f=($val)
unset IFS
ext="${f[2]%%:*}"
access="${f[3]}"
[[ "$access" == "disabled" ]] && continue
[[ "$ext" == "random" ]] && continue
[[ -z "$ext" ]] && continue
fixed+=("${f[1]:-port} -> ${f[2]}")
done < <(grep -E "^CFG_${type^^}_PORT_[0-9]+=" "$cfg" 2>/dev/null)
if [[ ${#fixed[@]} -gt 0 ]]; then
isError "Instance create: '$type' pins ${#fixed[@]} port(s) to a fixed host port, so a second copy cannot start:"
local p
for p in "${fixed[@]}"; do echo " $p"; done
isNotice "Change those to 'random:<internal>' in ${type}.config if the host port is arbitrary. If it is not — a DNS server on 53, a mail server on 25 — the app is genuinely one-per-host and should not be instanced."
return 1
fi
return 0
}
# Every container identity a compose declares: its service keys (from the
# SERVICE_TAG_<n> 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
# *-service / *_db tokens never appear in an image path).
_instanceRewriteCompose() {
local type="$1" slug="$2" dir="$3"
local f="$dir/docker-compose.yml"
[[ -f "$f" ]] || return 0
# 1. Every compound identity the compose declares — its service keys (from the
# SERVICE_TAG_<n> markers) and its container_name values. These are the
# names Docker and Traefik require to be unique, so a clone that keeps any
# of them collides head-on with the base app: docker refuses the duplicate
# container, and two routers with one name fight over the same host.
#
# Discovered rather than hardcoded. This used to assume every app had
# exactly <type>-service and <type>_db, which is true of Bookstack and
# false of most others — cloning Nextcloud would have left its -db, -redis
# and -web services pointing at the ORIGINAL app's containers.
#
# Commented-out lines are excluded. Several templates ship an optional
# sidecar parked behind `#` (adguard-exporter, pihole-exporter, …);
# harvesting those renamed a service that isn't running AND mangled the
# image name inside the same block, leaving a trap for whoever uncomments
# 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 < <(_instanceComposeIdentities "$f")
# Split into compound (<type> + separator + suffix) and bare (<type> exactly).
# Only compound names are safe to rewrite everywhere: the bare app name also
# appears inside image paths and values like MYSQL_USER=<type>, so it stays
# confined to the two anchored positions in rules 2/3 below — same split the
# original hardcoded rules relied on, now applied to whatever the app declares.
#
# Longest first: the substitution below lets `-` end a token, so a short
# name would otherwise match inside a longer one (`redis` inside
# `redis-cache`). Placeholdering the long name first makes that impossible.
local -a sorted=()
mapfile -t sorted < <(printf '%s\n' "${tokens[@]}" | awk '{print length"\t"$0}' | sort -rn | cut -f2-)
# Separators are compared as equivalent: an app dir named libreportal_catalog
# ships services named libreportal-catalog-*, and treating that as "unprefixed"
# would refuse an app that is perfectly instance-safe.
local type_norm="${type//-/_}"
local -a olds=() news=()
local t t_norm
for t in "${sorted[@]}"; do
t_norm="${t//-/_}"
if [[ "$t_norm" == "$type_norm" ]]; then
continue # bare name — handled by rules 2/3
elif [[ "$t_norm" == "${type_norm}_"* ]]; then
# Offset by ${#type}: swapping separators never changes length, so the
# suffix (and its original separator char) survives intact.
olds+=("$t"); news+=("${slug}${t:${#type}}")
else
# No <type> prefix at all (stoat's `database`/`minio`/`caddy`,
# prometheus's `node-exporter`). There is no mechanical way to make
# these unique — and rewriting a bare word like `minio` would also
# corrupt `image: minio/minio`. Refuse rather than hand back an
# instance that silently fights the base app for container names.
isError "Instance create: '$type' declares a service or container named '$t', which carries no '$type' prefix."
isNotice "Such names cannot be made unique per instance, so '$type' is not instance-safe. Prefix its services with '$type-' in the compose, or leave CFG_${type^^}_MULTI_INSTANCE unset."
return 1
fi
done
# Two-phase substitution via a placeholder, so a rewritten name can never be
# re-matched by a later pass.
#
# \b is deliberate: per-port Traefik routers are named <service>-<portname>
# (traefik.http.routers.adguard-service-webui), so the service token has to
# match when a `-` follows it. That is also what makes the longest-first
# ordering above load-bearing rather than cosmetic. image: lines are skipped
# outright — an app's name legitimately appears in its own image path.
local i ph
for i in "${!olds[@]}"; do
ph=$'\001'"LPSVC${i}"$'\001'
sed -i -E "/^[[:space:]]*#?[[:space:]]*image:/! s/\b${olds[$i]}\b/${ph}/g" "$f"
done
for i in "${!olds[@]}"; do
ph=$'\001'"LPSVC${i}"$'\001'
sed -i "s|${ph}|${news[$i]}|g" "$f"
done
# 2. The standalone app container (container_name: <type>) — anchored so the
# image: line ending in <type> is never touched. Commented lines are skipped
# for the same reason rule 1 skips them: a parked sidecar
# (# container_name: vaultwarden-exporter) is dead code, and half-renaming
# it just leaves the block internally inconsistent.
sed -i -E "/^[[:space:]]*#/! s/(container_name:[[:space:]]*)${type}\b/\1${slug}/g" "$f"
# 3. The files-backup label's container ref (libreportal.backup.files: "<type>:/...").
sed -i -E "/^[[:space:]]*#/! s/(libreportal\.backup\.files:[[:space:]]*\")${type}\b/\1${slug}/g" "$f"
# 4. The per-app tag namespace. tagsProcessorAppConfigValues derives tag names
# mechanically from the config keys (CFG_<APP>_APP_KEY_1 -> the tag
# <APP>_APP_KEY_1_TAG), so a clone still carrying the TYPE's tag names has
# nothing to match its own CFG_<SLUG>_* vars: the placeholders survive and
# the pre-start guard refuses to launch the instance.
# Deliberately narrow — the tag name right after the #LIBREPORTAL| marker,
# and the *_DATA placeholder tokens. A blanket <TYPE>_ rewrite would also
# hit an app whose compose sets a real container env var named after itself
# (- <TYPE>_SECRET=...), renaming the variable the image reads.
local type_u="${type^^}" slug_u="${slug^^}"
type_u="${type_u//-/_}"; slug_u="${slug_u//-/_}"
sed -i -E "s/(#LIBREPORTAL\|)${type_u}_/\1${slug_u}_/g" "$f"
sed -i -E "s/\b${type_u}_([A-Z0-9_]*)_DATA\b/${slug_u}_\1_DATA/g" "$f"
}
# Clone + prefix-rename the per-app tools/scripts so an instance's helpers target
# 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" src_compose="$4"
local d f base
local type_u="${type^^}" slug_u="${slug^^}"
type_u="${type_u//-/_}"; slug_u="${slug_u//-/_}"
# 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 `<type>_` 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 <type>-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
[[ -e "$f" ]] || continue
base="$(basename "$f")"
mv "$f" "$d/${base/#${type}/${slug}}"
done
for f in "$d"/*.sh "$d"/*.json; do
[[ -e "$f" ]] || continue
# Uniform lowercase-prefix rename keeps file names, function defs and
# tools.json ids consistent; then fix container-exec + config refs.
sed -i -E "s/\b${type}_/${slug}_/g" "$f"
sed -i -E "s/(docker[[:space:]]+(exec|logs|restart|stop|start|inspect)[[:space:]]+)${type}\b/\1${slug}/g" "$f"
sed -i -E "s/\b${type}\.config\b/${slug}.config/g" "$f"
# Config reads are uppercase and so escape the lowercase rename above:
# an instance hook left reading CFG_<TYPE>_ADMIN_EMAIL would provision
# itself from the type's config (its own value silently ignored).
sed -i -E "s/\bCFG_${type^^}_/CFG_${slug^^}_/g" "$f"
# container="<type>" / container_name="<type>" holds the docker target
# for exec-based helpers (auth adapters, tools). The bare literal has
# no trailing underscore, so the rename above misses it and the clone
# would operate on the BASE app's container. Kept to these two
# assignment forms — a blanket bare-<type> rewrite would hit image
# names and prose.
sed -i -E "s/(\b(container|container_name)=\")${type}(\")/\1${slug}\3/g" "$f"
# Function names carrying the app as an INFIX. Neither the prefix rule
# (needs <type> at a word boundary — `_mattermost` has none, `_` is a
# word character) nor the suffix rule below (needs the () immediately
# after the type) can see authAdapter_<type>_<method>(), so the clone
# defined authAdapter_mattermost_listUsers while auth_adapter.sh
# dispatches authAdapter_${app}_${method} — authAdapter_mattermost_teest_…
# Every user-management action on an instance failed with "does not
# implement", and the clone's definitions (bodies already rewritten to
# exec against the INSTANCE's container) collided with the base app's
# under its own name. Which of the two survived came down to the order
# `find` happened to return the files in, so on an unlucky filesystem
# the base app's user tools would have administered the instance.
#
# MUST run before the suffix rule, not after: the suffix rule turns
# appSetupComposeTags_<type>() into appSetupComposeTags_<slug>(), which
# then reads as an infix match (_<type>_ followed by the id half of the
# slug) and gets the suffix appended a second time —
# appSetupComposeTags_nextcloud_work_work(). In this order the infix
# rule sees `_<type>(` with no trailing underscore and passes it over.
sed -i -E "s/\b([A-Za-z_][A-Za-z0-9_]*)_${type}_([A-Za-z0-9_]+)(\(\))/\1_${slug}_\2\3/g" "$f"
# Function names carrying the app as a SUFFIX. The prefix rule above
# only matches <type>_, so appSetupComposeTags_vaultwarden survived
# untouched — and docker_config_setup_data.sh dispatches that hook as
# appSetupComposeTags_${app_name}, i.e. ..._vaultwarden_work for an
# instance. The clone therefore defined a function nobody calls (under
# a name that collides with the base app's), its compose tags were
# never filled, and the pre-start guard refused to launch the
# instance. Anchored on the () of a definition so only real function
# names are touched. Affects 8 apps that ship this hook shape.
sed -i -E "s/\b([A-Za-z_][A-Za-z0-9_]*)_${type}(\(\))/\1_${slug}\2/g" "$f"
# …and the call side of the same dispatch. The app is passed as a BARE
# word (`authAdapterCall mattermost listUsers`), which no rename above
# touches: not the prefix rule (no trailing _), not the container rules
# (not a docker verb, not container="…"). The instance's own tools
# therefore asked for the BASE app's adapter by name and operated on
# the base app's container — the failure mode this whole function
# exists to prevent, reached through the one argument nobody rewrote.
#
# authPersistCfg is the same shape and worse consequence: it writes the
# admin credential the tool just set into CFG_<APP>_ADMIN_*, so an
# instance resetting its own admin password was overwriting the BASE
# app's stored credential with a password that does not open it.
#
# Anchored on the two helper names that take a bare app as $1 — a
# blanket bare-<type> rewrite is not an option, mattermost_auth.sh has
# a comment about the deprecated `mattermost` binary that must survive.
sed -i -E "s/(\b(authAdapterCall|authAdapterCanDo|authPersistCfg)[[:space:]]+)${type}\b/\1${slug}/g" "$f"
# Tool entry points. dockerAppRunTool derives the function name from the
# slug as app<Ucfirst><PascalToolId> with NO case-insensitive fallback,
# so an instance's tools must be appMattermost_teestListUsers. The clone
# kept appMattermostListUsers — same collision as the adapters, and
# every tool on every instance answered "App '<slug>' has no tool '<id>'".
# ${type^} / ${slug^} reproduce dockerAppRunTool's own ucfirst exactly;
# anything cleverer would stop matching the name it has to produce.
sed -i -E "s/\bapp${type^}([A-Za-z0-9_]*)(\(\))/app${slug^}\1\2/g" "$f"
# The uppercase tag namespace, mirroring rule 4 of the compose rewrite.
# These hooks pass tag NAMES as strings ("VAULTWARDEN_ADMIN_TOKEN_1_TAG"),
# which the lowercase renames above cannot see. The cloned compose has
# already moved to <SLUG>_..._TAG, so leaving these behind would update
# a tag that no longer exists in the file.
sed -i -E "s/\b${type_u}_([A-Z0-9_]*_TAG)\b/${slug_u}_\1/g" "$f"
# The app's own deployed directory. Hooks that build it from
# "${containers_dir}<type>/..." 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
}
# Provision and install a new instance of a multi-instance-capable app.
# instanceCreate <type> <name> [domain_index] [subdomain] [local_only]
#
# local_only=true skips Traefik entirely — the instance is served on its own
# published host port and nothing else. That is also what you get implicitly when
# no CFG_DOMAIN_<n> is configured (initializeAppVariables forces the same thing),
# so the flag exists for the case where a domain IS set but this particular
# instance should stay off it.
instanceCreate() {
local type="$1" rawname="$2" domain_idx="$3" subdomain="$4" local_only="$5"
local type_dir="${install_containers_dir%/}/$type"
if [[ -z "$type" || ! -d "$type_dir" || ! -f "$type_dir/$type.config" ]]; then
isError "Instance create: unknown app type '$type'."
return 1
fi
local capable
capable=$(instanceTypeCfg "$type" "MULTI_INSTANCE")
if [[ "$capable" != "true" ]]; then
isError "App type '$type' is not multi-instance-capable. Set CFG_${type^^}_MULTI_INSTANCE=true on a reviewed app to allow it."
return 1
fi
# Fail before cloning: a fixed host port is a property of the app, and no
# amount of renaming makes a second copy able to bind it.
_instanceCheckPortsInstanceable "$type" || return 1
local id
id=$(instanceIdPart "$rawname")
if [[ -z "$id" ]]; then
isError "Instance create: '$rawname' has no usable letters/digits for an instance name."
return 1
fi
local slug="${type}_${id}"
local slug_u="${slug^^}"
if [[ -d "${install_containers_dir%/}/$slug" || -d "${containers_dir%/}/$slug" ]]; then
isError "An app or instance named '$slug' already exists. Pick a different name."
return 1
fi
# Default the host to a hyphen-safe form of the slug; let the caller override.
[[ -z "$subdomain" ]] && subdomain="${slug//_/-}"
if [[ "$local_only" == "true" ]]; then
isNotice "Creating new '$type' instance '$id' (slug: $slug, LAN-only on a published port)"
else
isNotice "Creating new '$type' instance '$id' (slug: $slug, host: ${subdomain}.<domain>)"
fi
# 1. Clone the type's template tree into a new instance template.
local inst_dir="${install_containers_dir%/}/$slug"
cp -r "$type_dir" "$inst_dir"
if [[ ! -d "$inst_dir" ]]; then
isError "Instance create: failed to clone template for '$slug'."
return 1
fi
# 2. Rename the files that are keyed by the type slug.
[[ -f "$inst_dir/$type.config" ]] && mv "$inst_dir/$type.config" "$inst_dir/$slug.config"
[[ -f "$inst_dir/$type.svg" ]] && cp "$inst_dir/$type.svg" "$inst_dir/$slug.svg"
[[ -f "$inst_dir/$type.png" ]] && cp "$inst_dir/$type.png" "$inst_dir/$slug.png"
local cfg="$inst_dir/$slug.config"
# 3. Re-namespace the config (CFG_<TYPE>_* -> CFG_<SLUG>_*) then stamp the
# instance metadata. Secrets keep their RANDOMIZED* placeholders so the
# install-time scanner mints fresh ones — instances never share secrets.
sed -i -E "s/CFG_${type^^}_/CFG_${slug_u}_/g" "$cfg"
local type_title
type_title=$(instanceTypeCfg "$type" "TITLE")
[[ -z "$type_title" ]] && type_title="$type"
# APP_NAME must follow the slug. The sed above re-namespaces the KEY but
# leaves the VALUE at the type ("bookstack"), and installApp resolves the app
# it operates on from CFG_<SLUG>_APP_NAME — so an instance install ran the
# whole pipeline against the BASE app instead: same deployed dir, same ports,
# compose down/up on the already-running base container, base DB row updated,
# and the instance itself never installed. Every base app ships APP_NAME ==
# its own slug; this keeps instances to that invariant.
_instanceSetCfg "CFG_${slug_u}_APP_NAME" "$slug" "$cfg"
_instanceSetCfg "CFG_${slug_u}_INSTANCE_OF" "$type" "$cfg"
_instanceSetCfg "CFG_${slug_u}_MULTI_INSTANCE" "false" "$cfg"
_instanceSetCfg "CFG_${slug_u}_TITLE" "${type_title} · ${id}" "$cfg"
[[ -n "$domain_idx" ]] && _instanceSetCfg "CFG_${slug_u}_DOMAIN" "$domain_idx" "$cfg"
_instanceSetSubdomain "$slug_u" "$subdomain" "$cfg"
# 4. Make the cloned compose + config ports + tools target the instance's own
# identity. The config ports must follow the compose or the WebUI can't
# join the instance's ports to its services.
# A refusal here means the app isn't instance-safe. Drop the clone rather
# than leave a half-rewritten template on disk that a later scan would pick
# up as a real app and try to install.
if ! _instanceRewriteCompose "$type" "$slug" "$inst_dir"; then
rm -rf "$inst_dir"
isError "Instance create aborted; no changes were made."
return 1
fi
_instanceRewriteConfigPorts "$type" "$slug" "$cfg"
_instanceRewriteTools "$type" "$slug" "$inst_dir" "$type_dir/docker-compose.yml"
if [[ "$local_only" == "true" ]]; then
_instanceSetLocalOnly "$slug_u" "$cfg"
isNotice "Instance '$slug' is LAN-only — no Traefik router, reachable on its published host port."
fi
isSuccessful "Instance template ready: $slug (instance of $type)"
# 5. Make the instance's freshly-cloned installers/hooks callable in THIS
# process. Both loaders ran at startup, before this dir existed: the eager
# scan (sourceScanFiles "containers") never saw it, and the lazy manifest
# has no stub for it. Without this, _appCallHook's `declare -F` finds
# nothing and every <slug>_install_* hook silently no-ops — for bookstack
# that is the readiness probe and the admin-account bootstrap, so the
# instance installs "successfully" with no usable login.
local _inst_f
while IFS= read -r -d '' _inst_f; do
source "$_inst_f"
done < <(find "$inst_dir" -maxdepth 2 -type d -name resources -prune -o -type f -name '*.sh' -print0 2>/dev/null)
# Persist that for later runs (and the WebUI): regenerate the file arrays +
# function manifest now that a new app dir exists. Best-effort — a stale
# manifest only affects lazy mode, and the in-process sourcing above already
# covers this install.
if declare -F lpRegenArrays >/dev/null 2>&1; then
lpRegenArrays force >/dev/null 2>&1 || true
fi
# 6. Hand off to the standard installer — from here it's just another app.
if ! declare -F dockerInstallApp >/dev/null 2>&1; then
isError "dockerInstallApp unavailable; instance template created but not installed."
return 1
fi
dockerInstallApp "$slug" "" "false"
}
# List instances, optionally filtered to one type. An instance is any app whose
# config declares CFG_<SLUG>_INSTANCE_OF.
instanceList() {
local want_type="$1"
local dir folder slug instance_of
for dir in "${install_containers_dir%/}"/*/; do
folder="$(basename "$dir")"
[[ -f "$dir/$folder.config" ]] || continue
instance_of=$(grep -E "^CFG_${folder^^}_INSTANCE_OF=" "$dir/$folder.config" 2>/dev/null | head -n1)
[[ -z "$instance_of" ]] && continue
instance_of="${instance_of#*=}"; instance_of="${instance_of//\"/}"; instance_of="${instance_of//$'\r'/}"
[[ -n "$want_type" && "$instance_of" != "$want_type" ]] && continue
echo "$folder (instance of $instance_of)"
done
}
# 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() {
local slug="$1"
local cfg="${install_containers_dir%/}/$slug/$slug.config"
if [[ ! -f "$cfg" ]]; then
isError "Instance remove: no such instance '$slug'."
return 1
fi
if ! grep -qE "^CFG_${slug^^}_INSTANCE_OF=" "$cfg"; then
isError "'$slug' is a base app, not an instance — uninstall it via 'libreportal app uninstall $slug'."
return 1
fi
if declare -F dockerUninstallApp >/dev/null 2>&1; then
dockerUninstallApp "$slug" "false" "false"
fi
rm -rf "${install_containers_dir%/}/$slug"
# Mirror of the create path: the file arrays + function manifest are keyed on
# the app dirs, and one just disappeared.
if declare -F lpRegenArrays >/dev/null 2>&1; then
lpRegenArrays force >/dev/null 2>&1 || true
fi
# Every WebUI app artifact is derived from the containers/<app> dirs, so
# dropping one has to be followed by a regen of the three that enumerate
# them. dockerUninstallApp already refreshed them — but that ran while the
# dir still existed, and its patch path can only flip an app to "not
# installed", never delete it. Without this the removed instance stayed in
# apps.json (installed false, INSTANCE_OF intact), so the app-detail
# Instances bar kept rendering a pill for it and the Apps grid kept counting
# it — across reloads, indefinitely.
#
# Called directly rather than via lpRegenWebui: that routes through the WebUI
# updater, which no-ops while another update holds the lock. A skipped run
# here is not self-correcting — the staleness check that would catch it
# compares mtimes of files that no longer exist.
isNotice "Refreshing WebUI app data after instance removal..."
local _gen _gen_rc=0
for _gen in webuiGenerateLibrePortalConfig webuiGenerateAppsServicesConfig webuiGenerateAppsToolsConfig; do
declare -F "$_gen" >/dev/null 2>&1 || continue
"$_gen" >/dev/null || _gen_rc=1
done
if [[ "$_gen_rc" -eq 0 ]]; then
isSuccessful "Refreshed WebUI app data."
else
isNotice "WebUI app data refresh reported an error — if '$slug' still shows in the Instances list, run 'libreportal regen webui --force'."
fi
isSuccessful "Removed instance '$slug'."
}