LibrePortal/scripts/instance/instance_create.sh
librelad e9fceeab99 Enable multi-instance on vaultwarden and searxng
Both now clear every guard: services are <type>-prefixed, and their host ports
became random in the previous commit.

Enabling them surfaced a real bug that would have made vaultwarden instances
fail to start, found by dry-running the clone path before trusting the flag.

Eight apps define an app-specific compose-tags hook named with the app as a
SUFFIX — appSetupComposeTags_vaultwarden — and docker_config_setup_data.sh
dispatches it as appSetupComposeTags_${app_name}. The tools rewrite only
renamed the <type>_ PREFIX form, so a clone kept the base name: it defined a
function nobody calls (colliding with the base app's), its ADMIN_TOKEN and
SIGNUPS_ALLOWED tags were never filled, and the pre-start guard would have
refused to launch the instance. Now renamed, anchored on the () of a definition
so only real function names are touched.

The same hooks pass tag NAMES as strings ("VAULTWARDEN_ADMIN_TOKEN_1_TAG"),
invisible to the lowercase renames, while the cloned compose had already moved
to <SLUG>_..._TAG. Those are rewritten too, mirroring compose rule 4. Verified:
the tags the cloned hook sets now match the cloned compose exactly.

Also affects matrix, nextcloud, speedtest, pihole, gluetun and wireguard, which
ship the same hook shape — latent for those, since none are enabled.

WebUI: the instance bar on app details rendered nothing at all for apps without
instance support, which reads as "this build has no instance feature" and sends
people hunting for a setting that isn't missing. It now states the reason where
the pills would be, and names the blocking ports when it can — the port rows
are in the config the frontend already holds, so it mirrors
_instanceCheckPortsInstanceable (skipping disabled and random rows). The other
blocker lives in the compose, which the frontend never sees, so that case is
left unexplained rather than guessed at.

Bookstack's rewritten compose and tool tree remain byte-identical to the
running instances.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:35:33 +01:00

586 lines
28 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 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"
# 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"
isSuccessful "Removed instance '$slug'."
}