Compare commits

...

26 Commits

Author SHA1 Message Date
github-actions[bot] c69231fa77 chore: bump version to v0.7.75 2026-05-20 18:52:46 +00:00
Aiirondev_dev ac43178b29 feat: implement tenant database resolution and configuration checks 2026-05-20 20:52:06 +02:00
github-actions[bot] a2d58208e6 chore: bump version to v0.7.74 2026-05-20 18:40:06 +00:00
Aiirondev_dev f8171a5f18 feat: enhance tenant aliasing for multi-tenant management in scripts 2026-05-20 20:39:45 +02:00
github-actions[bot] 6f898ffea4 chore: bump version to v0.7.73 2026-05-20 18:12:28 +00:00
Aiirondev_dev b29cc38a3d fix: enhance tenant ID management in session for multi-tenant support 2026-05-20 20:12:01 +02:00
github-actions[bot] 17745c64e0 chore: bump version to v0.7.72 2026-05-20 17:37:17 +00:00
Aiirondev_dev a0279f82e1 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-05-20 19:35:26 +02:00
Aiirondev_dev 8d1e3865d3 fix: improve environment variable handling and path resolution in restart_app_container function 2026-05-20 19:35:11 +02:00
github-actions[bot] 79be502aa1 chore: bump version to v0.7.71 2026-05-20 17:27:29 +00:00
Aiirondev_dev d0f54f0dca fix: ensure deterministic config path resolution in manage-tenant.sh 2026-05-20 19:26:00 +02:00
github-actions[bot] 2b00aab1fa chore: bump version to v0.7.70 2026-05-20 16:45:03 +00:00
Aiirondev_dev 6d4ac073a5 feat: Add module name candidate resolution for tenant feature modules 2026-05-20 18:42:55 +02:00
Aiirondev_dev 73ab1a37d2 feat: Update import paths for database settings in manage-tenant.sh 2026-05-20 18:31:17 +02:00
github-actions[bot] 0eb1ffe9d9 chore: bump version to v0.7.69 2026-05-20 16:24:14 +00:00
Aiirondev_dev 0ea1294237 feat: Update version handling in release workflow and improve directory creation 2026-05-20 18:23:34 +02:00
Aiirondev_dev a7ff2f1552 feat: Refactor logging module structure and implement audit logging functionality 2026-05-20 18:01:08 +02:00
Aiirondev_dev f847faea3e feat: Improve module import paths and add package initialization files 2026-05-20 17:43:06 +02:00
Aiirondev_dev 4c58dceb02 feat: Enhance error handling for URL resolution and unexpected exceptions 2026-05-20 17:06:22 +02:00
Aiirondev_dev db80693c6a fix: Update link to library admin in invoice template 2026-05-20 16:54:17 +02:00
Aiirondev_dev d451d58fc4 fix: Remove redundant conditional check for tag validation in release workflow 2026-05-20 16:40:56 +02:00
Aiirondev_dev 726520c9de feat: Simplify latest release tag fetching and bump strategy in release workflow 2026-05-20 16:36:53 +02:00
Aiirondev_dev fabac77877 feat: Add major version bump option and update release options documentation 2026-05-20 16:34:30 +02:00
Aiirondev_dev bb2832c273 feat: Remove local development image tar creation and update development bundle documentation 2026-05-20 16:32:27 +02:00
Aiirondev_dev ed122995ee feat: Add push_dev option for development image in release workflow and update tag handling 2026-05-20 16:26:09 +02:00
Aiirondev_dev 634feda9da feat: Refactor script copying to use loops for optional files in release bundle 2026-05-20 15:50:41 +02:00
19 changed files with 643 additions and 182 deletions
+1 -3
View File
@@ -1,8 +1,6 @@
services:
app:
working_dir: /app/Web
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "4", "--threads", "2", "--timeout", "30", "--graceful-timeout", "20", "--worker-connections", "100", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
image: ghcr.io/aiirondev/legendary-octo-garbanzo:latest
image: ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.42
build: null
ports:
- "10000:8000"
+100 -99
View File
@@ -14,7 +14,16 @@ on:
options:
- patch
- minor
- major
- development
push_dev:
description: "If true, push the :dev image to GHCR for development releases"
required: false
default: "false"
type: choice
options:
- "true"
- "false"
permissions:
contents: write
@@ -39,61 +48,46 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref_name }}
BUMP_TYPE: ${{ github.event.inputs.bump || 'patch' }}
PUSH_DEV: ${{ github.event.inputs.push_dev || 'false' }}
run: |
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
TAG="$REF_NAME"
else
TAG="$(python3 - <<'PY'
import json
import os
import re
import urllib.request
# Fetch latest release tag via GitHub API (fall back to v3.0.0)
latest_tag="v3.0.0"
if meta_json=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null); then
tag_name=$(printf "%s" "$meta_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
if [ -n "$tag_name" ]; then
latest_tag="$tag_name"
fi
fi
repo = os.environ["REPO"]
token = os.environ.get("GH_TOKEN", "")
bump = os.environ.get("BUMP_TYPE", "patch").strip().lower()
if [[ "$latest_tag" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
major=${BASH_REMATCH[1]}
minor=${BASH_REMATCH[2]}
patch=${BASH_REMATCH[3]}
else
major=3; minor=0; patch=0
fi
req = urllib.request.Request(
f"https://api.github.com/repos/{repo}/releases/latest",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"User-Agent": "legendary-octo-garbanzo-release-workflow",
},
)
# Bump strategy: major / minor / patch
if [ "${BUMP_TYPE:-}" = "major" ]; then
major=$((major + 1)); minor=0; patch=0
elif [ "${BUMP_TYPE:-}" = "minor" ]; then
minor=$((minor + 1)); patch=0
else
patch=$((patch + 1))
fi
latest_tag = "v3.0.0"
try:
with urllib.request.urlopen(req, timeout=20) as resp:
data = json.loads(resp.read().decode("utf-8"))
latest_tag = (data.get("tag_name") or latest_tag).strip()
except Exception:
pass
m = re.match(r"^v(\d+)\.(\d+)\.(\d+)$", latest_tag)
if not m:
major, minor, patch = 3, 0, 0
else:
major, minor, patch = map(int, m.groups())
# Bump strategy: major / minor / patch
if bump == "major":
major += 1
minor = 0
patch = 0
elif bump == "minor":
minor += 1
patch = 0
else:
patch += 1
print(f"v{major}.{minor}.{patch}")
PY
)"
if [ "${BUMP_TYPE:-}" = "development" ]; then
TAG="v${major}.${minor}.${patch}-dev"
else
TAG="v${major}.${minor}.${patch}"
fi
fi
if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Error: tag '$TAG' is not valid semver (vX.Y.Z)"
if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-dev(\.[0-9]+)?)?$'; then
echo "Error: tag '$TAG' is not valid semver (vX.Y.Z or vX.Y.Z-dev)"
exit 1
fi
@@ -116,12 +110,15 @@ jobs:
exit 1
fi
while git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; do
BASE="${TAG%.*}"
PATCH="${TAG##*.}"
PATCH="$((PATCH + 1))"
TAG="$BASE.$PATCH"
done
# Ensure tag uniqueness: if tag exists append numeric suffix
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
i=1
base="$TAG"
while git rev-parse -q --verify "refs/tags/${base}.${i}" >/dev/null; do
i="$((i + 1))"
done
TAG="${base}.${i}"
fi
IMAGE="ghcr.io/aiirondev/legendary-octo-garbanzo:${TAG}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
@@ -131,15 +128,28 @@ jobs:
else
echo "is_development=false" >> "$GITHUB_OUTPUT"
fi
if [ "${BUMP_TYPE:-}" = "development" ] && [ "${PUSH_DEV:-}" = "true" ]; then
echo "push_dev=true" >> "$GITHUB_OUTPUT"
else
echo "push_dev=false" >> "$GITHUB_OUTPUT"
fi
- name: Update .release-version file
run: |
echo "${{ steps.meta.outputs.tag }}" > .release-version
cat .release-version
- name: Create and push tag for manual releases
if: github.event_name == 'workflow_dispatch' && steps.meta.outputs.is_development != 'true'
if: github.event_name == 'workflow_dispatch'
run: |
TAG="${{ steps.meta.outputs.tag }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .release-version
git commit -m "chore: bump version to $TAG" || true
git tag "$TAG"
git push origin "$TAG"
git push origin HEAD:${{ github.ref_name }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -162,14 +172,15 @@ jobs:
${{ steps.meta.outputs.image }}
ghcr.io/aiirondev/legendary-octo-garbanzo:latest
- name: Build and push development image
- name: Build and push development image (:dev)
if: steps.meta.outputs.is_development == 'true' && steps.meta.outputs.push_dev == 'true'
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: |
ghcr.io/aiirondev/legendary-octo-garbanzo:development
ghcr.io/aiirondev/legendary-octo-garbanzo:dev
- name: Build local image tar for offline deploy
run: |
@@ -193,27 +204,19 @@ jobs:
docker build -t "$IMG" .
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
- name: Build local development image tar for offline deploy
# development tar omitted: dev releases will be versioned (vX.Y.Z-dev) and handled by update.sh using the tag
- name: Commit .release-version for tag pushes
if: github.event_name == 'push'
run: |
set -euo pipefail
DEV_IMG=ghcr.io/aiirondev/legendary-octo-garbanzo:development
if docker image inspect "$DEV_IMG" >/dev/null 2>&1; then
echo "Using local development image $DEV_IMG"
docker save "$DEV_IMG" | gzip > "inventarsystem-image-development.tar.gz"
exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if ! git diff --quiet .release-version; then
git add .release-version
git commit -m "chore: update version to ${{ steps.meta.outputs.tag }}"
git push origin HEAD:${{ github.ref_name }}
fi
echo "Local development image not found, trying to pull $DEV_IMG"
if docker pull "$DEV_IMG" >/dev/null 2>&1; then
docker save "$DEV_IMG" | gzip > "inventarsystem-image-development.tar.gz"
exit 0
fi
echo "Pull failed, attempting local docker build for development image"
docker build -t "$DEV_IMG" .
docker save "$DEV_IMG" | gzip > "inventarsystem-image-development.tar.gz"
- name: Create release-only docker bundle
run: |
mkdir -p release-bundle
@@ -283,45 +286,43 @@ jobs:
redis_data:
EOF
cp start.sh release-bundle/start.sh
cp stop.sh release-bundle/stop.sh
cp restart.sh release-bundle/restart.sh
cp backup.sh release-bundle/backup.sh
cp config.json release-bundle/config.json
cp update.sh release-bundle/update.sh
# Copy runtime scripts and config if present
for f in start.sh stop.sh restart.sh backup.sh config.json update.sh; do
if [ -f "$f" ]; then
cp "$f" "release-bundle/$(basename "$f")"
fi
done
# Multitenant scripts & docs
cp docker-compose-multitenant.yml release-bundle/docker-compose-multitenant.yml
cp manage-tenant.sh release-bundle/manage-tenant.sh
cp run-tenant-cmd.sh release-bundle/run-tenant-cmd.sh
cp MULTITENANT_DEPLOYMENT.md release-bundle/MULTITENANT_DEPLOYMENT.md
cp MULTITENANT_PYTHON_API.md release-bundle/MULTITENANT_PYTHON_API.md
# Multitenant scripts & docs (optional)
for f in docker-compose-multitenant.yml manage-tenant.sh run-tenant-cmd.sh MULTITENANT_DEPLOYMENT.md MULTITENANT_PYTHON_API.md; do
if [ -f "$f" ]; then
cp "$f" "release-bundle/$(basename "$f")"
fi
done
# Include optional development image tar and helper note
if [ -f "inventarsystem-image-development.tar.gz" ]; then
cp inventarsystem-image-development.tar.gz release-bundle/ || true
fi
cat > release-bundle/DEVELOPMENT.md <<EOF
This is a development prerelease bundle for tag: ${{ steps.meta.outputs.tag }}
This prerelease is intentionally separate from normal releases and will not be used by default.
To install this prerelease on a target host run:
./update.sh ${{ steps.meta.outputs.tag }}
cat > release-bundle/DEVELOPMENT.md <<'EOF'
This bundle contains an optional development image tar: inventarsystem-image-development.tar.gz
To install the development build on a target host, extract the bundle and run:
./update.sh development
EOF
chmod +x release-bundle/start.sh release-bundle/stop.sh release-bundle/restart.sh release-bundle/backup.sh release-bundle/update.sh release-bundle/manage-tenant.sh release-bundle/run-tenant-cmd.sh
# Make any shipped scripts executable
find release-bundle -maxdepth 1 -type f -name '*.sh' -exec chmod +x {} \; || true
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
- name: Create or update GitHub Release
if: steps.meta.outputs.is_development != 'true'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.meta.outputs.tag }}
prerelease: ${{ steps.meta.outputs.is_development }}
files: |
inventarsystem-docker-bundle.tar.gz
inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz
inventarsystem-image-development.tar.gz
inventarsystem-image-dev.tar.gz
fail_on_unmatched_files: false
generate_release_notes: true
+1 -1
View File
@@ -1 +1 @@
v0.7.42
v0.7.75
+38
View File
@@ -0,0 +1,38 @@
Release-Optionen
=================
Diese Datei beschreibt die Eingabeoptionen des CI-Workflows `.github/workflows/release-docker.yml`.
Inputs (workflow_dispatch)
- `bump` (choice)
- `patch` (Standard): Erhöht nur die Patch-Version (vX.Y.Z -> vX.Y.Z+1).
- `minor`: Erhöht die Minor-Version und setzt Patch auf 0 (vX.Y.Z -> vX.Y+1.0).
- `major`: Erhöht die Major-Version und setzt Minor/Patch auf 0 (vX.Y.Z -> vX+1.0.0).
- `development`: Erzeugt einen Development-Release mit Suffix `-dev` (z. B. `v3.1.4-dev`).
- `push_dev` (choice, optional)
- `false` (Standard): Bei `bump=development` wird das `:dev`-Image NICHT automatisch an GHCR gepusht.
- `true`: Bei `bump=development` wird zusätzlich das Image `ghcr.io/aiirondev/legendary-octo-garbanzo:dev` gepusht.
Verhalten/Anmerkungen
- Development releases werden als GitHub Release erzeugt und als `prerelease` markiert, damit sie nicht automatisch von normalen UpdateFlows genutzt werden.
- Es gibt pro Release genau einen ReleaseEintrag (für DevReleases mit `-dev` Suffix). Es wird kein separates `inventarsystem-image-dev.tar.gz` mehr erzeugt; das Update/Deployment erfolgt über den ReleaseTag / ImageTag.
- `update.sh` unterstützt weiterhin `dev`/`development`-Modus und akzeptiert nun auch explizite ReleaseTags wie `v3.1.4-dev`.
Beispiele
- Patch-Release (manuell):
- GitHub UI: Run workflow → `bump=patch`
- CLI mit `gh`:
gh workflow run release-docker.yml --repo AIIrondev/legendary-octo-garbanzo --field bump=patch
- Development prerelease (ohne Push des :dev Images):
- GitHub UI: Run workflow → `bump=development` (leave `push_dev=false`)
- Ergebnis: Release `vX.Y.Z-dev` als prerelease, Image wird nicht automatisch als `:dev` gepusht.
- Development prerelease + push des :dev Images:
- GitHub UI: Run workflow → `bump=development`, `push_dev=true`
- CLI Beispiel:
gh workflow run release-docker.yml --repo AIIrondev/legendary-octo-garbanzo --field bump=development --field push_dev=true
Empfehlung
- Verwende `bump=development` für experimentelle/early releases; Nutzer müssen explizit `./update.sh vX.Y.Z-dev` ausführen, um auf diese Version zu upgraden.
+1
View File
@@ -0,0 +1 @@
# Web package initialization
+57 -13
View File
@@ -28,11 +28,24 @@ Features:
from flask import Flask, render_template, request, redirect, url_for, session, flash, send_from_directory, get_flashed_messages, jsonify, Response, make_response, send_file, abort
from werkzeug.utils import secure_filename
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.exceptions import HTTPException
from werkzeug.routing import BuildError
from jinja2 import TemplateNotFound
import os
import sys
# Ensure imports work regardless of whether gunicorn starts in /app or /app/Web.
_CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
_PROJECT_ROOT = os.path.dirname(_CURRENT_DIR)
if _PROJECT_ROOT not in sys.path:
sys.path.insert(0, _PROJECT_ROOT)
if _CURRENT_DIR not in sys.path:
sys.path.insert(0, _CURRENT_DIR)
import Web.modules.database.user as us
import Web.modules.database.items as it
import Web.modules.database.ausleihung as au
import Web.modules.logs.audit_log as al
import Web.modules.log.audit_log as al
import push_notifications as pn
import Web.modules.inventarsystem.pdf_export as pdf_export
import datetime
@@ -42,7 +55,6 @@ from urllib.parse import urlparse, urlunparse
import requests
import csv
import ipaddress
import os
import json
import datetime
import time
@@ -63,7 +75,6 @@ except Exception:
# import qrcode
# from qrcode.constants import ERROR_CORRECT_L
import threading
import sys
import shutil
import uuid
from PIL import Image, ImageOps
@@ -109,7 +120,7 @@ app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
"""--------------------------------------------------------------Path Init-------------------------------------------------------"""
if not os.path.exists(app.config['UPLOAD_FOLDER']):
os.makedirs(app.config['UPLOAD_FOLDER'])
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
# QR Code directory creation deactivated
# if not os.path.exists(app.config['QR_CODE_FOLDER']):
# os.makedirs(app.config['QR_CODE_FOLDER'])
@@ -373,7 +384,7 @@ def _enforce_module_access():
if name != 'inventory' and cfg.MODULES.is_enabled('inventory'):
return redirect(url_for('home'))
elif name != 'library' and cfg.MODULES.is_enabled('library'):
return redirect(url_for('library_view'))
return redirect('/library')
return redirect(url_for('my_borrowed_items'))
@@ -433,6 +444,41 @@ def handle_not_found(e):
return redirect(url_for('login'))
@app.errorhandler(BuildError)
def handle_build_error(e):
"""Handle url_for endpoint mismatches without crashing the whole request."""
app.logger.error(f"BuildError while resolving endpoint: {e}", exc_info=True)
if request.is_json or request.path.startswith('/api/'):
return jsonify({'error': 'Route resolution failed', 'status': 500}), 500
# Known fallback for legacy library endpoint naming issues.
if 'library_view' in str(e):
return redirect('/library')
if 'username' in session:
return redirect(url_for('home'))
return redirect(url_for('login'))
@app.errorhandler(Exception)
def handle_unexpected_exception(e):
"""Last-resort handler to avoid raw 500 pages for unhandled exceptions."""
if isinstance(e, HTTPException):
return e
app.logger.error('Unhandled exception', exc_info=True)
if request.is_json or request.path.startswith('/api/'):
return jsonify({'error': 'Internal server error', 'status': 500}), 500
if 'username' in session:
try:
return render_template('error.html', error_code=500, error_message='Interner Serverfehler.'), 500
except Exception:
return 'Internal Server Error', 500
return redirect(url_for('login'))
def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'):
if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image', '/log_mobile_issue'}:
return jsonify({'error': message}), 400
@@ -2369,11 +2415,7 @@ def preview_file(filename):
if denied:
return denied
# Check production path first
prod_path = "/var/Inventarsystem/Web/previews"
dev_path = app.config['PREVIEW_FOLDER']
if os.path.exists(os.path.join(prod_path, filename)):
return send_from_directory(prod_path, filename)
if os.path.exists(os.path.join(dev_path, filename)):
return send_from_directory(dev_path, filename)
@@ -2640,7 +2682,7 @@ def home():
if not cfg.MODULES.is_enabled('inventory'):
if cfg.MODULES.is_enabled('library'):
return redirect(url_for('library_view'))
return redirect('/library')
else:
return "Weder Inventar- noch Bibliotheks-Modul sind aktiviert.", 403
@@ -2716,8 +2758,9 @@ def tutorial_page():
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS
)
@app.route('/library/export', defaults={'scope': 'all'})
@app.route('/library/export/<scope>')
def library_export_excel(scope):
def library_export_excel(scope='all'):
if 'username' not in session:
return redirect(url_for('login'))
@@ -2737,7 +2780,7 @@ def library_export_excel(scope):
filename = f"Bibliothek_Ausgeliehen_{username}.xlsx"
elif scope == 'all_borrowed':
if not is_admin_user:
return redirect(url_for('library_view'))
return redirect('/library')
query['Verfuegbar'] = False
filename = "Bibliothek_Alle_Ausleihen.xlsx"
elif scope == 'schulbuecher':
@@ -2751,7 +2794,7 @@ def library_export_excel(scope):
client.close()
excel_file = excel_export.generate_library_excel(items)
return flask.send_file(
return send_file(
excel_file,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
@@ -4134,6 +4177,7 @@ def logout():
session.pop('is_admin', None)
session.pop('favorites', None)
session.pop('favorites_owner', None)
session.pop('tenant_id', None)
return redirect(url_for('login'))
+1
View File
@@ -0,0 +1 @@
# Web.modules package initialization
+1
View File
@@ -0,0 +1 @@
# Web.modules.bibliothek package initialization
+1 -1
View File
@@ -103,7 +103,7 @@ def get_current_status(ausleihung, log_changes=False, user=None):
if log_changes and new_status != original_status and '_id' in ausleihung:
try:
# Importieren Sie das Modul nur bei Bedarf, um zirkuläre Importe zu vermeiden
import Web.modules.logs.ausleihung_log as ausleihung_log
import Web.modules.log.ausleihung_log as ausleihung_log
ausleihung_log.log_status_change(
str(ausleihung['_id']),
original_status,
+1 -1
View File
@@ -135,7 +135,7 @@ def _get_int_env(name, default):
return int(default)
def get_version():
with open(os.path.join(BASE_DIR, '..', '.docker-build.env'), 'r') as f:
with open(os.path.join(BASE_DIR, '..', '..', '..', '.docker-build.env'), 'r') as f:
for l in f:
if l.startswith('INVENTAR_APP_IMAGE='):
return l.split(':', 1)[1].strip()
+53 -17
View File
@@ -12,6 +12,7 @@ Provides methods for creating, validating, and retrieving user information.
'''
import hashlib
import copy
import importlib
import logging
import re
import secrets
@@ -59,6 +60,35 @@ def _get_tenant_db(client):
return client[cfg.MONGODB_DB]
def _has_tenant_configs():
for module_name in ('tenant', 'Web.tenant'):
try:
tenant_module = importlib.import_module(module_name)
tenant_registry = getattr(tenant_module, 'TENANT_REGISTRY', None)
if isinstance(tenant_registry, dict) and tenant_registry:
return True
except Exception:
continue
return isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict) and bool(cfg.TENANT_CONFIGS)
def _resolve_request_tenant_db():
for module_name in ('tenant', 'Web.tenant'):
try:
tenant_module = importlib.import_module(module_name)
get_tenant_context = getattr(tenant_module, 'get_tenant_context', None)
if not callable(get_tenant_context):
continue
ctx = get_tenant_context()
if ctx and ctx.tenant_id:
return ctx.db_name or ctx.resolve_tenant(), ctx.tenant_id
if ctx and ctx.db_name and not _has_tenant_configs():
return ctx.db_name, None
except Exception as exc:
logger.debug("Tenant context import %s failed: %s", module_name, exc)
return None, None
def build_name_synonym(first_name, last_name=''):
"""Build a deterministic, non-personalized short alias from 2 letters each."""
first = _clean_name_fragment(first_name)
@@ -424,22 +454,26 @@ def check_nm_pwd(username, password):
Returns:
dict: User document if credentials are valid, None otherwise
"""
db_name = cfg.MONGODB_DB
tenant_db = None
db_name, tenant_id = _resolve_request_tenant_db()
ctx = None
try:
from tenant import get_tenant_context
ctx = get_tenant_context()
if ctx and ctx.tenant_id:
tenant_db = ctx.db_name or ctx.resolve_tenant()
db_name = tenant_db
except Exception as exc:
logger.exception(f"Failed to resolve tenant context in check_nm_pwd: {exc}")
except Exception:
ctx = None
if not db_name:
if _has_tenant_configs():
logger.warning(
"Refusing default DB fallback for login because tenant configs exist and no tenant was resolved."
)
return None
db_name = cfg.MONGODB_DB
logger.info(
"check_nm_pwd start: username=%r tenant=%r db=%r host=%r port=%r uri=%r",
username,
ctx.tenant_id if ctx else None,
tenant_id,
db_name,
cfg.MONGODB_HOST,
cfg.MONGODB_PORT,
@@ -644,15 +678,17 @@ def get_user(username):
return users.find_one({'Username': username}) or users.find_one({'username': username})
# Try current tenant first when available
try:
from tenant import get_tenant_context
ctx = get_tenant_context()
if ctx and ctx.db_name:
user = find_in_db(ctx.db_name)
if user:
return user
except Exception:
pass
tenant_db, tenant_id = _resolve_request_tenant_db()
if tenant_db:
user = find_in_db(tenant_db)
if user:
return user
if _has_tenant_configs() and tenant_db is None:
logger.warning(
"Refusing default DB fallback for user lookup because tenant configs exist and no tenant was resolved."
)
return None
# Fallback to default configured database
user = find_in_db(cfg.MONGODB_DB)
+1
View File
@@ -0,0 +1 @@
# Web.modules.emailservice package initialization
+1
View File
@@ -0,0 +1 @@
# Web.modules.log package initialization
+149
View File
@@ -0,0 +1,149 @@
"""
Tamper-evident audit logging helpers.
The audit chain stores each entry with a hash of the previous entry.
Any mutation in history breaks the chain verification.
"""
import datetime
import hashlib
import json
import random
import time
from pymongo.errors import DuplicateKeyError
def _stable_json(value):
"""Serialize dictionaries in a stable way for deterministic hashing."""
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
def _entry_hash(prev_hash, payload):
"""Build the chained entry hash from previous hash + canonical payload."""
base = f"{prev_hash}|{_stable_json(payload)}"
return hashlib.sha256(base.encode("utf-8")).hexdigest()
def append_audit_event(db, event_type, actor, payload, request_ip=None, source="web", max_retries=5):
"""
Append an audit event to a tamper-evident chain.
Args:
db: MongoDB database handle.
event_type (str): Event category.
actor (str): User/system who performed the action.
payload (dict): Event details.
request_ip (str, optional): Request origin.
source (str): Source subsystem.
Returns:
dict: Inserted audit entry.
"""
logs = db["audit_log"]
attempts = 0
while attempts <= max_retries:
previous = logs.find_one(sort=[("chain_index", -1)])
prev_hash = previous.get("entry_hash", "") if previous else ""
chain_index = int(previous.get("chain_index", 0)) + 1 if previous else 1
timestamp = datetime.datetime.utcnow()
entry_payload = {
"event_type": event_type,
"actor": actor or "system",
"source": source,
"ip": request_ip or "",
"payload": payload or {},
"timestamp": timestamp.isoformat() + "Z",
}
entry_hash = _entry_hash(prev_hash, entry_payload)
entry = {
**entry_payload,
"created_at": timestamp,
"prev_hash": prev_hash,
"entry_hash": entry_hash,
"chain_index": chain_index,
}
try:
logs.insert_one(entry)
return entry
except DuplicateKeyError:
attempts += 1
if attempts > max_retries:
raise
# Exponential backoff with jitter to avoid retry storms.
delay = min(0.25, (0.005 * (2 ** attempts)) + random.random() * 0.01)
time.sleep(delay)
def ensure_audit_indexes(db):
"""Create indexes required for fast and safe audit operations."""
logs = db["audit_log"]
logs.create_index("chain_index", unique=True, name="audit_chain_index_unique")
logs.create_index("created_at", name="audit_created_at_idx")
logs.create_index("event_type", name="audit_event_type_idx")
def verify_audit_chain(db):
"""Verify hash chain integrity across all stored audit entries."""
logs = db["audit_log"]
entries = list(logs.find({}, {"_id": 1, "event_type": 1, "actor": 1, "source": 1, "ip": 1, "payload": 1, "timestamp": 1, "prev_hash": 1, "entry_hash": 1, "chain_index": 1}).sort("chain_index", 1))
previous_hash = ""
previous_index = 0
mismatches = []
for entry in entries:
chain_index = int(entry.get("chain_index", 0))
prev_hash = entry.get("prev_hash", "")
entry_hash = entry.get("entry_hash", "")
payload = {
"event_type": entry.get("event_type", ""),
"actor": entry.get("actor", ""),
"source": entry.get("source", ""),
"ip": entry.get("ip", ""),
"payload": entry.get("payload", {}),
"timestamp": entry.get("timestamp", ""),
}
expected_hash = _entry_hash(previous_hash, payload)
if chain_index != previous_index + 1:
mismatches.append({
"chain_index": chain_index,
"error": "chain_index_gap",
"expected": previous_index + 1,
"found": chain_index,
})
if prev_hash != previous_hash:
mismatches.append({
"chain_index": chain_index,
"error": "prev_hash_mismatch",
"expected": previous_hash,
"found": prev_hash,
})
if entry_hash != expected_hash:
mismatches.append({
"chain_index": chain_index,
"error": "entry_hash_mismatch",
"expected": expected_hash,
"found": entry_hash,
})
previous_hash = entry_hash
previous_index = chain_index
return {
"ok": len(mismatches) == 0,
"count": len(entries),
"last_chain_index": previous_index,
"last_hash": previous_hash,
"mismatches": mismatches,
}
+45
View File
@@ -0,0 +1,45 @@
'''
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
"""
Funktion zum Protokollieren von Statusänderungen bei Ausleihungen
"""
import os
import datetime
from bson.objectid import ObjectId
def log_status_change(ausleihung_id, old_status, new_status, user=None):
"""
Protokolliert eine Statusänderung einer Ausleihung in einer Log-Datei.
Args:
ausleihung_id: Die ID der Ausleihung
old_status: Der alte Status
new_status: Der neue Status
user: Der Benutzer, der die Änderung vorgenommen hat (optional)
"""
try:
# Erstelle Log-Verzeichnis, falls es nicht existiert
log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'logs')
if not os.path.exists(log_dir):
os.makedirs(log_dir)
# Log-Datei für Statusänderungen
log_file = os.path.join(log_dir, 'ausleihungen_status_changes.log')
# Protokolliere die Änderung
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
user_info = f" by {user}" if user else ""
with open(log_file, 'a', encoding='utf-8') as f:
f.write(f"{timestamp}: Ausleihung {ausleihung_id} - Status changed from '{old_status}' to '{new_status}'{user_info}\n")
return True
except Exception as e:
print(f"Fehler beim Protokollieren der Statusänderung: {e}")
return False
+1 -1
View File
@@ -124,7 +124,7 @@
</div>
<div class="head-actions">
<a class="btn btn-outline-secondary" href="{{ url_for('library_loans_admin') }}">Zur Bibliotheks-Ausleihenverwaltung</a>
<a class="btn btn-secondary" href="{{ url_for('library_view') }}">Bibliothek öffnen</a>
<a class="btn btn-secondary" href="{{ url_for('library_admin') }}">Bibliothek öffnen</a>
</div>
</div>
+102 -23
View File
@@ -7,11 +7,12 @@ Supports subdomain-based tenant identification and per-tenant database namespaci
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, session, has_request_context
from functools import wraps
import logging
import os
import re
import ipaddress
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
@@ -88,6 +89,33 @@ def _tenant_id_for_port(port):
return None
def _find_registered_tenant_id(candidate):
candidate = str(candidate or '').strip()
if not candidate:
return None
if candidate in TENANT_REGISTRY:
return candidate
lowered = candidate.lower()
for tenant_id in TENANT_REGISTRY:
if str(tenant_id).lower() == lowered:
return tenant_id
return None
def _is_ip_host(hostname):
hostname = str(hostname or '').strip()
if not hostname:
return False
try:
ipaddress.ip_address(hostname)
return True
except ValueError:
return False
def get_tenant_config(tenant_id=None):
"""Return the registered config for a tenant, falling back to default."""
if tenant_id is None:
@@ -113,6 +141,28 @@ def _normalize_db_name(db_name):
return db_name
def _module_name_candidates(module_name):
normalized = str(module_name or '').strip().lower().replace('-', '_')
if not normalized:
return []
candidates = [normalized]
alias_groups = {
'inventory': {'inventory', 'inventar'},
'library': {'library', 'bib', 'bibliothek'},
'student_cards': {'student_cards', 'studentcards', 'schuelerausweise', 'schueler_ausweise'},
}
for canonical_name, aliases in alias_groups.items():
if normalized in aliases:
for alias in (canonical_name, *sorted(aliases)):
if alias not in candidates:
candidates.append(alias)
break
return candidates
def _tenant_db_aliases(tenant_id):
aliases = []
env_map = _parse_tenant_db_map()
@@ -153,8 +203,12 @@ def _resolve_db_alias(tenant_id, db_name):
def is_tenant_module_enabled(module_name, tenant_id=None, default=False):
"""Resolve whether a feature module is enabled for the current tenant."""
config = get_tenant_config(tenant_id)
enabled = _get_nested_value(config, ['modules', module_name, 'enabled'], default)
return bool(enabled)
for candidate_name in _module_name_candidates(module_name):
enabled = _get_nested_value(config, ['modules', candidate_name, 'enabled'], None)
if enabled is not None:
return bool(enabled)
return bool(default)
class TenantContext:
@@ -181,13 +235,15 @@ class TenantContext:
# Priority 1: X-Tenant-ID header (for testing/internal APIs)
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
if tenant_from_header:
self.tenant_id = tenant_from_header
self.config = get_tenant_config(tenant_from_header)
return self._get_db_name(tenant_from_header)
matched_tenant = _find_registered_tenant_id(tenant_from_header) or tenant_from_header
self.tenant_id = matched_tenant
self.config = get_tenant_config(matched_tenant)
session['tenant_id'] = matched_tenant
return self._get_db_name(matched_tenant)
# Priority 2: Port-based tenant mapping
host = request.host.lower()
_, port = _parse_port_from_host(host)
hostname, port = _parse_port_from_host(host)
self.port = port
logger.info(f"Tenant resolution start: request.host={host} request.headers={dict(request.headers)}")
if port:
@@ -195,6 +251,7 @@ class TenantContext:
if tenant_from_port:
self.tenant_id = tenant_from_port
self.config = get_tenant_config(tenant_from_port)
session['tenant_id'] = tenant_from_port
logger.info(
f"Tenant resolution by port: host={host} port={port} tenant={tenant_from_port} config={self.config}"
)
@@ -202,24 +259,46 @@ class TenantContext:
logger.info(f"Tenant port not mapped: host={host} port={port}")
# Priority 3: Subdomain extraction
parts = host.split('.')
host_without_port = (hostname or '').strip().lower()
direct_host_match = _find_registered_tenant_id(host_without_port)
if direct_host_match:
self.subdomain = host_without_port
self.tenant_id = direct_host_match
self.config = get_tenant_config(direct_host_match)
session['tenant_id'] = direct_host_match
logger.info(
f"Tenant resolution by direct host match: host={host} tenant={direct_host_match} config={self.config}"
)
return self._get_db_name(direct_host_match)
# Extract subdomain from host
# Examples: schule1.example.com → schule1
# app.example.com → app (skip wildcard/app)
if len(parts) >= 3:
potential_subdomain = parts[0]
if host_without_port and not _is_ip_host(host_without_port):
parts = host_without_port.split('.')
if len(parts) >= 2:
potential_subdomain = parts[0]
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
matched_tenant = _find_registered_tenant_id(potential_subdomain)
if matched_tenant:
self.subdomain = potential_subdomain
self.tenant_id = matched_tenant
self.config = get_tenant_config(matched_tenant)
session['tenant_id'] = matched_tenant
logger.info(
f"Tenant resolution by subdomain: host={host} tenant={matched_tenant} config={self.config}"
)
return self._get_db_name(matched_tenant)
logger.info(f"Tenant subdomain not registered: {potential_subdomain}")
else:
logger.info(f"Tenant subdomain ignored: {potential_subdomain}")
# Filter out common non-tenant subdomains
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
self.subdomain = potential_subdomain
self.tenant_id = potential_subdomain
self.config = get_tenant_config(potential_subdomain)
logger.info(
f"Tenant resolution by subdomain: host={host} tenant={potential_subdomain} config={self.config}"
)
return self._get_db_name(potential_subdomain)
logger.info(f"Tenant subdomain ignored: {potential_subdomain}")
# Priority 4: sticky tenant from the authenticated session
session_tenant = session.get('tenant_id', '').strip() if session.get('tenant_id') else ''
if session_tenant:
self.tenant_id = session_tenant
self.config = get_tenant_config(session_tenant)
logger.info(
f"Tenant resolution by session: host={host} tenant={session_tenant} config={self.config}"
)
return self._get_db_name(session_tenant)
# Fallback to default tenant if no tenant identifier found.
# If no explicit 'default' tenant config exists, use configured MongoDB DB.
+88 -21
View File
@@ -9,7 +9,9 @@ if [ ! -f "docker-compose-multitenant.yml" ]; then
exit 1
fi
CONFIG_FILE="$PWD/config.json"
# 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
@@ -70,6 +72,24 @@ show_help() {
exit 1
}
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"
@@ -85,15 +105,25 @@ with open(path, 'r', encoding='utf-8') as 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 != tenant_id:
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 = tenants.get(tenant_id)
if existing is None or not isinstance(existing, dict):
existing = {}
existing = {}
for alias in aliases:
alias_cfg = tenants.get(alias)
if isinstance(alias_cfg, dict):
existing = alias_cfg
break
existing['port'] = int(port_str)
tenants[tenant_id] = existing
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)
@@ -212,13 +242,15 @@ EOF
}
restart_app_container() {
local env_file="$PWD/.docker-build.env"
local workdir="$SCRIPT_DIR"
local env_file="$SCRIPT_DIR/.docker-build.env"
local compose_args=()
ensure_runtime_config_json
# If HOST_WORKDIR is set (called from container), use absolute paths so docker daemon resolves them correctly
if [ -n "$HOST_WORKDIR" ]; then
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")" )
@@ -228,9 +260,9 @@ restart_app_container() {
fi
else
# Normal case: called directly from host
compose_args+=( -f "$PWD/docker-compose-multitenant.yml" )
if [ -f "$PWD/.docker-compose.runtime.override.yml" ]; then
compose_args+=( -f "$PWD/.docker-compose.runtime.override.yml" )
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" )
@@ -238,7 +270,7 @@ restart_app_container() {
fi
# Pass along COMPOSE_PROJECT_NAME if set so the internal docker-compose sees it
if [ -n "$COMPOSE_PROJECT_NAME" ]; then
if [ -n "${COMPOSE_PROJECT_NAME:-}" ]; then
compose_args=( -p "$COMPOSE_PROJECT_NAME" "${compose_args[@]}" )
fi
@@ -259,9 +291,20 @@ if not os.path.isfile(path):
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants', {})
if not isinstance(tenants, dict) or tenant_id not in tenants or not isinstance(tenants[tenant_id], dict):
if not isinstance(tenants, dict):
sys.exit(2)
removed = tenants.pop(tenant_id, None)
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)
@@ -373,7 +416,7 @@ case "$COMMAND" in
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -n "$APP_CONTAINER" ]; then
docker exec $APP_CONTAINER python3 -c "
import sys, re; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient; import hashlib
import sys, re; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient; import hashlib
tenant_id = sys.argv[1].lower()
sanitized = ''.join(c for c in tenant_id if c.isalnum() or c == '_')
db_name = f'inventar_{sanitized}'
@@ -441,7 +484,7 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
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/Web'); from tenant import TenantContext; import settings; from pymongo import MongoClient
import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from tenant import TenantContext; from Web.modules.database import settings; from pymongo import MongoClient
ctx = TenantContext()
db_name = ctx._get_db_name(sys.argv[1])
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
@@ -488,7 +531,7 @@ print(f'Database for tenant {sys.argv[1]} dropped.')
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/Web'); import settings; from pymongo import MongoClient
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() # Force sign-out / session clear
@@ -535,8 +578,9 @@ PY
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')
import settings
from Web.modules.database import settings
from pymongo import MongoClient
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
prefix = 'inventar_'
@@ -575,11 +619,26 @@ with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.setdefault('tenants', {})
tenant_cfg = tenants.setdefault(tenant_id, {})
if 'port' not in tenant_cfg:
print(f"Warning: Tenant {tenant_id} doesn't have a port mapping in config.json.", file=sys.stderr)
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:
@@ -591,6 +650,14 @@ for arg in module_args:
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
+1 -2
View File
@@ -489,7 +489,7 @@ main() {
# If user requested a development install, perform a simple dev deploy flow
if [ "$MODE" = "development" ]; then
log_message "Requested development install"
local tag="development"
local tag="dev"
local app_image="$APP_IMAGE_REPO:$tag"
local compose_path="$PROJECT_DIR/$COMPOSE_FILE"
@@ -523,7 +523,6 @@ EOF
# Bring up stack
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull app mongodb >> "$LOG_FILE" 2>&1 || true
docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
if ! verify_stack_health; then
log_message "ERROR: Development deployment failed health check"