#!/usr/bin/env bash set -euo pipefail IFS=$'\n\t' # Script to manage multitenant deployment # Allows adding, removing, and restarting tenants without downtime for others if [ ! -f "docker-compose-multitenant.yml" ]; then echo "Error: docker-compose-multitenant.yml not found." exit 1 fi # Resolve script directory so config paths are deterministic even when called via sudo SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONFIG_FILE="$SCRIPT_DIR/config.json" ensure_runtime_config_json() { local config_path backup_path config_path="$CONFIG_FILE" if [ -d "$config_path" ]; then backup_path="${config_path}.dir.$(date +%Y%m%d-%H%M%S).bak" mv "$config_path" "$backup_path" echo "Warning: moved unexpected directory $config_path to $backup_path" fi if [ ! -f "$config_path" ]; then cat > "$config_path" <<'EOF' { "ver": "2.6.5", "tenants": {} } EOF echo "Created default config.json" fi } get_tenant_aliases() { local tenant_id="$1" local normalized alias normalized="$(printf '%s' "$tenant_id" | tr '[:upper:]' '[:lower:]')" printf '%s\n' "$tenant_id" if [[ "$normalized" == schule* ]]; then alias="school${normalized#schule}" if [[ "$alias" != "$tenant_id" ]]; then printf '%s\n' "$alias" fi elif [[ "$normalized" == school* ]]; then alias="schule${normalized#school}" if [[ "$alias" != "$tenant_id" ]]; then printf '%s\n' "$alias" fi fi } 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 = {} aliases = {tenant_id} normalized = tenant_id.lower() if normalized.startswith('schule'): aliases.add('school' + normalized[len('schule'):]) elif normalized.startswith('school'): aliases.add('schule' + normalized[len('school'):]) for tid, conf in tenants.items(): if isinstance(conf, dict) and str(conf.get('port')) == port_str and tid not in aliases: print(f"Error: port {port_str} is already mapped to tenant {tid}", file=sys.stderr) sys.exit(2) existing = {} for alias in aliases: alias_cfg = tenants.get(alias) if isinstance(alias_cfg, dict): existing = alias_cfg break existing['port'] = int(port_str) for alias in aliases: tenants[alias] = dict(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 } write_trial_tenant_config() { local tenant_id="$1" local port="$2" local trial_days="$3" if python3 - <<'PY' "$CONFIG_FILE" "$tenant_id" "$port" "$trial_days" import json, sys, os, datetime path, tenant_id, port_str, trial_days_str = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] 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 = {} aliases = {tenant_id} normalized = tenant_id.lower() if normalized.startswith('schule'): aliases.add('school' + normalized[len('schule'):]) elif normalized.startswith('school'): aliases.add('schule' + normalized[len('school'):]) for tid, conf in tenants.items(): if port_str and isinstance(conf, dict) and str(conf.get('port')) == port_str and tid not in aliases: print(f"Error: port {port_str} is already mapped to tenant {tid}", file=sys.stderr) sys.exit(2) try: trial_days = max(1, int(trial_days_str)) except ValueError: print(f"Error: trial days must be numeric, got {trial_days_str!r}", file=sys.stderr) sys.exit(3) now = datetime.datetime.now(datetime.timezone.utc).isoformat() trial_config = { 'enabled': True, 'auto_delete': True, 'days': trial_days, 'ttl_days': trial_days, 'expires_after_days': trial_days, 'started_at': now, 'created_at': now, } existing = {} for alias in aliases: alias_cfg = tenants.get(alias) if isinstance(alias_cfg, dict): existing = alias_cfg break if port_str: existing['port'] = int(port_str) existing['modules'] = { 'inventory': {'enabled': True}, 'library': {'enabled': True}, 'student_cards': {'enabled': True, 'default_borrow_days': 14, 'max_borrow_days': 365}, 'terminplan': {'enabled': True}, 'mail': {'enabled': True}, } existing['trial'] = trial_config for alias in aliases: tenants[alias] = dict(existing) cfg['tenants'] = tenants with open(path, 'w', encoding='utf-8') as f: json.dump(cfg, f, indent=4, ensure_ascii=False) print(f"Configured trial tenant {tenant_id} with {trial_days} day(s) and auto-delete enabled") PY then echo "Trial tenant $tenant_id configured in config.json" else echo "Failed to configure trial tenant $tenant_id" exit 1 fi } initialize_tenant_database() { local tenant_id="$1" local mode="$2" APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1) if [ -z "$APP_CONTAINER" ]; then 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." return 0 fi docker exec -i "$APP_CONTAINER" python3 - "$tenant_id" "$mode" <<'PY' import sys, os, re, datetime, hashlib sys.path.insert(0, "/app") sys.path.insert(0, "/app/Web") from Web.modules.database import settings from pymongo import MongoClient tenant_id = sys.argv[1].lower() mode = sys.argv[2] sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_") db_name = f"inventar_{sanitized}" client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT)) db = client[db_name] pw_bytes = "admin123".encode("utf-8") random_salt = os.urandom(16) hashed = hashlib.scrypt(pw_bytes, salt=random_salt, n=16384, r=8, p=1) hashed_pw_string = f"v1${random_salt.hex()}${hashed.hex()}" action_permissions = { "can_borrow": True, "can_insert": True, "can_edit": True, "can_delete": True, "can_manage_users": True, "can_manage_settings": True, "can_view_logs": True, } page_permissions = { "home": True, "tutorial_page": True, "my_borrowed_items": True, "notifications_view": True, "impressum": True, "license": True, "library_view": True, "terminplan": True, "home_admin": True, "upload_admin": True, "library_admin": True, "admin_borrowings": True, "library_loans_admin": True, "admin_damaged_items": True, "admin_audit_dashboard": True, "logs": True, "manage_filters": True, "manage_locations": True, } if db.users.count_documents({"Username": "admin"}) == 0: db.users.insert_one({ "Username": "admin", "Password": hashed_pw_string, "Admin": True, "active_ausleihung": None, "name": "Admin", "last_name": "User", "IsStudent": False, "PermissionPreset": "full_access", "ActionPermissions": action_permissions, "PagePermissions": page_permissions, }) if mode == "trial": db.settings.update_one( {"setting_type": "tenant_trial"}, {"$set": { "setting_type": "tenant_trial", "enabled": True, "auto_delete": True, "days": 7, "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), }}, upsert=True, ) print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123") PY } update_runtime_ports() { local new_port="$1" local env_file="$PWD/.docker-build.env" local current_ports port_list unique_ports first_port ports_csv if [ -z "$new_port" ]; then return 0 fi current_ports="" if [ -f "$env_file" ]; then current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)" if [ -z "$current_ports" ]; then current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)" fi fi port_list=() unique_ports=() if [ -n "$current_ports" ]; then IFS=',' read -r -a port_list <<<"${current_ports// /,}" fi port_list+=("$new_port") for port in "${port_list[@]}"; do if [ -n "$port" ] && ! printf '%s\n' "${unique_ports[@]}" | grep -qx "$port"; then unique_ports+=("$port") fi done if [ ${#unique_ports[@]} -eq 0 ]; then unique_ports=("$new_port") fi first_port="${unique_ports[0]}" ports_csv="$(IFS=,; echo "${unique_ports[*]}")" if [ ! -f "$env_file" ]; then cat > "$env_file" <> "$env_file" fi if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file" else printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file" fi fi echo "Updated runtime env ports: $ports_csv" } sync_tenant_port_map() { local config_path="$CONFIG_FILE" local env_file="$PWD/.docker-build.env" local tenant_port_map tenant_port_map="$(python3 - <<'PY' "$config_path" import json, os, sys path = sys.argv[1] if not os.path.isfile(path): sys.exit(1) with open(path, 'r', encoding='utf-8') as f: cfg = json.load(f) tenants = cfg.get('tenants', {}) if not isinstance(tenants, dict): sys.exit(0) entries = [] for tenant_id, conf in sorted(tenants.items()): if isinstance(conf, dict): port = conf.get('port') if isinstance(port, (int, float)) or (isinstance(port, str) and str(port).strip().isdigit()): entries.append(f"{int(port)}={tenant_id}") print(','.join(entries)) PY )" if [ -z "$tenant_port_map" ]; then if [ -f "$env_file" ]; then sed -i '/^INVENTAR_TENANT_PORT_MAP=/d' "$env_file" fi return 0 fi if [ ! -f "$env_file" ]; then cat > "$env_file" <> "$env_file" fi } restart_app_container() { local workdir="$SCRIPT_DIR" local env_file="$SCRIPT_DIR/.docker-build.env" local compose_args=() ensure_runtime_config_json if [ -n "${HOST_WORKDIR:-}" ]; then workdir="$HOST_WORKDIR" compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/docker-compose-multitenant.yml")" ) if [ -f "$HOST_WORKDIR/.docker-compose.runtime.override.yml" ]; then compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/.docker-compose.runtime.override.yml")" ) fi if [ -f "$HOST_WORKDIR/.docker-build.env" ]; then compose_args+=( --env-file "$(readlink -f "$HOST_WORKDIR/.docker-build.env")" ) fi else compose_args+=( -f "$workdir/docker-compose-multitenant.yml" ) if [ -f "$workdir/.docker-compose.runtime.override.yml" ]; then compose_args+=( -f "$workdir/.docker-compose.runtime.override.yml" ) fi if [ -f "$env_file" ]; then compose_args+=( --env-file "$env_file" ) fi fi if [ -n "${COMPOSE_PROJECT_NAME:-}" ]; then compose_args=( -p "$COMPOSE_PROJECT_NAME" "${compose_args[@]}" ) fi echo "Restarting app container to apply tenant configuration changes..." docker compose "${compose_args[@]}" up -d --no-build --force-recreate app } remove_tenant_port() { local tenant_id="$1" local config_path="$CONFIG_FILE" local port port="$(python3 - "$config_path" "$tenant_id" <<'PY' import json, sys, os path, tenant_id = sys.argv[1], sys.argv[2] if not os.path.isfile(path): sys.exit(1) with open(path, 'r', encoding='utf-8') as f: cfg = json.load(f) tenants = cfg.get('tenants', {}) if not isinstance(tenants, dict): sys.exit(2) aliases = {tenant_id} normalized = tenant_id.lower() if normalized.startswith('schule'): aliases.add('school' + normalized[len('schule'):]) elif normalized.startswith('school'): aliases.add('schule' + normalized[len('school'):]) removed = None for alias in list(aliases): alias_cfg = tenants.get(alias) if isinstance(alias_cfg, dict): removed = alias_cfg if removed is None else removed tenants.pop(alias, None) cfg['tenants'] = tenants with open(path, 'w', encoding='utf-8') as f: json.dump(cfg, f, indent=4, ensure_ascii=False) if removed is not None: port = removed.get('port') if port is not None: print(port) PY )" local status=$? if [ $status -eq 1 ]; then echo "Error: config file not found: $config_path" return 1 fi if [ $status -eq 2 ]; then return 2 fi if [ -n "$port" ]; then echo "$port" fi return 0 } remove_runtime_port() { local removed_port="$1" local env_file="$PWD/.docker-build.env" local current_ports="" local port_list=() local new_ports=() local first_port="" if [ ! -f "$env_file" ]; then return 0 fi current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)" if [ -z "$current_ports" ]; then current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)" fi if [ -z "$current_ports" ]; then return 0 fi IFS=',' read -r -a port_list <<<"${current_ports// /,}" for port in "${port_list[@]}"; do if [ -n "$port" ] && [ "$port" != "$removed_port" ]; then new_ports+=("$port") fi done if [ ${#new_ports[@]} -eq 0 ]; then sed -i '/^INVENTAR_HTTP_PORTS=/d;/^INVENTAR_HTTP_PORT=/d' "$env_file" return 0 fi first_port="${new_ports[0]}" local ports_csv="$(IFS=,; echo "${new_ports[*]}")" if grep -q '^INVENTAR_HTTP_PORTS=' "$env_file" 2>/dev/null; then sed -i "s|^INVENTAR_HTTP_PORTS=.*|INVENTAR_HTTP_PORTS=$ports_csv|" "$env_file" else printf '\nINVENTAR_HTTP_PORTS=%s\n' "$ports_csv" >> "$env_file" fi if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file" else printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file" fi } show_help() { local HEADER='\033[95m' local BLUE='\033[94m' local GREEN='\033[92m' local YELLOW='\033[93m' local RED='\033[91m' local BOLD='\033[1m' local RESET='\033[0m' cat << EOF ${HEADER}${BOLD}=== MULTI-TENANT INVENTAR MANAGER ===${RESET} ${YELLOW}Nutzung:${RESET} $0 [tenant_id] [optionen] ${BLUE}${BOLD}VERFÜGBARE BEFEHLE:${RESET} ${GREEN}add${RESET} [port] Legt einen neuen Tenant an, registriert den Port und initialisiert die MongoDB-Datenbank mit einem Standard-Admin (${YELLOW}admin / admin123${RESET}). ${GREEN}trial${RESET} [port] [tage] Erstellt einen temporären Test-Tenant. Standardlaufzeit: 7 Tage. Läuft automatisch ab und löscht sich selbst. ${GREEN}remove${RESET} [-y|--yes] Löscht einen Tenant, seine Konfiguration, Ports und die Datenbank. Nutze ${RED}-y${RESET}, um die Bestätigungsabfrage zu überspringen. ${GREEN}restart-tenant${RESET} Startet einen spezifischen Tenant neu, indem alle aktiven Sessions und der Cache in der MongoDB geleert werden (Sitzungs-Reset). ${GREEN}restart-all${RESET} Führt einen Zero-Downtime Rolling-Restart für alle App-Container durch. ${GREEN}list${RESET} Listet alle registrierten Tenants aus der 'config.json' sowie alle aktiven Tenant-Datenbanken aus der MongoDB auf. ${GREEN}module${RESET} = ... Aktiviert oder deaktiviert bestimmte Features/Module für einen Tenant. Es können mehrere Module gleichzeitig übergeben werden. ${BLUE}${BOLD}GLOBALE OPTIONEN:${RESET} ${GREEN}-h, --help${RESET} Zeigt diese Hilfe an. ${YELLOW}Beispiele:${RESET} $0 add schule_muenchen 8081 $0 trial test_user 8082 14 $0 module schule_muenchen barre_code=on leih_historie=off $0 remove -y test_user ----------------------------------------------------------------- EOF } if [ -z "${1:-}" ]; then show_help exit 0 fi COMMAND="$1" # === MAIN ROUTING BLOCK === case "$COMMAND" in -h|--help) show_help ;; add) TENANT_ID="${2:-}" if [ -z "$TENANT_ID" ]; then echo "Error: Please provide a tenant_id." exit 1 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" update_runtime_ports "$PORT_ARG" sync_tenant_port_map if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then restart_app_container fi fi echo "Adding new tenant '$TENANT_ID'..." echo "Initializing database for $TENANT_ID..." initialize_tenant_database "$TENANT_ID" "standard" echo "Tenant '$TENANT_ID' successfully added. Ready to use." ;; trial) TENANT_ID="${2:-}" if [ -z "$TENANT_ID" ]; then echo "Error: Please provide a tenant_id." exit 1 fi PORT_ARG="${3:-}" DAYS_ARG="${4:-7}" 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" update_runtime_ports "$PORT_ARG" sync_tenant_port_map fi write_trial_tenant_config "$TENANT_ID" "$PORT_ARG" "$DAYS_ARG" if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then restart_app_container fi echo "Initializing trial database for $TENANT_ID..." initialize_tenant_database "$TENANT_ID" "trial" echo "Trial tenant '$TENANT_ID' successfully configured. It will expire after $DAYS_ARG day(s) and self-delete." ;; remove) FORCE_REMOVE=false TENANT_ARG="${2:-}" if [ "$TENANT_ARG" = "--yes" ] || [ "$TENANT_ARG" = "-y" ]; then FORCE_REMOVE=true TENANT_ID="${3:-}" else TENANT_ID="$TENANT_ARG" fi if [ -z "$TENANT_ID" ]; then echo "Error: Please provide a tenant_id to remove." exit 1 fi if [ "$FORCE_REMOVE" != true ]; then echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) " read confirm if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then echo "Removal canceled." exit 0 fi fi echo "Removing tenant '$TENANT_ID'..." APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1) port_to_remove="" if [ -n "$APP_CONTAINER" ]; then # Zuerst die Datenbank via PyMongo hart droppen (Erzwungenes Löschen) port_to_remove="$(docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" <<'PY' import sys, re sys.path.insert(0, '/app') sys.path.insert(0, '/app/Web') from tenant import delete_tenant, get_tenant_config from Web.modules.database import settings from pymongo import MongoClient tenant_id = sys.argv[1] tenant_cfg = get_tenant_config(tenant_id) port = tenant_cfg.get('port') # Datenbanknamen exakt rekonstruieren sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_") db_name = f"inventar_{sanitized}" try: client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT)) client.drop_database(db_name) print(f"MongoDB database '{db_name}' dropped successfully.", file=sys.stderr) except Exception as e: print(f"Warning: Could not drop database '{db_name}': {e}", file=sys.stderr) if not delete_tenant(tenant_id): print(f'Error: failed to delete tenant {tenant_id}', file=sys.stderr) sys.exit(1) if port is not None: print(port) PY )" echo "Tenant '$TENANT_ID' database and config removed." else echo "Warning: Application container not running. Tenant database may still exist in MongoDB." if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then : else echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed." fi fi if [ -n "$port_to_remove" ]; then remove_runtime_port "$port_to_remove" fi sync_tenant_port_map if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then restart_app_container fi if [ -n "$port_to_remove" ]; then echo "Removed tenant '$TENANT_ID' and cleaned runtime port $port_to_remove." else echo "Removed tenant '$TENANT_ID'. No port mapping was present." fi ;; restart-tenant) TENANT_ID="${2:-}" if [ -z "$TENANT_ID" ]; then echo "Error: Please provide a tenant_id." exit 1 fi echo "Restarting tenant '$TENANT_ID' (clearing session/cache)..." APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1) if [ -n "$APP_CONTAINER" ]; then docker exec $APP_CONTAINER python3 -c " import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT)) db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}'] db.sessions.drop() print(f'Tenant {sys.argv[1]} session cache cleared. Tenant restarted.') " "$TENANT_ID" echo "Tenant '$TENANT_ID' has been refreshed without impacting others." else echo "Error: Application container not running." fi ;; restart-all) echo "Restarting all application instances with zero-downtime rolling restart..." docker restart $(docker ps -qf "name=app") echo "Global restart complete." ;; list) echo "Listing configured tenants (config.json):" if [ -f "$CONFIG_FILE" ]; then python3 - "$CONFIG_FILE" <<'PY' import json, sys path = sys.argv[1] with open(path, 'r', encoding='utf-8') as f: cfg = json.load(f) tenants = cfg.get('tenants', {}) if isinstance(tenants, dict) and tenants: for tid, conf in tenants.items(): port = conf.get('port') if isinstance(conf, dict) else None if port is not None: print(f'- {tid} (port {port})') else: print(f'- {tid}') else: print(' (no tenants configured)') PY else echo " (config.json not found)" fi echo "" echo "Listing active tenant databases (MongoDB):" APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1) if [ -n "$APP_CONTAINER" ]; then docker exec -i "$APP_CONTAINER" python3 - <<'PY' import sys sys.path.insert(0, '/app') sys.path.insert(0, '/app/Web') from Web.modules.database import settings from pymongo import MongoClient client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT)) prefix = 'inventar_' dbs = [d for d in client.list_database_names() if d.startswith(prefix)] if dbs: for db in dbs: print(f'- {db.replace(prefix, "")}') else: print(' (no tenant databases found)') PY else echo "Error: Application container not running." fi ;; module) TENANT_ID="${2:-}" if [ -z "$TENANT_ID" ] || [ -z "${3:-}" ]; then echo "Error: Usage: manage-tenant.sh module = [...]" exit 1 fi shift 2 if python3 - "$CONFIG_FILE" "$TENANT_ID" "$@" <<'PY' import json, sys, os path = sys.argv[1] tenant_id = sys.argv[2] module_args = 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.setdefault('tenants', {}) aliases = {tenant_id} normalized = tenant_id.lower() if normalized.startswith('schule'): aliases.add('school' + normalized[len('schule'):]) elif normalized.startswith('school'): aliases.add('schule' + normalized[len('school'):]) tenant_cfg = None for alias in aliases: alias_cfg = tenants.get(alias) if isinstance(alias_cfg, dict): tenant_cfg = alias_cfg break if tenant_cfg is None: tenant_cfg = {} modules = tenant_cfg.setdefault('modules', {}) port = tenant_cfg.get('port') if port is None: print(f"Warning: Tenant {tenant_id} doesn't have a port mapping in config.json.", file=sys.stderr) for arg in module_args: if '=' not in arg: print(f"Warning: Ignoring invalid argument format '{arg}'. Expected name=on|off.", file=sys.stderr) continue mod_name, state_str = arg.split('=', 1) state = str(state_str).lower() in ('on', '1', 'true', 'yes') module_cfg = modules.setdefault(mod_name, {}) module_cfg['enabled'] = state print(f"Module '{mod_name}' set to '{'on' if state else 'off'}' for tenant '{tenant_id}'.") for alias in aliases: alias_cfg = tenants.get(alias) if not isinstance(alias_cfg, dict): alias_cfg = {} alias_cfg.update(tenant_cfg) alias_cfg['modules'] = modules tenants[alias] = alias_cfg with open(path, 'w', encoding='utf-8') as f: json.dump(cfg, f, indent=4, ensure_ascii=False) PY then echo "Module configurations updated successfully." if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then restart_app_container fi else echo "Failed to update module configuration." exit 1 fi ;; backup) TENANT_ID="${2:-}" if [ -z "$TENANT_ID" ]; then echo "Error: Please provide a tenant_id." exit 1 fi OUTPUT_FILE="${3:-backup_${TENANT_ID}_$(date +%Y%m%d_%H%M%S).zip}" APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1) if [ -z "$APP_CONTAINER" ]; then echo "Error: Application container not running." exit 1 fi echo "Creating complete backup (.zip) for tenant '$TENANT_ID'..." # We generate the zip temporarily inside the container CONTAINER_TMP_ZIP="/tmp/backup_${TENANT_ID}_$(date +%s).zip" docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" "$CONTAINER_TMP_ZIP" <<'PY' import sys, os, zipfile from bson import json_util sys.path.insert(0, '/app') sys.path.insert(0, '/app/Web') from Web.modules.database import settings from pymongo import MongoClient tenant_id = sys.argv[1] zip_path = sys.argv[2] # CHANGE THIS if your app stores files in a different directory UPLOADS_BASE = f"/app/uploads/{tenant_id}" sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_") db_name = f"inventar_{sanitized}" try: client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT)) db = client[db_name] dump = {} for coll_name in db.list_collection_names(): if coll_name.startswith('system.'): continue dump[coll_name] = list(db[coll_name].find()) db_json = json_util.dumps(dump) with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: # 1. Write the database dump zf.writestr('database.json', db_json) # 2. Add uploaded photos/invoices if the directory exists if os.path.isdir(UPLOADS_BASE): for root, dirs, files in os.walk(UPLOADS_BASE): for file in files: file_path = os.path.join(root, file) # Create a relative path for the zip internal structure arcname = os.path.relpath(file_path, start=UPLOADS_BASE) zf.write(file_path, arcname=f"uploads/{arcname}") except Exception as e: print(f"Error creating backup: {e}", file=sys.stderr) sys.exit(1) PY if [ $? -eq 0 ]; then # Copy the zip out of the container to the host docker cp "$APP_CONTAINER:$CONTAINER_TMP_ZIP" "$OUTPUT_FILE" # Cleanup inside the container docker exec "$APP_CONTAINER" rm -f "$CONTAINER_TMP_ZIP" echo "Backup successfully created: $OUTPUT_FILE" else echo "Error: Backup failed." docker exec "$APP_CONTAINER" rm -f "$CONTAINER_TMP_ZIP" exit 1 fi ;; restore) TENANT_ID="${2:-}" INPUT_FILE="${3:-}" if [ -z "$TENANT_ID" ] || [ -z "$INPUT_FILE" ]; then echo "Error: Usage: $0 restore " exit 1 fi if [ ! -f "$INPUT_FILE" ]; then echo "Error: Backup file '$INPUT_FILE' not found." exit 1 fi echo -n "WARNING: This will DROP the existing database and completely overwrite data AND files for tenant '$TENANT_ID'. Are you sure? (y/N) " read confirm if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then echo "Restore canceled." exit 0 fi APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1) if [ -z "$APP_CONTAINER" ]; then echo "Error: Application container not running." exit 1 fi echo "Uploading backup archive to container..." CONTAINER_TMP_ZIP="/tmp/restore_${TENANT_ID}_$(date +%s).zip" docker cp "$INPUT_FILE" "$APP_CONTAINER:$CONTAINER_TMP_ZIP" echo "Restoring database and files for tenant '$TENANT_ID'..." docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" "$CONTAINER_TMP_ZIP" <<'PY' import sys, os, zipfile, shutil from bson import json_util sys.path.insert(0, '/app') sys.path.insert(0, '/app/Web') from Web.modules.database import settings from pymongo import MongoClient tenant_id = sys.argv[1] zip_path = sys.argv[2] # CHANGE THIS if your app stores files in a different directory UPLOADS_BASE = f"/app/uploads/{tenant_id}" sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_") db_name = f"inventar_{sanitized}" try: with zipfile.ZipFile(zip_path, 'r') as zf: # 1. Restore Database db_json = zf.read('database.json').decode('utf-8') data = json_util.loads(db_json) client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT)) client.drop_database(db_name) db = client[db_name] restored_count = 0 for coll_name, docs in data.items(): if docs: db[coll_name].insert_many(docs) restored_count += len(docs) # 2. Restore Files # Wipe existing uploads to ensure an exact replica of the backup if os.path.exists(UPLOADS_BASE): shutil.rmtree(UPLOADS_BASE) for item in zf.namelist(): # Extract only file items that were saved under 'uploads/' if item.startswith('uploads/') and not item.endswith('/'): rel_path = item[len('uploads/'):] target_path = os.path.join(UPLOADS_BASE, rel_path) os.makedirs(os.path.dirname(target_path), exist_ok=True) with open(target_path, 'wb') as f: f.write(zf.read(item)) print(f"Successfully restored {restored_count} database documents and tenant files.") except Exception as e: print(f"Error restoring backup: {e}", file=sys.stderr) sys.exit(1) PY if [ $? -eq 0 ]; then # Clean up tmp zip inside container docker exec "$APP_CONTAINER" rm -f "$CONTAINER_TMP_ZIP" echo "Restore completed successfully." echo "Clearing session cache..." docker exec "$APP_CONTAINER" python3 -c " import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT)) db = client[f'inventar_{"".join(c for c in sys.argv[1] if c.isalnum() or c == "_")}'] db.sessions.drop() " "$TENANT_ID" else echo "Error: Restore failed." docker exec "$APP_CONTAINER" rm -f "$CONTAINER_TMP_ZIP" exit 1 fi ;; *) echo "Unknown command: $COMMAND" show_help ;; esac exit 0