Compare commits

...

22 Commits

Author SHA1 Message Date
Aiirondev_dev 6f5e24104b Refactor restart script: improve Docker container restart logic and update completion messages 2026-04-28 17:06:37 +02:00
Aiirondev_dev 43406c29d1 Enhance tenant management script: switch to bash, enforce strict mode, and validate port input 2026-04-28 16:54:42 +02:00
Aiirondev_dev 7873c45cfc Refactor multi-tenant deployment: update port handling in scripts, add tenant port registration, and enhance Docker configurations
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 16:36:55 +02:00
Aiirondev_dev 14c4192306 Refactor Docker setup: remove Nginx and TLS configurations, update service dependencies, and adjust health check endpoints
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 15:41:10 +02:00
Aiirondev_dev 1efc7e01be Update HTTP and HTTPS port configuration in .docker-build.env
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 14:38:44 +02:00
Aiirondev_dev d567ba583b Update port configuration for multi-tenant deployment to avoid conflicts
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 13:21:34 +02:00
Aiirondev_dev 5a5af5375d Add missing Nginx configuration for SSL and proxy settings
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 11:17:33 +02:00
Aiirondev_dev a60eb6eebf Update health check endpoint in verify_stack_health function in start.sh and update.sh
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 10:55:29 +02:00
Aiirondev_dev 23b7a4cd2f Remove obsolete configuration file config.yml 2026-04-26 21:38:02 +02:00
Aiirondev_dev e6fd485049 Update service configuration in config.yml and simplify cloudflared command in docker-compose 2026-04-26 17:01:20 +02:00
Aiirondev_dev 15d9eba987 Fix server block syntax in Nginx configuration in update.sh 2026-04-26 16:34:42 +02:00
Aiirondev_dev 05d6d299da Enhance scheduler lock file handling and add health check endpoint 2026-04-26 16:17:29 +02:00
Aiirondev_dev ab9db1211c Add server cleanup function to update.sh for Docker and log management
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 15:51:14 +02:00
Aiirondev_dev 45b69e5ddb Update HTTPS port configuration and add cloudflared setup in Docker Compose 2026-04-26 15:35:29 +02:00
Aiirondev_dev 4971bc859b Add disk space checks and cleanup functions in start.sh and update.sh 2026-04-24 21:29:39 +02:00
Aiirondev_dev b7f55b0de0 Remove custom command for MongoDB service in Docker Compose 2026-04-24 21:18:53 +02:00
Aiirondev_dev 9c24e79bba Add retry mechanism for Docker Compose startup in start.sh 2026-04-24 21:13:31 +02:00
Aiirondev_dev 79c325329c Refactor cloudflared configuration to use external config file and update command syntax 2026-04-24 21:05:21 +02:00
Aiirondev_dev 8f81ffb4c5 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-04-24 20:58:09 +02:00
Aiirondev_dev 1d7692ea01 Add cloudflared service and configure tunnel profile in Docker Compose 2026-04-24 20:56:15 +02:00
Aiirondev_dev 038390b8cd Add multi-tenant configuration support with dynamic module enabling 2026-04-24 09:16:04 +02:00
Aiirondev_dev f2c1dc2ba5 Implement session validation for active users before request processing
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 23:00:22 +02:00
19 changed files with 602 additions and 436 deletions
+1 -2
View File
@@ -1,4 +1,3 @@
NUITKA_BUILD=0 NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80 INVENTAR_HTTP_PORT=10000
INVENTAR_HTTPS_PORT=443
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
+33 -32
View File
@@ -158,42 +158,17 @@ jobs:
- name: Create release-only docker bundle - name: Create release-only docker bundle
run: | run: |
mkdir -p release-bundle mkdir -p release-bundle
mkdir -p release-bundle/docker/nginx
cat > release-bundle/docker-compose.yml <<EOF cat > release-bundle/docker-compose.yml <<EOF
services: services:
nginx:
image: nginx:1.27-alpine
container_name: inventarsystem-nginx
restart: unless-stopped
depends_on:
- app
ports:
- "${INVENTAR_HTTP_PORT:-80}:80"
- "${INVENTAR_HTTPS_PORT:-443}:443"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
mongodb:
image: mongo:7.0
container_name: inventarsystem-mongodb
restart: unless-stopped
volumes:
- mongodb_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
app: app:
image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:latest} image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}}
pull_policy: never
container_name: inventarsystem-app container_name: inventarsystem-app
restart: unless-stopped restart: unless-stopped
ports:
- "${INVENTAR_HTTP_PORT:-10000}:8000"
depends_on: depends_on:
mongodb: - mongodb
condition: service_healthy - redis
environment: environment:
INVENTAR_MONGODB_HOST: mongodb INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017" INVENTAR_MONGODB_PORT: "27017"
@@ -211,6 +186,33 @@ jobs:
- app_backups:/data/backups - app_backups:/data/backups
- app_logs:/data/logs - app_logs:/data/logs
mongodb:
image: mongo:7.0
container_name: inventarsystem-mongodb
restart: unless-stopped
volumes:
- mongodb_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
container_name: inventarsystem-redis
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes: volumes:
mongodb_data: mongodb_data:
app_uploads: app_uploads:
@@ -219,9 +221,9 @@ jobs:
app_qrcodes: app_qrcodes:
app_backups: app_backups:
app_logs: app_logs:
redis_data:
EOF EOF
cp docker/nginx/default.conf release-bundle/docker/nginx/default.conf
cp start.sh release-bundle/start.sh cp start.sh release-bundle/start.sh
cp stop.sh release-bundle/stop.sh cp stop.sh release-bundle/stop.sh
cp restart.sh release-bundle/restart.sh cp restart.sh release-bundle/restart.sh
@@ -232,7 +234,6 @@ jobs:
# Multitenant scripts & docs # Multitenant scripts & docs
cp docker-compose-multitenant.yml release-bundle/docker-compose-multitenant.yml cp docker-compose-multitenant.yml release-bundle/docker-compose-multitenant.yml
cp docker/nginx/multitenant.conf release-bundle/docker/nginx/multitenant.conf
cp manage-tenant.sh release-bundle/manage-tenant.sh cp manage-tenant.sh release-bundle/manage-tenant.sh
cp run-tenant-cmd.sh release-bundle/run-tenant-cmd.sh cp run-tenant-cmd.sh release-bundle/run-tenant-cmd.sh
cp MULTITENANT_DEPLOYMENT.md release-bundle/MULTITENANT_DEPLOYMENT.md cp MULTITENANT_DEPLOYMENT.md release-bundle/MULTITENANT_DEPLOYMENT.md
+1 -1
View File
@@ -14,7 +14,7 @@ Die optimierte Multi-Tenant-Architektur unterstützt **mehrere isolierte Instanz
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
↓ ↓ ↓ ↓ ↓ ↓
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ App :8001 │ │ App :8002 │ │ App :8003 │ App :10000 │ │ App :10002 │ │ App :10004
│ schule1 │ │ schule2 │ │ schule3 │ │ schule1 │ │ schule2 │ │ schule3 │
│ Tenant: t1 │ │ Tenant: t2 │ │ Tenant: t3 │ │ Tenant: t1 │ │ Tenant: t2 │ │ Tenant: t3 │
│ 20 Users │ │ 20 Users │ │ 20 Users │ │ 20 Users │ │ 20 Users │ │ 20 Users │
+26
View File
@@ -322,6 +322,32 @@ INVENTAR_WORKER_CONNECTIONS=100
--- ---
## Schul-Konfiguration pro Tenant
Die Datei `config.json` unterstützt jetzt einen `tenants`-Block. Damit kann jede Schule eigene Modul-Schalter bekommen, ohne dass das ganze System global umgestellt werden muss.
```json
{
"tenants": {
"schule1": {
"modules": {
"library": { "enabled": true },
"student_cards": { "enabled": false }
}
},
"schule2": {
"modules": {
"library": { "enabled": false }
}
}
}
}
```
Wenn ein Request über Subdomain oder `X-Tenant-ID` aufgelöst wird, liest die App diese Werte automatisch aus und blendet die Bibliothek bzw. andere Module nur für diesen Tenant ein oder aus.
---
## Support & Debugging ## Support & Debugging
**Fragen?** **Fragen?**
+38 -4
View File
@@ -339,6 +339,28 @@ def _enforce_user_permissions():
return None return None
@app.before_request
def _enforce_active_session_user():
endpoint = request.endpoint or ''
if endpoint == 'static' or endpoint.startswith('static'):
return None
username = session.get('username')
if not username:
return None
user = us.get_user(username)
if user:
return None
session.clear()
if request.path.startswith('/api/') or request.is_json:
return jsonify({'ok': False, 'message': 'Sitzung ungültig. Bitte erneut anmelden.'}), 401
flash('Ihre Sitzung ist nicht mehr gültig. Bitte erneut anmelden.', 'error')
return redirect(url_for('login'))
def _get_asset_version(): def _get_asset_version():
"""Return a cache-busting asset version tied to deployment state.""" """Return a cache-busting asset version tied to deployment state."""
env_version = os.getenv('INVENTAR_ASSET_VERSION', '').strip() env_version = os.getenv('INVENTAR_ASSET_VERSION', '').strip()
@@ -1291,9 +1313,17 @@ def _initialize_scheduler():
if lock_age > 300: # 5 minutes - indicates a stale lock from a previous container run if lock_age > 300: # 5 minutes - indicates a stale lock from a previous container run
os.remove(scheduler_lock_path) os.remove(scheduler_lock_path)
app.logger.info(f"Removed stale scheduler lock file (age: {lock_age:.0f}s)") app.logger.info(f"Removed stale scheduler lock file (age: {lock_age:.0f}s)")
except Exception: except Exception as e:
pass # If we can't clean up, continue anyway app.logger.warning(f"Could not clean up scheduler lock file: {e}")
# Always try to remove lock file on startup (extra safety)
try:
if os.path.exists(scheduler_lock_path):
os.remove(scheduler_lock_path)
app.logger.info("Scheduler lock file removed on startup.")
except Exception as e:
app.logger.warning(f"Could not remove scheduler lock file on startup: {e}")
try: try:
# Try to create the lock file - only succeeds if it doesn't exist # Try to create the lock file - only succeeds if it doesn't exist
lock_fd = os.open(scheduler_lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) lock_fd = os.open(scheduler_lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
@@ -1302,7 +1332,7 @@ def _initialize_scheduler():
except FileExistsError: except FileExistsError:
should_start = False should_start = False
app.logger.warning("Scheduler lock exists - another process is already running the scheduler") app.logger.warning("Scheduler lock exists - another process is already running the scheduler")
if should_start: if should_start:
scheduler.add_job(func=create_daily_backup, trigger="interval", hours=cfg.BACKUP_INTERVAL_HOURS) scheduler.add_job(func=create_daily_backup, trigger="interval", hours=cfg.BACKUP_INTERVAL_HOURS)
scheduler.add_job(func=update_appointment_statuses, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN) scheduler.add_job(func=update_appointment_statuses, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
@@ -10784,6 +10814,10 @@ def get_optimal_image_quality(img, target_size_kb=80):
# PUSH NOTIFICATION API ENDPOINTS # PUSH NOTIFICATION API ENDPOINTS
# ============================================================================ # ============================================================================
@app.route('/health')
def health_check():
return 'OK', 200
@app.route('/api/push/subscribe', methods=['POST']) @app.route('/api/push/subscribe', methods=['POST'])
def subscribe_to_push(): def subscribe_to_push():
""" """
+2 -2
View File
@@ -13,6 +13,6 @@ redis
reportlab reportlab
python-barcode python-barcode
openpyxl openpyxl
cryptography cryptography>=42.0.0
pywebpush pywebpush
py-vapid==1.9.0 py-vapid>=1.9.0
+30 -2
View File
@@ -157,8 +157,36 @@ SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods']) SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
# Optional feature modules # Optional feature modules
LIBRARY_MODULE_ENABLED = bool(_get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled'])) TENANT_CONFIGS = _get(_conf, ['tenants'], {})
STUDENT_CARDS_MODULE_ENABLED = bool(_get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
class _TenantAwareBool:
def __init__(self, module_name, default):
self.module_name = module_name
self.default = bool(default)
def resolve(self):
try:
from tenant import is_tenant_module_enabled
return bool(is_tenant_module_enabled(self.module_name, default=self.default))
except Exception:
return self.default
def __bool__(self):
return self.resolve()
def __int__(self):
return int(self.resolve())
def __str__(self):
return 'True' if self.resolve() else 'False'
def __repr__(self):
return f"_TenantAwareBool(module_name={self.module_name!r}, value={self.resolve()!r})"
LIBRARY_MODULE_ENABLED = _TenantAwareBool('library', _get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
STUDENT_CARDS_MODULE_ENABLED = _TenantAwareBool('student_cards', _get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
STUDENT_DEFAULT_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'default_borrow_days'], DEFAULTS['modules']['student_cards']['default_borrow_days'])) STUDENT_DEFAULT_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'default_borrow_days'], DEFAULTS['modules']['student_cards']['default_borrow_days']))
STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'max_borrow_days'], DEFAULTS['modules']['student_cards']['max_borrow_days'])) STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'max_borrow_days'], DEFAULTS['modules']['student_cards']['max_borrow_days']))
+93 -4
View File
@@ -10,28 +10,104 @@ 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
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Tenant registry: maps subdomain/tenant_id to database name # Tenant registry: maps subdomain/tenant_id to database name
TENANT_REGISTRY = {} TENANT_REGISTRY = {}
if isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict):
TENANT_REGISTRY.update(cfg.TENANT_CONFIGS)
def _get_nested_value(source, path, default=None):
current = source
for key in path:
if isinstance(current, dict) and key in current:
current = current[key]
else:
return default
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:
ctx = get_tenant_context()
tenant_id = ctx.tenant_id if ctx and ctx.tenant_id else 'default'
if tenant_id in TENANT_REGISTRY:
return TENANT_REGISTRY[tenant_id] or {}
return TENANT_REGISTRY.get('default', {}) or {}
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)
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 = {}
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
@@ -40,10 +116,21 @@ class TenantContext:
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip() tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
if tenant_from_header: if tenant_from_header:
self.tenant_id = 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) 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
@@ -56,10 +143,12 @@ class TenantContext:
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'): if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
self.subdomain = potential_subdomain self.subdomain = potential_subdomain
self.tenant_id = potential_subdomain self.tenant_id = 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')
return self._get_db_name('default') return self._get_db_name('default')
def _get_db_name(self, tenant_id): def _get_db_name(self, tenant_id):
+4 -2
View File
@@ -1,9 +1,9 @@
{ {
"dbg": false, "dbg": false,
"key": "InventarsystemSecureKey2026XYZ789abcdef012", "key": "InventarsystemSecureKey2026XYZ789abcdef012",
"ver": "0.0.2", "ver": "0.6.44",
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 443, "port": 8000,
"mongodb": { "mongodb": {
"host": "localhost", "host": "localhost",
@@ -50,6 +50,8 @@
} }
}, },
"tenants": {},
"allowed_extensions": [ "allowed_extensions": [
"png", "jpg", "jpeg", "gif", "png", "jpg", "jpeg", "gif",
"hevc", "heif", "hevc", "heif",
+5 -31
View File
@@ -1,12 +1,12 @@
# Multi-Tenant Optimized Docker Compose # Multi-Tenant Optimized Docker Compose
# Supports running multiple isolated app instances per subdomain # Supports running multiple isolated app instances with direct Docker host port binding
# Each instance: ~50MB base + 20-30MB per 20 users # Each instance: ~50MB base + 20-30MB per 20 users
# #
# Usage: # Usage:
# docker-compose -f docker-compose-multitenant.yml up -d # docker-compose -f docker-compose-multitenant.yml up -d
# #
# Scale example: To support 10 tenants with 20 users each: # Example: Start the stack and expose the app on host port 10000
# docker-compose -f docker-compose-multitenant.yml up -d --scale app=10 # INVENTAR_HTTP_PORT=10000 docker-compose -f docker-compose-multitenant.yml up -d
services: services:
# Management Container for multi-tenant scripts # Management Container for multi-tenant scripts
@@ -21,33 +21,6 @@ services:
- .:/workspace - .:/workspace
entrypoint: ["sh", "-c", "cd /workspace && ./manage-tenant.sh \"$$@\"", "--"] entrypoint: ["sh", "-c", "cd /workspace && ./manage-tenant.sh \"$$@\"", "--"]
nginx:
image: nginx:1.27-alpine
container_name: inventarsystem-nginx
restart: unless-stopped
depends_on:
app:
condition: service_started
redis:
condition: service_started
ports:
- "${INVENTAR_HTTP_PORT:-80}:80"
- "${INVENTAR_HTTPS_PORT:-443}:443"
volumes:
- ./docker/nginx/multitenant.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
- ./docker/nginx/acme:/etc/nginx/acme:ro
networks:
- inventar-net
healthcheck:
test: ["CMD", "wget", "-q", "-O-", "http://localhost/health"]
interval: 30s
timeout: 5s
retries: 3
environment:
NGINX_WORKER_PROCESSES: auto
NGINX_WORKER_CONNECTIONS: 1024
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: inventarsystem-redis container_name: inventarsystem-redis
@@ -69,7 +42,6 @@ services:
image: mongo:7.0 image: mongo:7.0
container_name: inventarsystem-mongodb container_name: inventarsystem-mongodb
restart: unless-stopped restart: unless-stopped
command: mongod --wiredTigerCacheSizeGB 2
expose: expose:
- "27017" - "27017"
volumes: volumes:
@@ -102,6 +74,8 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
ports:
- "${INVENTAR_HTTP_PORT:-10000}:8000"
networks: networks:
- inventar-net - inventar-net
expose: expose:
+26 -73
View File
@@ -1,88 +1,41 @@
version: "3.8"
services: services:
nginx: app:
image: nginx:1.27-alpine build: .
container_name: inventarsystem-nginx container_name: inventory-app
restart: unless-stopped restart: unless-stopped
environment:
- MONGO_URL=mongodb://mongodb:27017/inventar
- REDIS_URL=redis://redis:6379
- BASE_URL=https://inventar.maximiliangruendinger.de #alle öffentliche subdomains
depends_on: depends_on:
app: - mongodb
condition: service_started - redis
ports:
- "${INVENTAR_HTTP_PORT:-80}:80"
- "${INVENTAR_HTTPS_PORT:-443}:443"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
mongodb: mongodb:
image: mongo:7.0 image: mongo:latest
container_name: inventarsystem-mongodb container_name: mongodb
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- mongodb_data:/data/db - mongo_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
redis: redis:
image: redis:7-alpine image: redis:alpine
container_name: inventarsystem-redis container_name: redis
restart: unless-stopped restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--save", "60", "1000"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 10
app: cloudflared:
build: image: cloudflare/cloudflared:latest
context: . container_name: cloudflared
dockerfile: Dockerfile
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", "-"]
container_name: inventarsystem-app
restart: unless-stopped restart: unless-stopped
security_opt: # Der Tunnel-Name 'homeserver' muss zu deiner credentials.json passen
- no-new-privileges:true command: tunnel run homeserver
depends_on:
mongodb:
condition: service_healthy
redis:
condition: service_healthy
environment:
INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017"
INVENTAR_MONGODB_DB: Inventarsystem
INVENTAR_REDIS_HOST: redis
INVENTAR_REDIS_PORT: "6379"
INVENTAR_REDIS_CACHE_DB: "1"
INVENTAR_NOTIFICATION_STATUS_CACHE_TTL: "8"
INVENTAR_BACKUP_FOLDER: /data/backups
INVENTAR_LOGS_FOLDER: /data/logs
INVENTAR_DELETED_ARCHIVE_FOLDER: /data/deleted-archives
expose:
- "8000"
volumes: volumes:
- ./config.json:/app/config.json:ro - ./config.yml:/etc/cloudflared/config.yml
- app_uploads:/app/Web/uploads - ./credentials.json:/etc/cloudflared/credentials.json
- app_thumbnails:/app/Web/thumbnails depends_on:
- app_previews:/app/Web/previews - app
- app_qrcodes:/app/Web/QRCodes
- app_backups:/data/backups
- app_logs:/data/logs
- app_deleted_archives:/data/deleted-archives
volumes: volumes:
mongodb_data: mongo_data:
app_uploads:
app_thumbnails:
app_previews:
app_qrcodes:
app_backups:
app_logs:
app_deleted_archives:
redis_data:
+8
View File
@@ -0,0 +1,8 @@
tunnel: homeserver
credentials-file: /etc/cloudflared/credentials.json
ingress:
- service: https://nginx:443
originRequest:
noTLSVerify: true
- service: http_status:404
+2 -2
View File
@@ -473,8 +473,8 @@ EOF
if [ ! -f "$PROJECT_DIR/.docker-build.env" ]; then if [ ! -f "$PROJECT_DIR/.docker-build.env" ]; then
cat > "$TMP_DIR/.docker-build.env" <<EOF cat > "$TMP_DIR/.docker-build.env" <<EOF
NUITKA_BUILD=0 NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80 INVENTAR_HTTP_PORT=10000
INVENTAR_HTTPS_PORT=443 INVENTAR_HTTPS_PORT=10001
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag
EOF EOF
sudo install -m 644 "$TMP_DIR/.docker-build.env" "$PROJECT_DIR/.docker-build.env" sudo install -m 644 "$TMP_DIR/.docker-build.env" "$PROJECT_DIR/.docker-build.env"
+58 -12
View File
@@ -1,4 +1,6 @@
#!/bin/sh #!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# Script to manage multitenant deployment # Script to manage multitenant deployment
# Allows adding, removing, and restarting tenants without downtime for others # Allows adding, removing, and restarting tenants without downtime for others
@@ -7,23 +9,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
@@ -37,12 +78,17 @@ case "$COMMAND" in
echo "Error: Please provide a tenant_id." echo "Error: Please provide a tenant_id."
exit 1 exit 1
fi fi
echo "Adding new tenant '$TENANT_ID'..."
# Add Nginx configuration PORT_ARG="$3"
if [ -f "docker/nginx/multitenant.conf" ]; then if [ -n "$PORT_ARG" ]; then
echo "Assuming dynamic routing based on subdomain ($TENANT_ID)..." 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 fi
echo "Adding new tenant '$TENANT_ID'..."
# Initialize tenant database via Python inside container # Initialize tenant database via Python inside container
echo "Initializing database for $TENANT_ID..." echo "Initializing database for $TENANT_ID..."
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1) APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
+2 -2
View File
@@ -12,6 +12,6 @@ redis
reportlab reportlab
python-barcode python-barcode
openpyxl openpyxl
cryptography cryptography>=42.0.0
pywebpush pywebpush
py-vapid==1.9.0 py-vapid>=1.9.0
+37 -4
View File
@@ -1,7 +1,40 @@
#!/bin/bash #!/usr/bin/env bash
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/or restarting app container using $COMPOSE_FILE..."
if [ -f "$SCRIPT_DIR/Dockerfile" ]; then
docker compose -f "$COMPOSE_FILE" up -d --build app
else
echo "Warning: Dockerfile not found in $SCRIPT_DIR. Skipping build and restarting existing app container."
docker compose -f "$COMPOSE_FILE" up -d --no-build app
fi
echo "Cleaning up unused Docker images..."
docker image prune -f
echo "App restart complete."
+136 -181
View File
@@ -14,12 +14,20 @@ 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="8001" HTTP_PORT_VALUE="10000"
HTTPS_PORT_VALUE="8443" HTTP_PORTS_VALUE=""
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}"
COMPOSE_FILE="docker-compose-multitenant.yml" COMPOSE_FILE="docker-compose-multitenant.yml"
COMPOSE_PROFILES_VALUE=""
MIN_DOCKER_FREE_MB="${INVENTAR_MIN_DOCKER_FREE_MB:-1024}"
usage() { usage() {
cat <<EOF cat <<EOF
@@ -112,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
install_docker_engine if [ "$IS_ROOT" = "true" ]; then
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
@@ -136,14 +148,20 @@ ensure_runtime_dependencies() {
fi fi
if [ "${#missing[@]}" -gt 0 ]; then if [ "${#missing[@]}" -gt 0 ]; then
echo "Installing missing dependencies: ${missing[*]}" if [ "$IS_ROOT" = "true" ]; then
apt_install "${missing[@]}" 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 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
} }
@@ -156,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
@@ -197,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
@@ -207,101 +235,17 @@ 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"
} }
ensure_tls_certificates() {
local cert_dir cert_path key_path cn
cert_dir="$SCRIPT_DIR/certs"
cert_path="$cert_dir/inventarsystem.crt"
key_path="$cert_dir/inventarsystem.key"
mkdir -p "$cert_dir"
if [ -f "$cert_path" ] && [ -f "$key_path" ]; then
return 0
fi
cn="${TLS_CN:-localhost}"
echo "No TLS certificates found. Generating self-signed certificate for CN=$cn"
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout "$key_path" \
-out "$cert_path" \
-subj "/C=DE/ST=NA/L=NA/O=Inventarsystem/OU=IT/CN=$cn" >/dev/null 2>&1
chmod 600 "$key_path"
chmod 644 "$cert_path"
}
ensure_nginx_config_mount_source() {
local nginx_dir config_path backup_path
nginx_dir="$SCRIPT_DIR/docker/nginx"
config_path="$nginx_dir/default.conf"
mkdir -p "$nginx_dir"
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'
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name _;
ssl_certificate /etc/nginx/certs/inventarsystem.crt;
ssl_certificate_key /etc/nginx/certs/inventarsystem.key;
client_max_body_size 50M;
location / {
proxy_pass http://app:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
default_type text/html;
return 200 '<!doctype html><html><head><meta charset="utf-8"><title>Server Error</title></head><body><h1>Server Error</h1><p>The service is temporarily unavailable.</p></body></html>';
}
}
EOF
echo "Recreated missing nginx config at $config_path"
fi
}
ensure_runtime_config_json() { ensure_runtime_config_json() {
local config_path backup_path local config_path backup_path
config_path="$SCRIPT_DIR/config.json" config_path="$SCRIPT_DIR/config.json"
@@ -318,7 +262,7 @@ ensure_runtime_config_json() {
"ver": "2.6.5", "ver": "2.6.5",
"dbg": false, "dbg": false,
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 443, "port": 8000,
"mongodb": { "mongodb": {
"host": "mongodb", "host": "mongodb",
"port": 27017, "port": 27017,
@@ -457,97 +401,87 @@ find_free_port() {
echo "$port" echo "$port"
} }
stack_owns_host_port() { parse_port_list() {
local requested_port="$1" local raw="$1"
local container_port="$2" local port
local mapped_port local ports=()
raw="${raw//,/ }"
mapped_port="$(docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" port nginx "$container_port" 2>/dev/null | tail -n1 || true)" for port in $raw; do
if [ -z "$mapped_port" ]; then port="${port//[[:space:]]/}"
return 1 if [ -n "$port" ] && printf '%s\n' "$port" | grep -qE '^[0-9]+$'; then
fi ports+=("$port")
fi
mapped_port="${mapped_port##*:}" done
[ "$mapped_port" = "$requested_port" ] printf '%s\n' "${ports[@]}"
}
stop_host_nginx_services() {
local stopped_any=false
local service_name
if ! command -v systemctl >/dev/null 2>&1; then
return 1
fi
while IFS= read -r service_name; do
[ -z "$service_name" ] && continue
echo "Stopping host service $service_name to free web ports..."
$SUDO systemctl stop "$service_name" >/dev/null 2>&1 || true
stopped_any=true
done < <(systemctl list-units --type=service --state=active --no-pager 2>/dev/null | awk '{print $1}' | grep -E '(^nginx\.service$|nginx)' || true)
if [ "$stopped_any" = true ]; then
sleep 2
fi
if [ "$stopped_any" = true ]; then
return 0
fi
return 1
} }
configure_host_ports() { configure_host_ports() {
local requested_http requested_https local requested_http
local requested_ports
local 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)"
fi requested_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
if [ -z "$requested_http" ]; then
requested_http="8001"
fi fi
if stack_owns_host_port "$requested_http" "80"; then if [ -n "${INVENTAR_HTTP_PORTS:-}" ]; then
HTTP_PORT_VALUE="$requested_http" requested_ports="$INVENTAR_HTTP_PORTS"
elif port_in_use "$requested_http"; then 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"
echo "Freed HTTP port $requested_http by stopping host nginx service" fi
else
HTTP_PORT_VALUE="$(find_free_port 8080)" if [ -n "$requested_ports" ]; then
echo "HTTP port is in use. Using fallback HTTP port: $HTTP_PORT_VALUE" mapfile -t ports < <(parse_port_list "$requested_ports")
fi fi
if [ ${#ports[@]} -gt 0 ]; then
HTTP_PORTS_VALUE="${ports[*]}"
HTTP_PORT_VALUE="${ports[0]}"
else else
HTTP_PORT_VALUE="$requested_http" HTTP_PORT_VALUE="$DEFAULT_TENANT_PORT_START"
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
fi fi
requested_https="" if port_in_use "$HTTP_PORT_VALUE"; then
if [ -f "$ENV_FILE" ]; then HTTP_PORT_VALUE="$(find_free_port "$DEFAULT_TENANT_PORT_START")"
requested_https="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)" 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() {
local docker_root available_kb available_mb
if ! command -v df >/dev/null 2>&1; then
return 0
fi fi
if [ -z "$requested_https" ]; then docker_root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
requested_https="8443" if [ -z "$docker_root" ]; then
docker_root="/var/lib/docker"
fi fi
if stack_owns_host_port "$requested_https" "443"; then if [ ! -d "$docker_root" ]; then
HTTPS_PORT_VALUE="$requested_https" return 0
elif port_in_use "$requested_https"; then fi
if ! port_in_use "$requested_https"; then available_kb="$(df -Pk "$docker_root" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
HTTPS_PORT_VALUE="$requested_https" if [ -z "$available_kb" ]; then
echo "Freed HTTPS port $requested_https by stopping host nginx service" return 0
return fi
fi
HTTPS_PORT_VALUE="$(find_free_port 8443)" available_mb=$((available_kb / 1024))
echo "HTTPS port is in use. Using fallback HTTPS port: $HTTPS_PORT_VALUE"
else if [ "$available_mb" -lt "$MIN_DOCKER_FREE_MB" ]; then
HTTPS_PORT_VALUE="$requested_https" echo "Error: low disk space in Docker data root ($docker_root)."
echo "Available: ${available_mb} MB; required minimum: ${MIN_DOCKER_FREE_MB} MB"
echo "MongoDB may fail with 'No space left on device'. Free space and retry."
exit 1
fi fi
} }
@@ -555,7 +489,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_HTTPS_PORT=$HTTPS_PORT_VALUE INVENTAR_HTTP_PORTS=${HTTP_PORTS_VALUE// /,}
INVENTAR_APP_IMAGE=$APP_IMAGE_VALUE INVENTAR_APP_IMAGE=$APP_IMAGE_VALUE
EOF EOF
} }
@@ -569,6 +503,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() {
@@ -585,10 +534,10 @@ verify_stack_health() {
for _ in $(seq 1 60); do for _ in $(seq 1 60); do
running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)" running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)"
if printf '%s\n' "$running_services" | grep -Fxq app && \ if printf '%s\n' "$running_services" | grep -Fxq app && \
printf '%s\n' "$running_services" | grep -Fxq nginx && \ printf '%s\n' "$running_services" | grep -Fxq redis && \
printf '%s\n' "$running_services" | grep -Fxq mongodb; then printf '%s\n' "$running_services" | grep -Fxq mongodb; then
if docker compose "${compose_args[@]}" exec -T app python3 -c "import flask, pymongo" >/dev/null 2>&1; then if docker compose "${compose_args[@]}" exec -T app python3 -c "import flask, pymongo" >/dev/null 2>&1; then
if curl -kfsS "https://127.0.0.1:$HTTPS_PORT_VALUE" >/dev/null 2>&1; then if curl -fsS "http://127.0.0.1:$HTTP_PORT_VALUE/health" >/dev/null 2>&1; then
echo "Health check passed." echo "Health check passed."
return 0 return 0
fi fi
@@ -601,8 +550,8 @@ verify_stack_health() {
if [[ $retry_count -eq 0 ]]; then if [[ $retry_count -eq 0 ]]; then
echo "Health check failed. Attempting to restart containers..." echo "Health check failed. Attempting to restart containers..."
docker compose "${compose_args[@]}" ps || true docker compose "${compose_args[@]}" ps || true
docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb || true docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb || true
docker compose "${compose_args[@]}" restart app nginx mongodb docker compose "${compose_args[@]}" restart app redis mongodb
sleep 3 sleep 3
((retry_count++)) ((retry_count++))
else else
@@ -613,7 +562,7 @@ verify_stack_health() {
# Final failure # Final failure
echo "Error: stack health check failed after restart attempt." echo "Error: stack health check failed after restart attempt."
docker compose "${compose_args[@]}" ps || true docker compose "${compose_args[@]}" ps || true
docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb || true docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb || true
return 1 return 1
} }
@@ -621,13 +570,12 @@ parse_args "$@"
ensure_runtime_dependencies ensure_runtime_dependencies
setup_boot_autostart_service setup_boot_autostart_service
ensure_tls_certificates
ensure_nginx_config_mount_source
ensure_runtime_config_json ensure_runtime_config_json
setup_scheduled_jobs setup_scheduled_jobs
configure_nuitka_mode configure_nuitka_mode
resolve_app_image resolve_app_image
configure_host_ports configure_host_ports
ensure_min_docker_disk_space
ensure_app_image_loaded ensure_app_image_loaded
write_env_file write_env_file
write_runtime_compose_override write_runtime_compose_override
@@ -638,9 +586,16 @@ if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
compose_up_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE") compose_up_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
fi fi
compose_up_args+=(--env-file "$ENV_FILE") compose_up_args+=(--env-file "$ENV_FILE")
docker compose "${compose_up_args[@]}" up -d --remove-orphans if [ -n "$COMPOSE_PROFILES_VALUE" ]; then
export COMPOSE_PROFILES="$COMPOSE_PROFILES_VALUE"
fi
if ! docker compose "${compose_up_args[@]}" up -d --remove-orphans; then
echo "Docker Compose startup failed once. Waiting briefly and retrying..."
sleep 5
docker compose "${compose_up_args[@]}" up -d --remove-orphans
fi
verify_stack_health verify_stack_health
echo "Stack started." echo "Stack started."
echo "Open: https://<server-ip>:$HTTPS_PORT_VALUE" echo "Open: http://<server-ip>:$HTTP_PORT_VALUE"
+94 -82
View File
@@ -16,6 +16,8 @@ ENV_FILE="$PROJECT_DIR/.docker-build.env"
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo" APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
DIST_DIR="$PROJECT_DIR/dist" DIST_DIR="$PROJECT_DIR/dist"
COMPOSE_FILE="docker-compose-multitenant.yml" COMPOSE_FILE="docker-compose-multitenant.yml"
MIN_ROOT_FREE_MB="${INVENTAR_MIN_ROOT_FREE_MB:-2048}"
DIST_KEEP_COUNT="${INVENTAR_DIST_KEEP_COUNT:-2}"
mkdir -p "$LOG_DIR" mkdir -p "$LOG_DIR"
chmod 777 "$LOG_DIR" 2>/dev/null || true chmod 777 "$LOG_DIR" 2>/dev/null || true
@@ -36,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 "$@"
@@ -45,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
@@ -65,87 +72,77 @@ ensure_runtime_dependencies() {
fi fi
if [ "${#missing[@]}" -gt 0 ]; then if [ "${#missing[@]}" -gt 0 ]; then
log_message "Installing missing dependencies: ${missing[*]}" if [ "$IS_ROOT" = "true" ]; then
apt_install "${missing[@]}" 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 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
} }
ensure_tls_certificates() { ensure_min_root_disk_space() {
local cert_dir cert_path key_path cn local available_kb available_mb
cert_dir="$PROJECT_DIR/certs"
cert_path="$cert_dir/inventarsystem.crt"
key_path="$cert_dir/inventarsystem.key"
mkdir -p "$cert_dir" if ! command -v df >/dev/null 2>&1; then
if [ -f "$cert_path" ] && [ -f "$key_path" ]; then
return 0 return 0
fi fi
cn="${TLS_CN:-localhost}" available_kb="$(df -Pk "$PROJECT_DIR" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
log_message "No TLS certificates found. Generating self-signed certificate for CN=$cn" if [ -z "$available_kb" ]; then
return 0
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout "$key_path" \
-out "$cert_path" \
-subj "/C=DE/ST=NA/L=NA/O=Inventarsystem/OU=IT/CN=$cn" >/dev/null 2>&1
chmod 600 "$key_path"
chmod 644 "$cert_path"
}
ensure_nginx_config_mount_source() {
local nginx_dir config_path backup_path
nginx_dir="$PROJECT_DIR/docker/nginx"
config_path="$nginx_dir/default.conf"
mkdir -p "$nginx_dir"
if [ -d "$config_path" ]; then
backup_path="${config_path}.dir.$(date +%Y%m%d-%H%M%S).bak"
mv "$config_path" "$backup_path"
log_message "WARNING: Moved unexpected directory $config_path to $backup_path"
fi fi
if [ ! -f "$config_path" ]; then available_mb=$((available_kb / 1024))
cat > "$config_path" <<'EOF' if [ "$available_mb" -lt "$MIN_ROOT_FREE_MB" ]; then
server { log_message "ERROR: Low disk space on filesystem containing $PROJECT_DIR"
listen 80; log_message "Available: ${available_mb} MB; required minimum: ${MIN_ROOT_FREE_MB} MB"
server_name _; log_message "Free disk space and rerun update."
return 301 https://$host$request_uri; exit 1
fi
} }
server { cleanup_old_dist_artifacts() {
listen 443 ssl; local keep_count
server_name _; keep_count="$DIST_KEEP_COUNT"
ssl_certificate /etc/nginx/certs/inventarsystem.crt; if [ ! -d "$DIST_DIR" ]; then
ssl_certificate_key /etc/nginx/certs/inventarsystem.key; return 0
fi
client_max_body_size 50M; if ! [[ "$keep_count" =~ ^[0-9]+$ ]]; then
keep_count=2
fi
location / { mapfile -t archives < <(find "$DIST_DIR" -maxdepth 1 -type f \( -name 'inventarsystem-image-*.tar.gz' -o -name 'inventarsystem-image-*.tar' \) -printf '%T@ %p\n' | sort -nr | awk '{print $2}')
proxy_pass http://app:8000; if [ "${#archives[@]}" -le "$keep_count" ]; then
proxy_http_version 1.1; return 0
proxy_set_header Host $host; fi
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
}
error_page 500 502 503 504 /50x.html; local index old_archive deleted=0
location = /50x.html { for (( index=keep_count; index<${#archives[@]}; index++ )); do
default_type text/html; old_archive="${archives[$index]}"
return 200 '<!doctype html><html><head><meta charset="utf-8"><title>Server Error</title></head><body><h1>Server Error</h1><p>The service is temporarily unavailable.</p></body></html>'; if rm -f "$old_archive"; then
} deleted=$((deleted + 1))
fi
done
if [ "$deleted" -gt 0 ]; then
log_message "Cleaned up $deleted old dist image archive(s)"
fi
} }
EOF
log_message "Recreated missing nginx config at $config_path" cleanup_docker_dangling_images() {
if docker image prune -f >> "$LOG_FILE" 2>&1; then
log_message "Cleaned up dangling Docker images"
else
log_message "WARNING: Could not prune dangling Docker images"
fi fi
} }
@@ -347,9 +344,7 @@ download_and_extract_bundle() {
tar -xzf "$archive" -C "$tmp_dir" tar -xzf "$archive" -C "$tmp_dir"
# The bundle must contain docker deployment files only. # The bundle must contain docker deployment files only.
mkdir -p "$PROJECT_DIR/docker/nginx"
cp -f "$tmp_dir/docker-compose.yml" "$PROJECT_DIR/docker-compose.yml" cp -f "$tmp_dir/docker-compose.yml" "$PROJECT_DIR/docker-compose.yml"
cp -f "$tmp_dir/docker/nginx/default.conf" "$PROJECT_DIR/docker/nginx/default.conf"
cp -f "$tmp_dir/start.sh" "$PROJECT_DIR/start.sh" cp -f "$tmp_dir/start.sh" "$PROJECT_DIR/start.sh"
cp -f "$tmp_dir/stop.sh" "$PROJECT_DIR/stop.sh" cp -f "$tmp_dir/stop.sh" "$PROJECT_DIR/stop.sh"
@@ -365,9 +360,6 @@ download_and_extract_bundle() {
if [ -f "$tmp_dir/docker-compose-multitenant.yml" ]; then if [ -f "$tmp_dir/docker-compose-multitenant.yml" ]; then
cp -f "$tmp_dir/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose-multitenant.yml" cp -f "$tmp_dir/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose-multitenant.yml"
fi fi
if [ -f "$tmp_dir/docker/nginx/multitenant.conf" ]; then
cp -f "$tmp_dir/docker/nginx/multitenant.conf" "$PROJECT_DIR/docker/nginx/multitenant.conf"
fi
if [ -f "$tmp_dir/manage-tenant.sh" ]; then if [ -f "$tmp_dir/manage-tenant.sh" ]; then
cp -f "$tmp_dir/manage-tenant.sh" "$PROJECT_DIR/manage-tenant.sh" cp -f "$tmp_dir/manage-tenant.sh" "$PROJECT_DIR/manage-tenant.sh"
fi fi
@@ -408,8 +400,7 @@ deploy() {
if [ ! -f "$ENV_FILE" ]; then if [ ! -f "$ENV_FILE" ]; then
cat > "$ENV_FILE" <<EOF cat > "$ENV_FILE" <<EOF
NUITKA_BUILD=0 NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80 INVENTAR_HTTP_PORT=10000
INVENTAR_HTTPS_PORT=443
INVENTAR_APP_IMAGE=$app_image INVENTAR_APP_IMAGE=$app_image
EOF EOF
elif grep -q '^INVENTAR_APP_IMAGE=' "$ENV_FILE"; then elif grep -q '^INVENTAR_APP_IMAGE=' "$ENV_FILE"; then
@@ -428,27 +419,27 @@ EOF
fi fi
fi fi
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull nginx mongodb >> "$LOG_FILE" 2>&1 docker compose -f "$compose_path" --env-file "$ENV_FILE" pull app mongodb >> "$LOG_FILE" 2>&1
docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1 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 docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
} }
verify_stack_health() { verify_stack_health() {
local compose_args running_services local compose_args running_services
local https_port local http_port
compose_args=(-f "$PROJECT_DIR/$COMPOSE_FILE" --env-file "$ENV_FILE") compose_args=(-f "$PROJECT_DIR/$COMPOSE_FILE" --env-file "$ENV_FILE")
https_port="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')" http_port="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')"
if [ -z "$https_port" ]; then if [ -z "$http_port" ]; then
https_port="443" http_port="10000"
fi fi
for _ in $(seq 1 60); do for _ in $(seq 1 60); do
running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)" running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)"
if printf '%s\n' "$running_services" | grep -Fxq app && \ if printf '%s\n' "$running_services" | grep -Fxq app && \
printf '%s\n' "$running_services" | grep -Fxq nginx && \ printf '%s\n' "$running_services" | grep -Fxq redis && \
printf '%s\n' "$running_services" | grep -Fxq mongodb; then printf '%s\n' "$running_services" | grep -Fxq mongodb; then
# Primary check: HTTP endpoint responds (most reliable) # Primary check: health endpoint responds (most reliable)
if curl -kfsS "https://127.0.0.1:$https_port" >/dev/null 2>&1; then if curl -fsS "http://127.0.0.1:$http_port/health" >/dev/null 2>&1; then
return 0 return 0
fi fi
fi fi
@@ -456,22 +447,41 @@ verify_stack_health() {
done done
docker compose "${compose_args[@]}" ps >> "$LOG_FILE" 2>&1 || true docker compose "${compose_args[@]}" ps >> "$LOG_FILE" 2>&1 || true
docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb >> "$LOG_FILE" 2>&1 || true docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb >> "$LOG_FILE" 2>&1 || true
return 1 return 1
} }
cleanup_server_space() {
log_message "Running server cleanup before update..."
# Remove unused Docker objects
if docker system prune -af --volumes >> "$LOG_FILE" 2>&1; then
log_message "Docker system pruned (all unused images, containers, volumes, networks)"
else
log_message "WARNING: Docker system prune failed"
fi
# Clean up old dist artifacts
cleanup_old_dist_artifacts
# Clean up log files older than 7 days
if find "$LOG_DIR" -type f -name '*.log' -mtime +7 -exec rm -f {} +; then
log_message "Old log files (older than 7 days) cleaned up"
else
log_message "WARNING: Failed to clean up old log files"
fi
}
main() { main() {
parse_args "$@" parse_args "$@"
cleanup_server_space
ensure_runtime_dependencies ensure_runtime_dependencies
ensure_tls_certificates
ensure_nginx_config_mount_source
require_cmd curl require_cmd curl
require_cmd tar require_cmd tar
require_cmd docker require_cmd docker
require_cmd python3 require_cmd python3
ensure_min_root_disk_space
archive_logs archive_logs
create_backup create_backup
@@ -559,6 +569,8 @@ main() {
fi fi
echo "$latest_tag" > "$STATE_FILE" echo "$latest_tag" > "$STATE_FILE"
cleanup_old_dist_artifacts
cleanup_docker_dangling_images
log_message "Update completed successfully to release $latest_tag" log_message "Update completed successfully to release $latest_tag"
} }