Refactor multi-tenant deployment: update port handling in scripts, add tenant port registration, and enhance Docker configurations

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-28 16:36:55 +02:00
parent 14c4192306
commit 7873c45cfc
7 changed files with 257 additions and 55 deletions
-1
View File
@@ -1,4 +1,3 @@
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=10000
INVENTAR_HTTPS_PORT=10001
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:latest
+6
View File
@@ -0,0 +1,6 @@
services:
app:
working_dir: /app/Web
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
image: ghcr.io/aiirondev/legendary-octo-garbanzo:latest
build: null
+57 -4
View File
@@ -10,6 +10,8 @@ Each tenant can support up to 20+ users with isolated data and resource pools.
from flask import request, g, has_request_context
from functools import wraps
import logging
import os
import re
import settings as cfg
logger = logging.getLogger(__name__)
@@ -30,6 +32,46 @@ def _get_nested_value(source, path, default=None):
return current
def _parse_port_from_host(host):
"""Parse host header and return (hostname, port) when a numeric port is present."""
if host.startswith('['):
# IPv6 with port: [::1]:10000
if ']:' in host:
host_part, _, port_part = host.rpartition(']:')
return host_part + ']', port_part
return host, None
if host.count(':') == 1:
hostname, port = host.split(':', 1)
if port.isdigit():
return hostname, port
return host, None
def _tenant_id_for_port(port):
"""Map a host port to a registered tenant ID via tenant configs or env overrides."""
for tenant_id, config in TENANT_REGISTRY.items():
if isinstance(config, dict) and config.get('port') is not None:
try:
configured_port = str(int(config.get('port')))
except (TypeError, ValueError):
continue
if configured_port == str(port):
return tenant_id
port_map = os.getenv('INVENTAR_TENANT_PORT_MAP', '').strip()
if port_map:
for mapping in re.split(r'[;,\s]+', port_map):
if '=' not in mapping:
continue
key, value = mapping.split('=', 1)
if key.strip() == str(port):
return value.strip()
return None
def get_tenant_config(tenant_id=None):
"""Return the registered config for a tenant, falling back to default."""
if tenant_id is None:
@@ -52,19 +94,20 @@ def is_tenant_module_enabled(module_name, tenant_id=None, default=False):
class TenantContext:
"""
Manages current tenant context for request lifecycle.
Automatically resolves tenant from subdomain or request header.
Automatically resolves tenant from port, header, or subdomain.
"""
def __init__(self):
self.tenant_id = None
self.db_name = None
self.subdomain = None
self.port = None
self.config = {}
def resolve_tenant(self):
"""
Resolve tenant from request context.
Priority: Header > Subdomain > Default
Priority: Header > Port mapping > Subdomain > Default
"""
if not has_request_context():
return None
@@ -76,8 +119,18 @@ class TenantContext:
self.config = get_tenant_config(tenant_from_header)
return self._get_db_name(tenant_from_header)
# Priority 2: Subdomain extraction
# Priority 2: Port-based tenant mapping
host = request.host.lower()
_, port = _parse_port_from_host(host)
self.port = port
if port:
tenant_from_port = _tenant_id_for_port(port)
if tenant_from_port:
self.tenant_id = tenant_from_port
self.config = get_tenant_config(tenant_from_port)
return self._get_db_name(tenant_from_port)
# Priority 3: Subdomain extraction
parts = host.split('.')
# Extract subdomain from host
@@ -93,7 +146,7 @@ class TenantContext:
self.config = get_tenant_config(potential_subdomain)
return self._get_db_name(potential_subdomain)
# Fallback to default tenant if no subdomain detected
# Fallback to default tenant if no tenant identifier found
self.tenant_id = 'default'
self.config = get_tenant_config('default')
return self._get_db_name('default')
+54 -6
View File
@@ -7,23 +7,62 @@ if [ ! -f "docker-compose-multitenant.yml" ]; then
exit 1
fi
CONFIG_FILE="$PWD/config.json"
show_help() {
echo "Usage: ./manage-tenant.sh [COMMAND] [OPTIONS]"
echo ""
echo "Commands:"
echo " add <tenant_id> Add a new tenant (initializes database)"
echo " remove <tenant_id> Remove a tenant completely (deletes data!)"
echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
echo " restart-all Restart all application containers (zero-downtime reload)"
echo " list List active tenants"
echo " add <tenant_id> [port] Add a new tenant (initializes database)"
echo " remove <tenant_id> Remove a tenant completely (deletes data!)"
echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
echo " restart-all Restart all application containers (zero-downtime reload)"
echo " list List active tenants"
echo ""
echo "Examples:"
echo " ./manage-tenant.sh add school_a"
echo " ./manage-tenant.sh add school_a 10001"
echo " ./manage-tenant.sh remove test_tenant"
echo " ./manage-tenant.sh restart-all"
exit 1
}
register_tenant_port() {
local tenant_id="$1"
local port="$2"
if python3 - <<'PY' "$CONFIG_FILE" "$tenant_id" "$port"
import json, sys, os
path, tenant_id, port_str = sys.argv[1], sys.argv[2], sys.argv[3]
if not os.path.isfile(path):
print(f"Error: config file not found: {path}", file=sys.stderr)
sys.exit(1)
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants')
if tenants is None or not isinstance(tenants, dict):
tenants = {}
for tid, conf in tenants.items():
if isinstance(conf, dict) and str(conf.get('port')) == port_str and tid != tenant_id:
print(f"Error: port {port_str} is already mapped to tenant {tid}", file=sys.stderr)
sys.exit(2)
existing = tenants.get(tenant_id)
if existing is None or not isinstance(existing, dict):
existing = {}
existing['port'] = int(port_str)
tenants[tenant_id] = existing
cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
print(f"Registered tenant port {port_str} for {tenant_id}")
PY
then
echo "Tenant $tenant_id port $port registered in config.json"
else
echo "Failed to register tenant port $port for $tenant_id"
exit 1
fi
}
if [ -z "$1" ]; then
show_help
fi
@@ -55,6 +94,15 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
echo "Warning: Application container is not running. Please start the multi-tenant system first."
echo "Data will be initialized upon first access by the tenant."
fi
PORT_ARG="$3"
if [ -n "$PORT_ARG" ]; then
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
echo "Error: Port must be a numeric value."
exit 1
fi
register_tenant_port "$TENANT_ID" "$PORT_ARG"
fi
;;
remove)
+32 -11
View File
@@ -1,14 +1,35 @@
#!/bin/bash
# Nur die App neu bauen und starten, ohne den Tunnel oder die DB zu killen
docker compose up -d --build app
# Optional: Alles aufräumen, was nicht mehr gebraucht wird
docker image prune -f
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
cd "$SCRIPT_DIR"
"$SCRIPT_DIR/stop.sh" "$@"
"$SCRIPT_DIR/start.sh" "$@"
COMPOSE_FILE="docker-compose-multitenant.yml"
while [[ $# -gt 0 ]]; do
case "$1" in
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
*)
shift
;;
esac
done
if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker command not found. Install Docker first."
exit 1
fi
echo "Rebuilding and restarting app container using $COMPOSE_FILE..."
docker compose -f "$COMPOSE_FILE" up -d --build app
echo "Cleaning up unused Docker images..."
docker image prune -f
echo "App rebuild complete."
+92 -28
View File
@@ -14,8 +14,14 @@ if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
IS_ROOT="false"
if [ "$(id -u)" -eq 0 ]; then
IS_ROOT="true"
fi
NUITKA_BUILD_VALUE="0"
HTTP_PORT_VALUE="10000"
HTTP_PORTS_VALUE=""
DEFAULT_TENANT_PORT_START="${INVENTAR_TENANT_PORT_START:-10000}"
CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}"
APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-$APP_IMAGE_REPO:latest}"
@@ -114,7 +120,11 @@ ensure_runtime_dependencies() {
local missing=()
if ! command -v docker >/dev/null 2>&1; then
install_docker_engine
if [ "$IS_ROOT" = "true" ]; then
install_docker_engine
else
missing+=(docker)
fi
fi
if ! docker compose version >/dev/null 2>&1; then
@@ -138,14 +148,20 @@ ensure_runtime_dependencies() {
fi
if [ "${#missing[@]}" -gt 0 ]; then
echo "Installing missing dependencies: ${missing[*]}"
apt_install "${missing[@]}"
if [ "$IS_ROOT" = "true" ]; then
echo "Installing missing dependencies: ${missing[*]}"
apt_install "${missing[@]}"
else
echo "ERROR: Missing dependencies: ${missing[*]}"
echo "Please install the missing tools or run this script as root."
exit 1
fi
fi
if command -v systemctl >/dev/null 2>&1; then
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true
if [ "$IS_ROOT" = "true" ] && command -v systemctl >/dev/null 2>&1; then
systemctl enable --now docker >/dev/null 2>&1 || true
if cron_setup_enabled; then
$SUDO systemctl enable --now cron >/dev/null 2>&1 || true
systemctl enable --now cron >/dev/null 2>&1 || true
fi
fi
}
@@ -158,6 +174,11 @@ setup_boot_autostart_service() {
return 0
fi
if [ "$IS_ROOT" != "true" ]; then
echo "Skipping systemd autostart setup when not running as root."
return 0
fi
if ! command -v systemctl >/dev/null 2>&1; then
return 0
fi
@@ -199,6 +220,11 @@ setup_scheduled_jobs() {
return 0
fi
if [ "$IS_ROOT" != "true" ]; then
echo "Skipping cron job setup when not running as root."
return 0
fi
if ! command -v crontab >/dev/null 2>&1; then
echo "Warning: crontab not available, skipping nightly update setup"
return 0
@@ -209,21 +235,12 @@ setup_scheduled_jobs() {
backup_line="30 2 * * * cd $SCRIPT_DIR && ./backup.sh --mode auto >> $SCRIPT_DIR/logs/backup.log 2>&1"
local existing_cron
if [ "$(id -u)" -eq 0 ]; then
existing_cron="$(crontab -l 2>/dev/null || true)"
{
printf '%s\n' "$existing_cron" | grep -vF "$SCRIPT_DIR/update.sh" | grep -vF "$SCRIPT_DIR/backup-docker.sh" | grep -vF "$SCRIPT_DIR/backup.sh" || true
echo "$backup_line"
echo "$update_line"
} | crontab -
else
existing_cron="$($SUDO crontab -l 2>/dev/null || true)"
{
printf '%s\n' "$existing_cron" | grep -vF "$SCRIPT_DIR/update.sh" | grep -vF "$SCRIPT_DIR/backup-docker.sh" | grep -vF "$SCRIPT_DIR/backup.sh" || true
echo "$backup_line"
echo "$update_line"
} | $SUDO crontab -
fi
existing_cron="$(crontab -l 2>/dev/null || true)"
{
printf '%s\n' "$existing_cron" | grep -vF "$SCRIPT_DIR/update.sh" | grep -vF "$SCRIPT_DIR/backup-docker.sh" | grep -vF "$SCRIPT_DIR/backup.sh" || true
echo "$backup_line"
echo "$update_line"
} | crontab -
echo "Nightly backup scheduled at 02:30"
echo "Nightly auto-update scheduled at 03:00"
@@ -384,25 +401,56 @@ find_free_port() {
echo "$port"
}
parse_port_list() {
local raw="$1"
local port
local ports=()
raw="${raw//,/ }"
for port in $raw; do
port="${port//[[:space:]]/}"
if [ -n "$port" ] && printf '%s\n' "$port" | grep -qE '^[0-9]+$'; then
ports+=("$port")
fi
done
printf '%s\n' "${ports[@]}"
}
configure_host_ports() {
local requested_http
local requested_ports
requested_http=""
requested_ports=""
if [ -f "$ENV_FILE" ]; then
requested_http="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
requested_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
fi
if [ -z "$requested_http" ]; then
requested_http="$DEFAULT_TENANT_PORT_START"
if [ -n "${INVENTAR_HTTP_PORTS:-}" ]; then
requested_ports="$INVENTAR_HTTP_PORTS"
fi
if ! port_in_use "$requested_http"; then
HTTP_PORT_VALUE="$requested_http"
return
if [ -n "${INVENTAR_HTTP_PORT:-}" ] && [ -z "$requested_ports" ]; then
requested_ports="$INVENTAR_HTTP_PORT"
fi
HTTP_PORT_VALUE="$(find_free_port "$DEFAULT_TENANT_PORT_START")"
echo "Host port $requested_http is already occupied. Assigned new tenant port: $HTTP_PORT_VALUE"
if [ -n "$requested_ports" ]; then
mapfile -t ports < <(parse_port_list "$requested_ports")
fi
if [ ${#ports[@]} -gt 0 ]; then
HTTP_PORTS_VALUE="${ports[*]}"
HTTP_PORT_VALUE="${ports[0]}"
else
HTTP_PORT_VALUE="$DEFAULT_TENANT_PORT_START"
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
fi
if port_in_use "$HTTP_PORT_VALUE"; then
HTTP_PORT_VALUE="$(find_free_port "$DEFAULT_TENANT_PORT_START")"
echo "Host port ${ports[0]:-$DEFAULT_TENANT_PORT_START} is already occupied. Assigned new tenant port: $HTTP_PORT_VALUE"
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
fi
}
ensure_min_docker_disk_space() {
@@ -440,6 +488,7 @@ write_env_file() {
cat > "$ENV_FILE" <<EOF
NUITKA_BUILD=$NUITKA_BUILD_VALUE
INVENTAR_HTTP_PORT=$HTTP_PORT_VALUE
INVENTAR_HTTP_PORTS=${HTTP_PORTS_VALUE// /,}
INVENTAR_APP_IMAGE=$APP_IMAGE_VALUE
EOF
}
@@ -453,6 +502,21 @@ services:
image: ${APP_IMAGE_VALUE}
build: null
EOF
if [ -n "$HTTP_PORTS_VALUE" ]; then
local ports_array
read -r -a ports_array <<<"$HTTP_PORTS_VALUE"
if [ "${#ports_array[@]}" -gt 1 ]; then
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
ports:
EOF
for port in "${ports_array[@]}"; do
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
- "$port:8000"
EOF
done
fi
fi
}
verify_stack_health() {
+16 -5
View File
@@ -38,6 +38,11 @@ if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
IS_ROOT="false"
if [ "$(id -u)" -eq 0 ]; then
IS_ROOT="true"
fi
apt_install() {
$SUDO apt-get update -y
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"
@@ -47,7 +52,7 @@ ensure_runtime_dependencies() {
local missing=()
if ! command -v docker >/dev/null 2>&1; then
missing+=(docker.io)
missing+=(docker)
fi
if ! docker compose version >/dev/null 2>&1; then
@@ -67,12 +72,18 @@ ensure_runtime_dependencies() {
fi
if [ "${#missing[@]}" -gt 0 ]; then
log_message "Installing missing dependencies: ${missing[*]}"
apt_install "${missing[@]}"
if [ "$IS_ROOT" = "true" ]; then
log_message "Installing missing dependencies: ${missing[*]}"
apt_install "${missing[@]}"
else
log_message "ERROR: Missing dependencies: ${missing[*]}"
log_message "Install the missing tools manually or re-run as root."
exit 1
fi
fi
if command -v systemctl >/dev/null 2>&1; then
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true
if [ "$IS_ROOT" = "true" ] && command -v systemctl >/dev/null 2>&1; then
systemctl enable --now docker >/dev/null 2>&1 || true
fi
}