#!/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 # (_), its own CFG__* 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__MULTI_INSTANCE=true can be instanced; # structurally-singleton apps (Traefik, DNS, VPN, the *arr stack, LibrePortal # itself) never get the flag. # Read a CFG__ 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 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 . — 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_ 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_. 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://: 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:' 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_ 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_ 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 -service and _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 ( + separator + suffix) and bare ( exactly). # Only compound names are safe to rewrite everywhere: the bare app name also # appears inside image paths and values like MYSQL_USER=, 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 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 - # (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: ) — anchored so the # image: line ending in 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: ":/..."). 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_KEY_1 -> the tag # _APP_KEY_1_TAG), so a clone still carrying the TYPE's tag names has # nothing to match its own CFG__* 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 _ rewrite would also # hit an app whose compose sets a real container env var named after itself # (- _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 `_` 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 [[ -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__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="" / container_name="" 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- 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 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__(), 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_() into appSetupComposeTags_(), which # then reads as an infix match (__ 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 `_(` 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 _, 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__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- 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 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 '' has no tool ''". # ${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 _..._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}/..." instead of "$(appDir "$app_name")/..." # read and WRITE the base app's files — adguard's auth adapter edits # $(appDir 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 "$(appDir dashy)" ]] # and gluetun does (cd "$(appDir gluetun)" && …), both ending at # the quote. Only the first path component is touched, so a data subdir # that repeats the app name ($(appDir prometheus)/prometheus/…) # keeps its inner segment. # Post-sweep form: hooks resolve their dir with $(appDir ). sed -i -E "s@(\\\$\(appDir )${type}(\))@\1${slug}\2@g" "$f" sed -i -E "s@(\\\$\(appDir \")${type}(\"\))@\1${slug}\2@g" "$f" # Legacy form, for any hook that still builds the path by hand. 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 [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_ 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 "$(appDir "$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}.)" 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__* -> CFG__*) 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__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 _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__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 } # --------------------------------------------------------------------------- # 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() { 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/ 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'." }