From 376610cd11c44e997549ccc665a5a46442d59035 Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 4 Jun 2026 23:34:52 +0100 Subject: [PATCH] feat(apps): scoped multi-instance support (run two of an app) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a *multi-instance-capable* app run as several fully isolated instances on one box (e.g. two Bookstack/WordPress sites, or a "family" + "work" Nextcloud) — distinct data, DB, subdomain, backups and update cadence. Design: an instance is just another app. It gets its own slug (_), its own CFG__* namespace, deployed dir, DB row, IP/port allocation and host, so the entire existing pipeline (scan, install, services, routing, updater, backups) treats it like any app with zero changes. All instance-specific rewriting is confined to a clone of the type's template; the shipped template and the core engine are untouched. Gating: opt-in per app via CFG__MULTI_INSTANCE=true. Only Bookstack carries it for now (the validated reference). The other 31 apps are unaffected — the feature is invisible unless the flag is present. - scripts/instance/instance_create.sh — clone + re-namespace config, rewrite compose identity (container_name / Traefik routers / backup labels) and per-app tools, set a hostname-safe subdomain (PORT field 10), then hand off to dockerInstallApp. Plus instanceList / instanceRemove. - libreportal instance create|remove|list — new CLI category; mutations route through the task system (no new mutating API endpoint). - WebUI: "instance of " badge + a "New instance" card action on capable apps, and a create modal (name + domain# + subdomain, live host preview) that dispatches the standard task. Capability/instance-of read straight off the already-exposed app config. Known follow-ups (documented): flip the flag on more apps after a compose identity check (Nextcloud next); per-app tools are best-effort isolated. Co-Authored-By: Claude Opus 4.8 Signed-off-by: librelad --- containers/bookstack/bookstack.config | 5 + .../components/apps/core/css/apps.css | 160 ++++++++++++ .../components/apps/core/js/apps-grid.js | 16 ++ .../apps/core/js/instance-manager.js | 186 ++++++++++++++ .../frontend/core/boot/js/system-loader.js | 3 +- .../frontend/core/tasks/js/task-actions.js | 22 ++ .../frontend/core/tasks/js/task-commands.js | 13 +- .../frontend/core/tasks/js/task-router.js | 5 +- .../instance/cli_instance_commands.sh | 56 ++++ .../commands/instance/cli_instance_header.sh | 25 ++ scripts/instance/instance_create.sh | 239 ++++++++++++++++++ scripts/source/files/arrays/files_cli.sh | 2 + scripts/source/files/arrays/files_instance.sh | 9 + scripts/source/files/arrays/files_source.sh | 1 + .../source/files/arrays/function_manifest.sh | 33 +++ 15 files changed, 770 insertions(+), 5 deletions(-) create mode 100644 containers/libreportal/frontend/components/apps/core/js/instance-manager.js create mode 100644 scripts/cli/commands/instance/cli_instance_commands.sh create mode 100644 scripts/cli/commands/instance/cli_instance_header.sh create mode 100644 scripts/instance/instance_create.sh create mode 100644 scripts/source/files/arrays/files_instance.sh diff --git a/containers/bookstack/bookstack.config b/containers/bookstack/bookstack.config index 1bf4dbb..9f7d40c 100644 --- a/containers/bookstack/bookstack.config +++ b/containers/bookstack/bookstack.config @@ -12,6 +12,11 @@ # ADMIN_PASSWORD = password used for the Bookstack admin account # CFG_BOOKSTACK_APP_NAME=bookstack +# MULTI_INSTANCE = if true, this app can run as multiple isolated instances +# (own data/DB/subdomain/backups) via `libreportal instance create`. Only set on +# apps whose compose identity (container_name, Traefik routers, backup labels) +# is instance-safe — see scripts/instance/instance_create.sh. +CFG_BOOKSTACK_MULTI_INSTANCE=true CFG_BOOKSTACK_BACKUP=true CFG_BOOKSTACK_BACKUP_STRATEGY=auto CFG_BOOKSTACK_COMPOSE_FILE=default diff --git a/containers/libreportal/frontend/components/apps/core/css/apps.css b/containers/libreportal/frontend/components/apps/core/css/apps.css index 9fbf4aa..9881ee7 100644 --- a/containers/libreportal/frontend/components/apps/core/css/apps.css +++ b/containers/libreportal/frontend/components/apps/core/css/apps.css @@ -435,3 +435,163 @@ justify-content: center; } } + +/* ============================================================================ + Multi-instance — "instance of" badge + the "New instance" card action and + creation modal. An instance is just another app; this is only the create UX. + ========================================================================= */ +.app-tag.instance-tag { + background: rgba(var(--accent-rgb), 0.12); + color: var(--accent); + border-color: rgba(var(--accent-rgb), 0.30); + font-weight: 600; +} + +.app-card-actions .new-instance-btn { + flex: 0 0 auto; + min-height: 44px; + padding: 12px 14px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + background: transparent; + color: var(--accent); + border: 1px dashed rgba(var(--accent-rgb), 0.55) !important; + transition: background 0.2s, border-color 0.2s, transform 0.2s; +} +.app-card-actions .new-instance-btn:hover { + background: rgba(var(--accent-rgb), 0.12); + border-color: var(--accent) !important; +} + +/* Modal */ +.lp-instance-overlay { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); +} +.lp-instance-modal { + width: 100%; + max-width: 460px; + background: var(--card-bg); + border: 1px solid var(--card-border, var(--border-color)); + border-radius: 14px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45); + padding: 22px 22px 18px; + color: var(--text-primary); +} +.lp-instance-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.lp-instance-head h3 { + margin: 0; + font-size: 18px; + font-weight: 700; +} +.lp-instance-x { + background: transparent; + border: none; + color: var(--text-secondary); + font-size: 24px; + line-height: 1; + cursor: pointer; + padding: 0 4px; +} +.lp-instance-x:hover { color: var(--text-primary); } +.lp-instance-sub { + margin: 6px 0 16px; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.45; +} +.lp-instance-field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 14px; +} +.lp-instance-field > span { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.lp-instance-field input, +.lp-instance-field select { + width: 100%; + padding: 10px 12px; + border-radius: 8px; + background: var(--input-bg); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-size: 14px; +} +.lp-instance-field input:focus, +.lp-instance-field select:focus { + outline: none; + border-color: var(--accent); +} +.lp-instance-hint { + font-size: 12px; + color: var(--text-secondary); + min-height: 14px; +} +.lp-instance-row { + display: flex; + gap: 12px; +} +.lp-instance-row .lp-instance-field { flex: 1; } +.lp-instance-preview { + font-size: 13px; + color: var(--text-secondary); + margin: 2px 0 18px; +} +.lp-instance-preview code { + color: var(--accent); + background: rgba(var(--accent-rgb), 0.10); + padding: 2px 6px; + border-radius: 5px; + font-weight: 600; +} +.lp-instance-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} +.lp-instance-btn { + padding: 10px 18px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + border: 1px solid var(--border-color); + background: transparent; + color: var(--text-primary); + transition: background 0.2s, border-color 0.2s, opacity 0.2s; +} +.lp-instance-cancel:hover { background: rgba(var(--text-rgb), 0.08); } +.lp-instance-create { + background: var(--accent); + border-color: var(--accent); + color: var(--text-primary); +} +.lp-instance-create:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.lp-instance-create:not(:disabled):hover { + filter: brightness(1.08); +} diff --git a/containers/libreportal/frontend/components/apps/core/js/apps-grid.js b/containers/libreportal/frontend/components/apps/core/js/apps-grid.js index 665f2aa..4c2f8ff 100644 --- a/containers/libreportal/frontend/components/apps/core/js/apps-grid.js +++ b/containers/libreportal/frontend/components/apps/core/js/apps-grid.js @@ -209,6 +209,20 @@ Object.assign(AppsManager.prototype, { ` : ''; + // Multi-instance: capability + instance-of are read straight off the app's + // own config (apps.json emits every CFG__* var). An instance is just + // another app, so this only adds a sibling badge + a "New instance" action. + const _cfg = app.config || {}; + const _U = appName.toUpperCase(); + const instanceOf = _cfg[`CFG_${_U}_INSTANCE_OF`] || ''; + const isMultiCapable = String(_cfg[`CFG_${_U}_MULTI_INSTANCE`]).toLowerCase() === 'true'; + const instanceBadge = instanceOf + ? `⧉ instance of ${instanceOf}` + : ''; + const newInstanceBtn = isMultiCapable + ? `` + : ''; + card.innerHTML = `
@@ -219,6 +233,7 @@ Object.assign(AppsManager.prototype, {
${descriptionTag} ${categoryTag} + ${instanceBadge} ${status}
@@ -228,6 +243,7 @@ Object.assign(AppsManager.prototype, { + ${newInstanceBtn} ${serviceTrigger}
`; diff --git a/containers/libreportal/frontend/components/apps/core/js/instance-manager.js b/containers/libreportal/frontend/components/apps/core/js/instance-manager.js new file mode 100644 index 0000000..533705f --- /dev/null +++ b/containers/libreportal/frontend/components/apps/core/js/instance-manager.js @@ -0,0 +1,186 @@ +// Multi-instance UI — the "New instance" flow for multi-instance-capable apps. +// +// An instance is just another app (slug _), so once created it flows +// through the existing grid / detail / config / routing / backups pages with no +// special-casing. This file only owns the *creation* affordance: a small modal +// that collects a name + domain + subdomain and dispatches the standard task +// (libreportal instance create …) through the same task pipeline as install. +// +// Capability + instance-of are read straight off app.config (the apps.json +// generator already emits every CFG__* var), so the grid stays declarative. + +class InstanceManager { + constructor() { + this.domains = []; + } + + // Read configured domains (CFG_DOMAIN_1..9) from the generated system config. + async _loadDomains() { + try { + const r = await fetch('/data/config/generated/configs.json', { cache: 'no-store' }); + if (!r.ok) return []; + const data = await r.json(); + const cfg = data.config || {}; + const out = []; + for (let i = 1; i <= 9; i++) { + const k = `CFG_DOMAIN_${i}`; + const v = ((cfg[k] && cfg[k].value !== undefined ? cfg[k].value : cfg[k]) || '').toString().trim(); + if (v) out.push({ number: i, domain: v }); + } + return out; + } catch (_) { + return []; + } + } + + // The type's display title, for the modal heading. + _typeTitle(typeSlug) { + const a = (window.apps || []).find(x => (x.command || '').split(' ').pop() === typeSlug); + return a ? (a.name || typeSlug).split(' - ')[0].trim() : typeSlug; + } + + // user text -> [a-z0-9] id (mirrors the backend's instanceIdPart) + _idPart(raw) { + return String(raw || '').toLowerCase().replace(/[^a-z0-9]/g, ''); + } + + _esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, c => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] + )); + } + + close() { + const el = document.getElementById('lp-instance-modal'); + if (el) el.remove(); + document.removeEventListener('keydown', this._onKey); + } + + async openCreateModal(typeSlug) { + if (document.getElementById('lp-instance-modal')) return; + const title = this._typeTitle(typeSlug); + this.domains = await this._loadDomains(); + + const domainOptions = this.domains.length + ? this.domains.map(d => ``).join('') + : ''; + + const overlay = document.createElement('div'); + overlay.id = 'lp-instance-modal'; + overlay.className = 'lp-instance-overlay'; + overlay.innerHTML = ` + `; + + document.body.appendChild(overlay); + + const nameEl = overlay.querySelector('#lp-instance-name'); + const slugEl = overlay.querySelector('#lp-instance-slug'); + const subEl = overlay.querySelector('#lp-instance-subdomain'); + const domEl = overlay.querySelector('#lp-instance-domain'); + const hostEl = overlay.querySelector('#lp-instance-host'); + const createBtn = overlay.querySelector('.lp-instance-create'); + + let subEdited = false; + subEl.addEventListener('input', () => { subEdited = true; }); + + const refresh = () => { + const id = this._idPart(nameEl.value); + const slug = id ? `${typeSlug}_${id}` : ''; + slugEl.textContent = slug ? `Created as ${slug}` : 'Letters and numbers only'; + if (!subEdited) subEl.value = slug ? slug.replace(/_/g, '-') : ''; + const dom = (this.domains.find(d => String(d.number) === domEl.value) || {}).domain || ''; + const sub = (subEl.value || '').trim(); + hostEl.textContent = sub ? `${sub}.${dom}` : `…`; + createBtn.disabled = !id; + }; + nameEl.addEventListener('input', refresh); + domEl.addEventListener('change', refresh); + subEl.addEventListener('input', refresh); + refresh(); + setTimeout(() => nameEl.focus(), 30); + + this._onKey = (e) => { if (e.key === 'Escape') this.close(); }; + document.addEventListener('keydown', this._onKey); + overlay.querySelector('.lp-instance-x').addEventListener('click', () => this.close()); + overlay.querySelector('.lp-instance-cancel').addEventListener('click', () => this.close()); + overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close(); }); + + createBtn.addEventListener('click', () => this._submit(typeSlug, nameEl.value, domEl.value, subEl.value)); + } + + async _submit(typeSlug, name, domainIndex, subdomain) { + const id = this._idPart(name); + if (!id) return; + const slug = `${typeSlug}_${id}`; + + // Ensure the task system is up (same on-demand load path install uses). + if ((!window.tasksManager || !window.tasksManager.router) && window.appsManager && window.appsManager.loadTaskSystem) { + try { await window.appsManager.loadTaskSystem(); } catch (_) {} + } + + const notify = (msg, kind) => { + const sys = (typeof window.ensureNotificationSystem === 'function') + ? window.ensureNotificationSystem() : window.notificationSystem; + if (sys && typeof sys.show === 'function') sys.show(msg, kind || 'info'); + }; + + if (!window.tasksManager || !window.tasksManager.router) { + notify('Task system unavailable — could not start instance creation.', 'error'); + return; + } + + try { + await window.tasksManager.router.routeAction('instance_create', { + type: typeSlug, + name: name, + domainIndex: domainIndex, + subdomain: subdomain + }); + this.close(); + notify(`Creating instance ${slug} — track progress in Tasks.`, 'success'); + if (window.librePortalSPA && window.librePortalSPA.navigateTo) { + window.librePortalSPA.navigateTo('/tasks'); + } else if (window.navigateToRoute) { + window.navigateToRoute('tasks'); + } else { + window.location.href = '/tasks'; + } + } catch (error) { + notify(`Failed to create instance: ${error.message}`, 'error'); + } + } +} + +window.instanceManager = window.instanceManager || new InstanceManager(); diff --git a/containers/libreportal/frontend/core/boot/js/system-loader.js b/containers/libreportal/frontend/core/boot/js/system-loader.js index 3ffe0b1..a7a1cfc 100755 --- a/containers/libreportal/frontend/core/boot/js/system-loader.js +++ b/containers/libreportal/frontend/core/boot/js/system-loader.js @@ -208,7 +208,8 @@ class SystemLoader { '/components/apps/core/js/gluetun-vpn.js', '/components/apps/core/js/config-dirty-guard.js', '/components/apps/core/js/install-console.js', - '/components/apps/core/js/service-buttons-sidebar.js' + '/components/apps/core/js/service-buttons-sidebar.js', + '/components/apps/core/js/instance-manager.js' // multi-instance "New instance" modal (window.instanceManager) ] }); diff --git a/containers/libreportal/frontend/core/tasks/js/task-actions.js b/containers/libreportal/frontend/core/tasks/js/task-actions.js index 3055bf6..43d15d0 100755 --- a/containers/libreportal/frontend/core/tasks/js/task-actions.js +++ b/containers/libreportal/frontend/core/tasks/js/task-actions.js @@ -28,6 +28,28 @@ class TaskActions { } } + /** + * Provision a new isolated instance of a multi-instance-capable app. + * Dispatched as an 'install' of the new instance slug so the UI treats it + * like any install (button state, task icon, progress), while the verbatim + * command carries the optional domain#/subdomain through executeTask. + */ + async instanceCreate(type, name, domainIndex = '', subdomain = '') { + try { + this.commands.validateCommand('instance_create', { type, name }); + const parts = ['libreportal', 'instance', 'create', type, name]; + if (domainIndex) parts.push(domainIndex); + if (subdomain) parts.push(subdomain); + // The new instance's slug — mirrors the backend's _ scheme so + // monitorTask/highlight track the right app once it appears. + const id = String(name).toLowerCase().replace(/[^a-z0-9]/g, ''); + const slug = `${type}_${id}`; + return await this.executeTask('install', slug, parts.join(' '), `New ${type} instance`); + } catch (error) { + throw new Error(`Failed to create ${type} instance "${name}": ${error.message}`); + } + } + /** * Uninstall an application */ diff --git a/containers/libreportal/frontend/core/tasks/js/task-commands.js b/containers/libreportal/frontend/core/tasks/js/task-commands.js index 5d6dd97..f7e0638 100755 --- a/containers/libreportal/frontend/core/tasks/js/task-commands.js +++ b/containers/libreportal/frontend/core/tasks/js/task-commands.js @@ -15,11 +15,16 @@ class TaskCommands { backup: 'libreportal app backup {appName}', status: 'libreportal app status {appName}', + // Multi-instance (✅ IMPLEMENTED) — provision another isolated copy of a + // multi-instance-capable app. The verbatim command (with optional + // domain#/subdomain) is built in TaskActions.instanceCreate. + instance_create: 'libreportal instance create {type} {name}', + // Docker Compose Management (✅ IMPLEMENTED) up: 'libreportal app up {appName}', down: 'libreportal app down {appName}', reload: 'libreportal app reload {appName}', - + // System Commands (✅ IMPLEMENTED) system_status: 'libreportal system status', system_update: 'libreportal system update', @@ -48,7 +53,8 @@ class TaskCommands { system_status: 'implemented', system_update: 'implemented', system_reset: 'implemented', - + instance_create: 'implemented', + // ❌ Not yet implemented in CLI restore: 'not_implemented', update_config: 'not_implemented', @@ -136,7 +142,8 @@ class TaskCommands { status: ['appName'], up: ['appName'], down: ['appName'], - reload: ['appName'] + reload: ['appName'], + instance_create: ['type', 'name'] }; const required = requiredParams[type] || []; diff --git a/containers/libreportal/frontend/core/tasks/js/task-router.js b/containers/libreportal/frontend/core/tasks/js/task-router.js index 70a24bb..ea40e33 100755 --- a/containers/libreportal/frontend/core/tasks/js/task-router.js +++ b/containers/libreportal/frontend/core/tasks/js/task-router.js @@ -20,7 +20,10 @@ class TaskRouter { switch (action) { case 'install': return await this.actions.installApp(params.appName, params.config, params.resetNetwork); - + + case 'instance_create': + return await this.actions.instanceCreate(params.type, params.name, params.domainIndex, params.subdomain); + case 'uninstall': return await this.actions.uninstallApp(params.appName, params.deleteImage, params.deleteTasks); diff --git a/scripts/cli/commands/instance/cli_instance_commands.sh b/scripts/cli/commands/instance/cli_instance_commands.sh new file mode 100644 index 0000000..b9509b9 --- /dev/null +++ b/scripts/cli/commands/instance/cli_instance_commands.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +# Instance Commands Handler +# Multi-instance lifecycle for multi-instance-capable apps. Mutating verbs route +# through the task system (mirroring `app install`): the WebUI queues a task, the +# processor re-invokes the CLI with LIBREPORTAL_TASK_EXEC=1, and only then does +# the real work run. No new mutating backend API endpoint is introduced. + +cliHandleInstanceCommands() +{ + local action="$initial_command2" + local type="$initial_command3" + local name="$initial_command4" + local domain_idx="$initial_command5" + local subdomain="$initial_command6" + + case "$action" in + "create") + if [[ -z "$type" || -z "$name" ]]; then + isNotice "Usage: libreportal instance create [domain_index] [subdomain]" + cliShowInstanceHelp + return 1 + fi + if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then + instanceCreate "$type" "$name" "$domain_idx" "$subdomain" + else + local _cmd="libreportal instance create $type $name" + [[ -n "$domain_idx" ]] && _cmd+=" $domain_idx" + [[ -n "$subdomain" ]] && _cmd+=" $subdomain" + cliTaskRun "$_cmd" "install" "${type}_$(instanceIdPart "$name")" + fi + ;; + + "remove"|"delete") + # Here $type holds the instance slug (positional reuse). + local slug="$type" + if [[ -z "$slug" ]]; then + isNotice "Usage: libreportal instance remove " + return 1 + fi + if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then + instanceRemove "$slug" + else + cliTaskRun "libreportal instance remove $slug" "uninstall" "$slug" + fi + ;; + + "list") + instanceList "$type" # $type optional = filter by app type + ;; + + *) + cliShowInstanceHelp + ;; + esac +} diff --git a/scripts/cli/commands/instance/cli_instance_header.sh b/scripts/cli/commands/instance/cli_instance_header.sh new file mode 100644 index 0000000..379f313 --- /dev/null +++ b/scripts/cli/commands/instance/cli_instance_header.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# Instance Commands Header +# Shows available instance commands and help information + +cliShowInstanceHelp() +{ + echo "" + echo "Available Instance Commands (* is required):" + echo "" + echo " Run more than one copy of a multi-instance-capable app (e.g. two" + echo " Bookstack/WordPress sites). Each instance is a full, isolated app:" + echo " its own data, DB, subdomain, backups and update cadence. Only apps" + echo " with CFG__MULTI_INSTANCE=true can be instanced." + echo "" + echo " libreportal instance create [type*] [name*] [domain#] [subdomain]" + echo " - Provision + install a new instance." + echo " type = base app slug (e.g. bookstack)" + echo " name - instance name (e.g. blog)" + echo " domain# - which CFG_DOMAIN_n to route on (default 1)" + echo " subdomain- host label (default -)" + echo " libreportal instance remove [slug*] - Uninstall + remove an instance (e.g. bookstack_blog)" + echo " libreportal instance list [type] - List instances (all, or just for one app type)" + echo "" +} diff --git a/scripts/instance/instance_create.sh b/scripts/instance/instance_create.sh new file mode 100644 index 0000000..cb6c463 --- /dev/null +++ b/scripts/instance/instance_create.sh @@ -0,0 +1,239 @@ +#!/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'." +} diff --git a/scripts/source/files/arrays/files_cli.sh b/scripts/source/files/arrays/files_cli.sh index ba59139..bc38f54 100755 --- a/scripts/source/files/arrays/files_cli.sh +++ b/scripts/source/files/arrays/files_cli.sh @@ -27,6 +27,8 @@ cli_scripts=( "cli/commands/help/cli_help_header.sh" "cli/commands/install/cli_install_commands.sh" "cli/commands/install/cli_install_header.sh" + "cli/commands/instance/cli_instance_commands.sh" + "cli/commands/instance/cli_instance_header.sh" "cli/commands/ip/cli_ip_commands.sh" "cli/commands/ip/cli_ip_header.sh" "cli/commands/peer/cli_peer_commands.sh" diff --git a/scripts/source/files/arrays/files_instance.sh b/scripts/source/files/arrays/files_instance.sh new file mode 100644 index 0000000..46fa77b --- /dev/null +++ b/scripts/source/files/arrays/files_instance.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +# This file is auto-generated by generate_arrays.sh +# Do not edit manually - run './scripts/source/files/generate_arrays.sh run' to regenerate + +instance_scripts=( + "instance/instance_create.sh" + +) diff --git a/scripts/source/files/arrays/files_source.sh b/scripts/source/files/arrays/files_source.sh index 2bfce85..55b1206 100755 --- a/scripts/source/files/arrays/files_source.sh +++ b/scripts/source/files/arrays/files_source.sh @@ -16,6 +16,7 @@ source_scripts=( "source/files/arrays/files_docker.sh" "source/files/arrays/files_function.sh" "source/files/arrays/files_install.sh" + "source/files/arrays/files_instance.sh" "source/files/arrays/files_logs.sh" "source/files/arrays/files_menu.sh" "source/files/arrays/files_migrate.sh" diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index eff41dc..c42c36d 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -279,6 +279,7 @@ declare -gA LP_FN_MAP=( [cliHandleFirewallCommands]="cli/commands/firewall/cli_firewall_commands.sh" [cliHandleHelpCommands]="cli/commands/help/cli_help_commands.sh" [cliHandleInstallCommands]="cli/commands/install/cli_install_commands.sh" + [cliHandleInstanceCommands]="cli/commands/instance/cli_instance_commands.sh" [cliHandleIPCommands]="cli/commands/ip/cli_ip_commands.sh" [cliHandlePeerCommands]="cli/commands/peer/cli_peer_commands.sh" [cliHandleRegenCommands]="cli/commands/regen/cli_regen_commands.sh" @@ -302,6 +303,7 @@ declare -gA LP_FN_MAP=( [cliShowDockertypeHelp]="cli/commands/dockertype/cli_dockertype_header.sh" [cliShowHelpHelp]="cli/commands/help/cli_help_header.sh" [cliShowInstallHelp]="cli/commands/install/cli_install_header.sh" + [cliShowInstanceHelp]="cli/commands/instance/cli_instance_header.sh" [cliShowIPHelp]="cli/commands/ip/cli_ip_header.sh" [cliShowPeerHelp]="cli/commands/peer/cli_peer_header.sh" [cliShowRegenHelp]="cli/commands/regen/cli_regen_header.sh" @@ -519,6 +521,15 @@ declare -gA LP_FN_MAP=( [installSwapfile]="install/install_swapfile.sh" [installUFW]="install/install_ufw.sh" [installUFWDocker]="install/install_ufwd.sh" + [instanceCreate]="instance/instance_create.sh" + [instanceIdPart]="instance/instance_create.sh" + [instanceList]="instance/instance_create.sh" + [instanceRemove]="instance/instance_create.sh" + [_instanceRewriteCompose]="instance/instance_create.sh" + [_instanceRewriteTools]="instance/instance_create.sh" + [_instanceSetCfg]="instance/instance_create.sh" + [_instanceSetSubdomain]="instance/instance_create.sh" + [instanceTypeCfg]="instance/instance_create.sh" [_invidiousBcrypt]="invidious/scripts/invidious_auth.sh" [_invidiousPsql]="invidious/scripts/invidious_auth.sh" [invidiousToolsMenu]="menu/tools/manage_invidious.sh" @@ -1226,6 +1237,7 @@ declare -gA LP_FN_ROOT=( [cliHandleFirewallCommands]="scripts" [cliHandleHelpCommands]="scripts" [cliHandleInstallCommands]="scripts" + [cliHandleInstanceCommands]="scripts" [cliHandleIPCommands]="scripts" [cliHandlePeerCommands]="scripts" [cliHandleRegenCommands]="scripts" @@ -1249,6 +1261,7 @@ declare -gA LP_FN_ROOT=( [cliShowDockertypeHelp]="scripts" [cliShowHelpHelp]="scripts" [cliShowInstallHelp]="scripts" + [cliShowInstanceHelp]="scripts" [cliShowIPHelp]="scripts" [cliShowPeerHelp]="scripts" [cliShowRegenHelp]="scripts" @@ -1466,6 +1479,15 @@ declare -gA LP_FN_ROOT=( [installSwapfile]="scripts" [installUFW]="scripts" [installUFWDocker]="scripts" + [instanceCreate]="scripts" + [instanceIdPart]="scripts" + [instanceList]="scripts" + [instanceRemove]="scripts" + [_instanceRewriteCompose]="scripts" + [_instanceRewriteTools]="scripts" + [_instanceSetCfg]="scripts" + [_instanceSetSubdomain]="scripts" + [instanceTypeCfg]="scripts" [_invidiousBcrypt]="containers" [_invidiousPsql]="containers" [invidiousToolsMenu]="scripts" @@ -2194,6 +2216,7 @@ cliHandleDockertypeCommands() { source "${install_scripts_dir}cli/commands/docke cliHandleFirewallCommands() { source "${install_scripts_dir}cli/commands/firewall/cli_firewall_commands.sh"; cliHandleFirewallCommands "$@"; } cliHandleHelpCommands() { source "${install_scripts_dir}cli/commands/help/cli_help_commands.sh"; cliHandleHelpCommands "$@"; } cliHandleInstallCommands() { source "${install_scripts_dir}cli/commands/install/cli_install_commands.sh"; cliHandleInstallCommands "$@"; } +cliHandleInstanceCommands() { source "${install_scripts_dir}cli/commands/instance/cli_instance_commands.sh"; cliHandleInstanceCommands "$@"; } cliHandleIPCommands() { source "${install_scripts_dir}cli/commands/ip/cli_ip_commands.sh"; cliHandleIPCommands "$@"; } cliHandlePeerCommands() { source "${install_scripts_dir}cli/commands/peer/cli_peer_commands.sh"; cliHandlePeerCommands "$@"; } cliHandleRegenCommands() { source "${install_scripts_dir}cli/commands/regen/cli_regen_commands.sh"; cliHandleRegenCommands "$@"; } @@ -2217,6 +2240,7 @@ cliShowDebugHelp() { source "${install_scripts_dir}cli/commands/debug/cli_debug_ cliShowDockertypeHelp() { source "${install_scripts_dir}cli/commands/dockertype/cli_dockertype_header.sh"; cliShowDockertypeHelp "$@"; } cliShowHelpHelp() { source "${install_scripts_dir}cli/commands/help/cli_help_header.sh"; cliShowHelpHelp "$@"; } cliShowInstallHelp() { source "${install_scripts_dir}cli/commands/install/cli_install_header.sh"; cliShowInstallHelp "$@"; } +cliShowInstanceHelp() { source "${install_scripts_dir}cli/commands/instance/cli_instance_header.sh"; cliShowInstanceHelp "$@"; } cliShowIPHelp() { source "${install_scripts_dir}cli/commands/ip/cli_ip_header.sh"; cliShowIPHelp "$@"; } cliShowPeerHelp() { source "${install_scripts_dir}cli/commands/peer/cli_peer_header.sh"; cliShowPeerHelp "$@"; } cliShowRegenHelp() { source "${install_scripts_dir}cli/commands/regen/cli_regen_header.sh"; cliShowRegenHelp "$@"; } @@ -2434,6 +2458,15 @@ installSSLCertificate() { source "${install_scripts_dir}install/install_certific installSwapfile() { source "${install_scripts_dir}install/install_swapfile.sh"; installSwapfile "$@"; } installUFW() { source "${install_scripts_dir}install/install_ufw.sh"; installUFW "$@"; } installUFWDocker() { source "${install_scripts_dir}install/install_ufwd.sh"; installUFWDocker "$@"; } +instanceCreate() { source "${install_scripts_dir}instance/instance_create.sh"; instanceCreate "$@"; } +instanceIdPart() { source "${install_scripts_dir}instance/instance_create.sh"; instanceIdPart "$@"; } +instanceList() { source "${install_scripts_dir}instance/instance_create.sh"; instanceList "$@"; } +instanceRemove() { source "${install_scripts_dir}instance/instance_create.sh"; instanceRemove "$@"; } +_instanceRewriteCompose() { source "${install_scripts_dir}instance/instance_create.sh"; _instanceRewriteCompose "$@"; } +_instanceRewriteTools() { source "${install_scripts_dir}instance/instance_create.sh"; _instanceRewriteTools "$@"; } +_instanceSetCfg() { source "${install_scripts_dir}instance/instance_create.sh"; _instanceSetCfg "$@"; } +_instanceSetSubdomain() { source "${install_scripts_dir}instance/instance_create.sh"; _instanceSetSubdomain "$@"; } +instanceTypeCfg() { source "${install_scripts_dir}instance/instance_create.sh"; instanceTypeCfg "$@"; } _invidiousBcrypt() { source "${install_containers_dir}invidious/scripts/invidious_auth.sh"; _invidiousBcrypt "$@"; } _invidiousPsql() { source "${install_containers_dir}invidious/scripts/invidious_auth.sh"; _invidiousPsql "$@"; } invidiousToolsMenu() { source "${install_scripts_dir}menu/tools/manage_invidious.sh"; invidiousToolsMenu "$@"; }