The compose rewrite assumed each app had exactly <type>-service and <type>_db. That holds for Bookstack and almost nothing else: cloning Nextcloud left -db, -redis and -web pointing at the ORIGINAL app's containers, and Matrix, Ollama, Mastodon, Owncloud, Gitea, Jitsi, Invidious, Rocketchat and Mattermost all had the same hole. Docker refuses a duplicate container name and two Traefik routers sharing a name fight over the host, so those clones could not have worked. Service identities are now discovered from the compose itself — its SERVICE_TAG_<n> markers plus its container_name values — and each is renamed. Verified across all 38 shipped apps: 15 are fixed, 21 produce byte-identical output to the old rule (Bookstack among them, so the running instances are unaffected), and 2 are refused. Details worth knowing: - Separators compare as equivalent, so the app dir libreportal_catalog matches its libreportal-catalog-* services instead of being wrongly refused. - Tokens are substituted longest-first through placeholders. \b has to end a token because per-port routers are named <service>-<portname> (traefik.http.routers.adguard-service-webui), which also means a short name could otherwise match inside a longer one — ordering is what prevents that. - Commented-out lines are not harvested. Several templates park an optional sidecar behind # (adguard-exporter, pihole-exporter, wireguard-exporter); renaming those also mangled the image name in the same block, leaving a trap for anyone uncommenting it. Commented image: lines are skipped too. - image: lines are genuinely excluded now. The old comment claimed service tokens "never appear in an image path", but libreportal builds a local image named after its own service and the old rule rewrote that reference. An app with a service carrying no <type> prefix (stoat's api/database/minio, prometheus's node-exporter/cadvisor) cannot be made unique mechanically, and rewriting a bare word like minio would corrupt image: minio/minio. Those are refused with an explanation and the partial clone is removed, rather than handed back as an instance that silently fights the base app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
453 lines
21 KiB
Bash
453 lines
21 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
|
|
}
|
|
|
|
# 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 < <(
|
|
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
|
|
)
|
|
|
|
# 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.
|
|
_instanceRewriteTools() {
|
|
local type="$1" slug="$2" dir="$3"
|
|
local d f base
|
|
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"
|
|
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"
|
|
|
|
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'."
|
|
}
|