#!/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 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. Traefik router/service names (-service) and the db container/host # (_db) — these tokens are unambiguous, rewrite everywhere. sed -i -E "s/\b${type}-service\b/${slug}-service/g; s/\b${type}_db\b/${slug}_db/g" "$f" # 2. The standalone app container (container_name: ) — anchored so the # image: line ending in 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: ":/..."). sed -i -E "s/(libreportal\.backup\.files:[[:space:]]*\")${type}\b/\1${slug}/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" done done } # Provision and install a new instance of a multi-instance-capable app. # instanceCreate [domain_index] [subdomain] instanceCreate() { local type="$1" rawname="$2" domain_idx="$3" subdomain="$4" 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//_/-}" isNotice "Creating new '$type' instance '$id' (slug: $slug, host: ${subdomain}.)" # 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" _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 + tools target the instance's own identity. _instanceRewriteCompose "$type" "$slug" "$inst_dir" _instanceRewriteTools "$type" "$slug" "$inst_dir" isSuccessful "Instance template ready: $slug (instance of $type)" # 5. 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 } # 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'." }