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 NUITKA_BUILD=0
INVENTAR_HTTP_PORT=10000 INVENTAR_HTTP_PORT=10000
INVENTAR_HTTPS_PORT=10001
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:latest 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 flask import request, g, has_request_context
from functools import wraps from functools import wraps
import logging import logging
import os
import re
import settings as cfg import settings as cfg
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,6 +32,46 @@ def _get_nested_value(source, path, default=None):
return current 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): def get_tenant_config(tenant_id=None):
"""Return the registered config for a tenant, falling back to default.""" """Return the registered config for a tenant, falling back to default."""
if tenant_id is None: if tenant_id is None:
@@ -52,19 +94,20 @@ def is_tenant_module_enabled(module_name, tenant_id=None, default=False):
class TenantContext: class TenantContext:
""" """
Manages current tenant context for request lifecycle. 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): def __init__(self):
self.tenant_id = None self.tenant_id = None
self.db_name = None self.db_name = None
self.subdomain = None self.subdomain = None
self.port = None
self.config = {} self.config = {}
def resolve_tenant(self): def resolve_tenant(self):
""" """
Resolve tenant from request context. Resolve tenant from request context.
Priority: Header > Subdomain > Default Priority: Header > Port mapping > Subdomain > Default
""" """
if not has_request_context(): if not has_request_context():
return None return None
@@ -76,8 +119,18 @@ class TenantContext:
self.config = get_tenant_config(tenant_from_header) self.config = get_tenant_config(tenant_from_header)
return self._get_db_name(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() 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('.') parts = host.split('.')
# Extract subdomain from host # Extract subdomain from host
@@ -93,7 +146,7 @@ class TenantContext:
self.config = get_tenant_config(potential_subdomain) self.config = get_tenant_config(potential_subdomain)
return self._get_db_name(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.tenant_id = 'default'
self.config = get_tenant_config('default') self.config = get_tenant_config('default')
return self._get_db_name('default') return self._get_db_name('default')
+50 -2
View File
@@ -7,23 +7,62 @@ if [ ! -f "docker-compose-multitenant.yml" ]; then
exit 1 exit 1
fi fi
CONFIG_FILE="$PWD/config.json"
show_help() { show_help() {
echo "Usage: ./manage-tenant.sh [COMMAND] [OPTIONS]" echo "Usage: ./manage-tenant.sh [COMMAND] [OPTIONS]"
echo "" echo ""
echo "Commands:" echo "Commands:"
echo " add <tenant_id> Add a new tenant (initializes database)" echo " add <tenant_id> [port] Add a new tenant (initializes database)"
echo " remove <tenant_id> Remove a tenant completely (deletes data!)" echo " remove <tenant_id> Remove a tenant completely (deletes data!)"
echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)" echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
echo " restart-all Restart all application containers (zero-downtime reload)" echo " restart-all Restart all application containers (zero-downtime reload)"
echo " list List active tenants" echo " list List active tenants"
echo "" echo ""
echo "Examples:" 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 remove test_tenant"
echo " ./manage-tenant.sh restart-all" echo " ./manage-tenant.sh restart-all"
exit 1 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 if [ -z "$1" ]; then
show_help show_help
fi 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 "Warning: Application container is not running. Please start the multi-tenant system first."
echo "Data will be initialized upon first access by the tenant." echo "Data will be initialized upon first access by the tenant."
fi 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) remove)
+32 -11
View File
@@ -1,14 +1,35 @@
#!/bin/bash #!/usr/bin/env 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
set -euo pipefail 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" "$@" COMPOSE_FILE="docker-compose-multitenant.yml"
"$SCRIPT_DIR/start.sh" "$@" 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."
+82 -18
View File
@@ -14,8 +14,14 @@ if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
SUDO="sudo" SUDO="sudo"
fi fi
IS_ROOT="false"
if [ "$(id -u)" -eq 0 ]; then
IS_ROOT="true"
fi
NUITKA_BUILD_VALUE="0" NUITKA_BUILD_VALUE="0"
HTTP_PORT_VALUE="10000" HTTP_PORT_VALUE="10000"
HTTP_PORTS_VALUE=""
DEFAULT_TENANT_PORT_START="${INVENTAR_TENANT_PORT_START:-10000}" DEFAULT_TENANT_PORT_START="${INVENTAR_TENANT_PORT_START:-10000}"
CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}" CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}"
APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-$APP_IMAGE_REPO:latest}" APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-$APP_IMAGE_REPO:latest}"
@@ -114,7 +120,11 @@ ensure_runtime_dependencies() {
local missing=() local missing=()
if ! command -v docker >/dev/null 2>&1; then if ! command -v docker >/dev/null 2>&1; then
if [ "$IS_ROOT" = "true" ]; then
install_docker_engine install_docker_engine
else
missing+=(docker)
fi
fi fi
if ! docker compose version >/dev/null 2>&1; then if ! docker compose version >/dev/null 2>&1; then
@@ -138,14 +148,20 @@ ensure_runtime_dependencies() {
fi fi
if [ "${#missing[@]}" -gt 0 ]; then if [ "${#missing[@]}" -gt 0 ]; then
if [ "$IS_ROOT" = "true" ]; then
echo "Installing missing dependencies: ${missing[*]}" echo "Installing missing dependencies: ${missing[*]}"
apt_install "${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 fi
if command -v systemctl >/dev/null 2>&1; then if [ "$IS_ROOT" = "true" ] && command -v systemctl >/dev/null 2>&1; then
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true systemctl enable --now docker >/dev/null 2>&1 || true
if cron_setup_enabled; then 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
fi fi
} }
@@ -158,6 +174,11 @@ setup_boot_autostart_service() {
return 0 return 0
fi 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 if ! command -v systemctl >/dev/null 2>&1; then
return 0 return 0
fi fi
@@ -199,6 +220,11 @@ setup_scheduled_jobs() {
return 0 return 0
fi 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 if ! command -v crontab >/dev/null 2>&1; then
echo "Warning: crontab not available, skipping nightly update setup" echo "Warning: crontab not available, skipping nightly update setup"
return 0 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" backup_line="30 2 * * * cd $SCRIPT_DIR && ./backup.sh --mode auto >> $SCRIPT_DIR/logs/backup.log 2>&1"
local existing_cron local existing_cron
if [ "$(id -u)" -eq 0 ]; then
existing_cron="$(crontab -l 2>/dev/null || true)" 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 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 "$backup_line"
echo "$update_line" echo "$update_line"
} | crontab - } | 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
echo "Nightly backup scheduled at 02:30" echo "Nightly backup scheduled at 02:30"
echo "Nightly auto-update scheduled at 03:00" echo "Nightly auto-update scheduled at 03:00"
@@ -384,25 +401,56 @@ find_free_port() {
echo "$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() { configure_host_ports() {
local requested_http local requested_http
local requested_ports
requested_http="" requested_http=""
requested_ports=""
if [ -f "$ENV_FILE" ]; then if [ -f "$ENV_FILE" ]; then
requested_http="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)" 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 fi
if [ -z "$requested_http" ]; then if [ -n "${INVENTAR_HTTP_PORTS:-}" ]; then
requested_http="$DEFAULT_TENANT_PORT_START" requested_ports="$INVENTAR_HTTP_PORTS"
fi fi
if ! port_in_use "$requested_http"; then if [ -n "${INVENTAR_HTTP_PORT:-}" ] && [ -z "$requested_ports" ]; then
HTTP_PORT_VALUE="$requested_http" requested_ports="$INVENTAR_HTTP_PORT"
return
fi fi
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")" 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" 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() { ensure_min_docker_disk_space() {
@@ -440,6 +488,7 @@ write_env_file() {
cat > "$ENV_FILE" <<EOF cat > "$ENV_FILE" <<EOF
NUITKA_BUILD=$NUITKA_BUILD_VALUE NUITKA_BUILD=$NUITKA_BUILD_VALUE
INVENTAR_HTTP_PORT=$HTTP_PORT_VALUE INVENTAR_HTTP_PORT=$HTTP_PORT_VALUE
INVENTAR_HTTP_PORTS=${HTTP_PORTS_VALUE// /,}
INVENTAR_APP_IMAGE=$APP_IMAGE_VALUE INVENTAR_APP_IMAGE=$APP_IMAGE_VALUE
EOF EOF
} }
@@ -453,6 +502,21 @@ services:
image: ${APP_IMAGE_VALUE} image: ${APP_IMAGE_VALUE}
build: null build: null
EOF 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() { verify_stack_health() {
+14 -3
View File
@@ -38,6 +38,11 @@ if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
SUDO="sudo" SUDO="sudo"
fi fi
IS_ROOT="false"
if [ "$(id -u)" -eq 0 ]; then
IS_ROOT="true"
fi
apt_install() { apt_install() {
$SUDO apt-get update -y $SUDO apt-get update -y
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$@" $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"
@@ -47,7 +52,7 @@ ensure_runtime_dependencies() {
local missing=() local missing=()
if ! command -v docker >/dev/null 2>&1; then if ! command -v docker >/dev/null 2>&1; then
missing+=(docker.io) missing+=(docker)
fi fi
if ! docker compose version >/dev/null 2>&1; then if ! docker compose version >/dev/null 2>&1; then
@@ -67,12 +72,18 @@ ensure_runtime_dependencies() {
fi fi
if [ "${#missing[@]}" -gt 0 ]; then if [ "${#missing[@]}" -gt 0 ]; then
if [ "$IS_ROOT" = "true" ]; then
log_message "Installing missing dependencies: ${missing[*]}" log_message "Installing missing dependencies: ${missing[*]}"
apt_install "${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 fi
if command -v systemctl >/dev/null 2>&1; then if [ "$IS_ROOT" = "true" ] && command -v systemctl >/dev/null 2>&1; then
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true systemctl enable --now docker >/dev/null 2>&1 || true
fi fi
} }