Instance install (bugs found by running one end to end): - The cloned compose kept the TYPE's tag namespace (#LIBREPORTAL|BOOKSTACK_APP_KEY_1_TAG|...) while the config had been re-namespaced to CFG_<SLUG>_*, so tagsProcessorAppConfigValues matched nothing, the placeholders survived and the pre-start guard refused to launch. Rewrite the tag names and *_DATA tokens too — narrowly, so an app whose compose sets a real env var named after itself is untouched. - Tools/hooks kept uppercase CFG_<TYPE>_ reads, so an instance provisioned itself from the type's config and ignored its own values. - Cloned hooks were never loaded: both loaders run at startup, before the instance dir exists, so _appCallHook's `declare -F` found nothing and every <slug>_install_* hook silently no-opped — for bookstack that is the readiness probe and the admin bootstrap. Source the instance's own scripts in-process, then regen arrays + manifest for later runs. - bookstack's hook hardcoded the container name after `docker exec -e ...` flags, where the rewriter can't see it, so an instance's admin bootstrap ran against the BASE app's container — including a tinker DELETE of a user. Target "$app_name" instead, and teach the rewriter the container="<type>" assignment form used by auth adapters. network_resources uniqueness: UNIQUE(resource_type, resource_value) is right for 'ip' and 'port' but the port-tag writer stores descriptive rows in the same table with INSERT OR REPLACE, so every install DELETED the matching row from whichever app held it. traefik_managed and url_accessible are booleans, so the whole table could only ever hold one row of each. Observed live: installing a second bookstack took all four traefik_managed/url_accessible rows from stoat and bookstack, and removing that instance took the stolen rows with it. Replace it with a partial unique index scoped to ip/port, and migrate existing databases in place (SQLite can't drop a constraint, so the table is rebuilt inside a transaction). The migration is invoked from portUpdateComposeTags, not just databaseCreateTables — the latter only runs from startPreInstall, which a working install never re-runs. Verified: two bookstacks now hold port_tag_internal=80, traefik_managed and url_accessible simultaneously; duplicate host ports and IPs are still rejected; instance installs, serves HTTP 200, provisions its own admin in its own database, and removes cleanly with no orphan rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
11 KiB
Bash
Executable File
201 lines
11 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
databaseCreateTables()
|
|
{
|
|
if command -v sqlite3 &> /dev/null; then
|
|
setup_table_name=path
|
|
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
|
|
# Table info here
|
|
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (path TEXT);")
|
|
checkSuccess "Creating $setup_table_name table"
|
|
fi
|
|
|
|
setup_table_name=sysupdate
|
|
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
|
|
# Table info here
|
|
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (date DATE, time TIME);")
|
|
checkSuccess "Creating $setup_table_name table"
|
|
fi
|
|
|
|
setup_table_name=options
|
|
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
|
|
# Table info here
|
|
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (option TEXT UNIQUE, content TEXT);")
|
|
checkSuccess "Creating $setup_table_name table"
|
|
fi
|
|
|
|
setup_table_name=apps
|
|
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
|
|
# Table info here
|
|
# status = 1 = installed, 0 uninstalled
|
|
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (name TEXT UNIQUE, status DATE, install_date DATE, install_time TIME, uninstall_date DATE, uninstall_time TIME);")
|
|
checkSuccess "Creating $setup_table_name table"
|
|
fi
|
|
|
|
setup_table_name=backups
|
|
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
|
|
# Table info here
|
|
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, date DATE, time TIME);")
|
|
checkSuccess "Creating $setup_table_name table"
|
|
fi
|
|
|
|
setup_table_name=restores
|
|
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
|
|
# Table info here
|
|
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, date DATE, time TIME);")
|
|
checkSuccess "Creating $setup_table_name table"
|
|
fi
|
|
|
|
setup_table_name=migrations
|
|
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
|
|
# Table info here
|
|
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, date DATE, time TIME);")
|
|
checkSuccess "Creating $setup_table_name table"
|
|
fi
|
|
|
|
setup_table_name=ssh
|
|
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
|
|
# Table info here
|
|
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (id INTEGER PRIMARY KEY AUTOINCREMENT, ip TEXT, date DATE, time TIME);")
|
|
checkSuccess "Creating $setup_table_name table"
|
|
fi
|
|
|
|
setup_table_name=ssh_keys
|
|
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
|
|
# Table info here
|
|
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (name TEXT UNIQUE, hash TEXT, date DATE, time TIME);")
|
|
checkSuccess "Creating $setup_table_name table"
|
|
fi
|
|
|
|
setup_table_name=peers
|
|
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
|
|
# Named other LibrePortal instances. kind selects the transport:
|
|
# backup-channel Phase 1/2 — friendly label over a hostname
|
|
# that already shows up in a shared backup repo
|
|
# direct-ssh-direct Phase 3 — reachable peer over plain SSH
|
|
# direct-ssh-via-relay Phase 3b — peer over Connect's blind relay
|
|
# config_json carries kind-specific knobs (hostname, loc_idx, pubkey
|
|
# fingerprint, relay token, etc.) so adding new kinds doesn't need
|
|
# another schema migration.
|
|
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT UNIQUE NOT NULL,
|
|
kind TEXT NOT NULL DEFAULT 'backup-channel',
|
|
config_json TEXT NOT NULL DEFAULT '{}',
|
|
status TEXT DEFAULT 'unknown',
|
|
last_seen TEXT,
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
);")
|
|
checkSuccess "Creating $setup_table_name table"
|
|
local result; result=$(sqlite3 "$docker_dir/$db_file" "CREATE INDEX IF NOT EXISTS idx_peers_name ON peers(name);")
|
|
local result; result=$(sqlite3 "$docker_dir/$db_file" "CREATE INDEX IF NOT EXISTS idx_peers_kind ON peers(kind);")
|
|
fi
|
|
|
|
setup_table_name=network_resources
|
|
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
|
|
# Simple unified network resources table - replaces all complex network tables
|
|
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
app_name TEXT NOT NULL,
|
|
resource_type TEXT NOT NULL, -- 'ip' or 'port'
|
|
resource_value TEXT NOT NULL, -- '172.20.0.10' or '3001'
|
|
service_name TEXT DEFAULT 'main', -- service within app
|
|
parent_service TEXT DEFAULT NULL, -- parent Docker service for ports
|
|
status TEXT DEFAULT 'active',
|
|
created_date DATE DEFAULT CURRENT_DATE,
|
|
created_time TIME DEFAULT CURRENT_TIME,
|
|
UNIQUE(app_name, resource_type, service_name)
|
|
);")
|
|
checkSuccess "Creating unified network_resources table"
|
|
|
|
# Create simple indexes for performance
|
|
local result; result=$(sqlite3 "$docker_dir/$db_file" "CREATE INDEX IF NOT EXISTS idx_network_resources_app ON network_resources(app_name);")
|
|
checkSuccess "Creating network resources app index"
|
|
local result; result=$(sqlite3 "$docker_dir/$db_file" "CREATE INDEX IF NOT EXISTS idx_network_resources_type ON network_resources(resource_type);")
|
|
checkSuccess "Creating network resources type index"
|
|
local result; result=$(sqlite3 "$docker_dir/$db_file" "CREATE INDEX IF NOT EXISTS idx_network_resources_value ON network_resources(resource_value);")
|
|
checkSuccess "Creating network resources value index"
|
|
local result; result=$(sqlite3 "$docker_dir/$db_file" "CREATE INDEX IF NOT EXISTS idx_network_resources_status ON network_resources(status);")
|
|
checkSuccess "Creating network resources status index"
|
|
local result; result=$(sqlite3 "$docker_dir/$db_file" "CREATE INDEX IF NOT EXISTS idx_network_resources_parent_service ON network_resources(parent_service);")
|
|
checkSuccess "Creating network resources parent service index"
|
|
fi
|
|
|
|
# Runs for fresh AND existing databases (this function is re-run on every
|
|
# startup), so an install created before the constraint was scoped is
|
|
# repaired in place.
|
|
databaseMigrateNetworkResourcesUnique
|
|
else
|
|
echo "SQLite3 is not installed. Skipping table creation."
|
|
fi
|
|
}
|
|
|
|
# Scope the global-uniqueness constraint on network_resources to the resources
|
|
# that are actually globally unique.
|
|
#
|
|
# The table shipped with UNIQUE(resource_type, resource_value). That is right for
|
|
# 'ip' and 'port' — no two apps may hold the same host port or container IP — but
|
|
# the port-tag writer stores descriptive rows in the same table and writes them
|
|
# with INSERT OR REPLACE, so each install DELETED the matching row from whichever
|
|
# app held it:
|
|
# port_tag_internal the CONTAINER-side port: every web app uses 80
|
|
# traefik_managed 'true'/'false' — two possible values, so the whole table
|
|
# url_accessible could only ever hold ONE row of each, for one app
|
|
# Consequences: the firewall rebuild's traefik_managed LEFT JOIN reads NULL for
|
|
# every app but the last one installed, and in rooted mode a stolen
|
|
# port_tag_internal makes ufw-docker fall back to the EXTERNAL port — the exact
|
|
# "cannot find the published port" failure firewall_rebuild_from_db.sh warns
|
|
# about. Rows already lost are not reconstructable here; each app repopulates its
|
|
# own on the next `libreportal app install <app>`.
|
|
#
|
|
# SQLite cannot drop a table constraint, so this rebuilds the table when the
|
|
# legacy constraint is still present, then enforces the narrow rule as a partial
|
|
# unique index. Idempotent: once migrated, the guard skips it.
|
|
databaseMigrateNetworkResourcesUnique()
|
|
{
|
|
local table_sql
|
|
table_sql=$(sqlite3 "$docker_dir/$db_file" "SELECT sql FROM sqlite_master WHERE type='table' AND name='network_resources';" 2>/dev/null)
|
|
[[ -n "$table_sql" ]] || return 0
|
|
|
|
if [[ "$table_sql" == *"UNIQUE(resource_type, resource_value)"* ]]; then
|
|
# Single transaction: either the whole swap lands or the old table stays.
|
|
local result; result=$(sqlite3 "$docker_dir/$db_file" "
|
|
PRAGMA foreign_keys=off;
|
|
BEGIN TRANSACTION;
|
|
CREATE TABLE network_resources_migrated (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
app_name TEXT NOT NULL,
|
|
resource_type TEXT NOT NULL,
|
|
resource_value TEXT NOT NULL,
|
|
service_name TEXT DEFAULT 'main',
|
|
parent_service TEXT DEFAULT NULL,
|
|
status TEXT DEFAULT 'active',
|
|
created_date DATE DEFAULT CURRENT_DATE,
|
|
created_time TIME DEFAULT CURRENT_TIME,
|
|
UNIQUE(app_name, resource_type, service_name)
|
|
);
|
|
INSERT INTO network_resources_migrated
|
|
SELECT id, app_name, resource_type, resource_value, service_name,
|
|
parent_service, status, created_date, created_time
|
|
FROM network_resources;
|
|
DROP TABLE network_resources;
|
|
ALTER TABLE network_resources_migrated RENAME TO network_resources;
|
|
CREATE INDEX IF NOT EXISTS idx_network_resources_app ON network_resources(app_name);
|
|
CREATE INDEX IF NOT EXISTS idx_network_resources_type ON network_resources(resource_type);
|
|
CREATE INDEX IF NOT EXISTS idx_network_resources_value ON network_resources(resource_value);
|
|
CREATE INDEX IF NOT EXISTS idx_network_resources_status ON network_resources(status);
|
|
CREATE INDEX IF NOT EXISTS idx_network_resources_parent_service ON network_resources(parent_service);
|
|
COMMIT;
|
|
" 2>&1)
|
|
if [[ -n "$result" ]]; then
|
|
isNotice "network_resources migration reported: $result"
|
|
fi
|
|
checkSuccess "Scoping network_resources uniqueness to ip/port"
|
|
fi
|
|
|
|
# The narrow rule, as a partial index — still blocks two apps claiming the
|
|
# same host port or IP, and lets the descriptive rows coexist. Silent: it is
|
|
# a no-op on every startup after the first.
|
|
local result; result=$(sqlite3 "$docker_dir/$db_file" "CREATE UNIQUE INDEX IF NOT EXISTS idx_network_resources_global_value ON network_resources(resource_type, resource_value) WHERE resource_type IN ('ip','port');" 2>/dev/null)
|
|
}
|