Audit of per-app hooks/tools found 19 of 33 apps whose helpers would have
operated on the BASE app after cloning. Two general causes, both fixed by
rewriting classes rather than patching apps:
- Container references escaped the rewrite whenever a flag sat between the
docker verb and the target (`docker exec -u git gitea-service …`), since the
old rule only matched a name immediately after the verb — and the hyphenated
form missed the `<type>_` rule too. Hook trees now get the same discovered
identity rename the compose does, reading names from the TYPE's compose since
the clone has already been rewritten by then. Safe to apply broadly: the
compose pass runs first and aborts for any app whose identities aren't
<type>-prefixed, so a bare word like stoat's `api` never reaches it.
- Hooks that build the deployed path as "${containers_dir}<type>/..." instead
of "$containers_dir$app_name/..." read and WROTE the base app's files —
adguard's auth adapter edits AdGuardHome.yaml, so an instance would have
rewritten the original's config. The trailing slash is optional in the match:
dashy tests [[ -d "${containers_dir}dashy" ]] and gluetun cds into it, both
ending at the quote. Only the first path component is touched, so
${containers_dir}prometheus/prometheus/... keeps its inner segment.
Re-audit: all 33 apps with hook trees are clean. Stoat still leaks, but it is
refused at the compose stage and never reaches this code.
Volumes audited too, and need no changes: no app uses named volumes, so the
./relative bind mounts every app uses resolve inside each instance's own
deployed dir. The absolute sources that exist are host or in-container paths
correctly shared read-only (/etc/localtime, /sys, /etc/ssl/certs). Jitsi's
${CONFIG} is set per-app by its own hook to $containers_dir$app_name/... and so
follows the slug.
Bookstack's rewritten tool tree is byte-identical to the live instance's across
all 8 files, so the running instances are unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
519 lines
25 KiB
Bash
519 lines
25 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
|
|
}
|
|
|
|
# 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.
|
|
sed -i -E "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 "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
|
|
|
|
# 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"
|
|
|
|
# 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
|
|
|
|
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'."
|
|
}
|