Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cb41c1277 | |||
| f0d02d6af1 | |||
| 6f5e24104b | |||
| 43406c29d1 | |||
| 7873c45cfc | |||
| 14c4192306 | |||
| 1efc7e01be | |||
| d567ba583b | |||
| 5a5af5375d | |||
| a60eb6eebf | |||
| 23b7a4cd2f | |||
| e6fd485049 | |||
| 15d9eba987 | |||
| 05d6d299da | |||
| ab9db1211c | |||
| 45b69e5ddb | |||
| 4971bc859b | |||
| b7f55b0de0 | |||
| 9c24e79bba | |||
| 79c325329c | |||
| 8f81ffb4c5 | |||
| 1d7692ea01 | |||
| 038390b8cd | |||
| f2c1dc2ba5 | |||
| 3eeae76e6c | |||
| 0228e6cb1d | |||
| 4c59cca1a4 | |||
| 8412ae76ee | |||
| ec4483c415 | |||
| 52656c715e | |||
| f6755aad42 | |||
| cbbcc09fc2 | |||
| aa8912e8b7 | |||
| 68488598af | |||
| 6e8bb8236e |
+1
-2
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 │
|
||||||
|
|||||||
@@ -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
@@ -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():
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
@@ -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']))
|
||||||
|
|
||||||
|
|||||||
+148
-62
@@ -306,6 +306,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
width: min(420px, 42vw);
|
width: min(420px, 42vw);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.function-search-form {
|
.function-search-form {
|
||||||
@@ -313,10 +314,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.function-search-input {
|
.function-search-input {
|
||||||
width: 100%;
|
flex: 1 1 auto;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
@@ -337,6 +341,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.function-search-btn {
|
.function-search-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||||
@@ -539,9 +544,48 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar-collapse {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-collapse > .d-flex {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.function-search-wrap {
|
.function-search-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
margin: 8px 0 10px;
|
margin: 8px 0 10px;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-search-form {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-search-input {
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function-search-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-text {
|
||||||
|
margin-right: 0 !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-wrap {
|
||||||
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-nav .nav-item {
|
.navbar-nav .nav-item {
|
||||||
@@ -911,12 +955,24 @@
|
|||||||
<a class="nav-link {% if current_path == url_for('home') %}nav-active{% endif %}" href="{{ url_for('home') }}">Artikel</a>
|
<a class="nav-link {% if current_path == url_for('home') %}nav-active{% endif %}" href="{{ url_for('home') }}">Artikel</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if 'username' in session %}
|
||||||
|
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_permissions.pages.get('tutorial_page', True) %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% if 'username' in session and current_permissions.pages.get('upload_admin', True) and current_permissions.actions.get('can_insert', True) %}
|
{% if 'username' in session and current_permissions.pages.get('upload_admin', True) and current_permissions.actions.get('can_insert', True) %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}">➕ Hochladen</a>
|
<a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}">➕ Hochladen</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li class="nav-item">
|
<li class="nav-item" data-nav-fixed="true">
|
||||||
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
|
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
|
||||||
<span class="theme-icon-light" style="display: none;">☀️</span>
|
<span class="theme-icon-light" style="display: none;">☀️</span>
|
||||||
<span class="theme-icon-dark" style="display: none;">🌙</span>
|
<span class="theme-icon-dark" style="display: none;">🌙</span>
|
||||||
@@ -928,15 +984,6 @@
|
|||||||
Mehr Optionen
|
Mehr Optionen
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
|
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
|
||||||
{% if 'username' in session %}
|
|
||||||
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a></li>
|
|
||||||
{% endif %}
|
|
||||||
{% if current_permissions.pages.get('tutorial_page', True) %}
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
|
|
||||||
{% endif %}
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
{% endif %}
|
|
||||||
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
|
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
|
||||||
<li><h6 class="dropdown-header">Verwaltung</h6></li>
|
<li><h6 class="dropdown-header">Verwaltung</h6></li>
|
||||||
{% if current_permissions.pages.get('manage_filters', True) %}
|
{% if current_permissions.pages.get('manage_filters', True) %}
|
||||||
@@ -1027,12 +1074,24 @@
|
|||||||
<a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}">Medien</a>
|
<a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}">Medien</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if 'username' in session %}
|
||||||
|
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_permissions.pages.get('tutorial_page', True) %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% if 'username' in session and current_permissions.actions.get('can_insert', True) and current_permissions.pages.get('library_admin', True) %}
|
{% if 'username' in session and current_permissions.actions.get('can_insert', True) and current_permissions.pages.get('library_admin', True) %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}">📖 Hochladen</a>
|
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}">📖 Hochladen</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li class="nav-item">
|
<li class="nav-item" data-nav-fixed="true">
|
||||||
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
|
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
|
||||||
<span class="theme-icon-light" style="display: none;">☀️</span>
|
<span class="theme-icon-light" style="display: none;">☀️</span>
|
||||||
<span class="theme-icon-dark" style="display: none;">🌙</span>
|
<span class="theme-icon-dark" style="display: none;">🌙</span>
|
||||||
@@ -1044,15 +1103,6 @@
|
|||||||
Mehr Optionen
|
Mehr Optionen
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
|
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
|
||||||
{% if 'username' in session %}
|
|
||||||
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a></li>
|
|
||||||
{% endif %}
|
|
||||||
{% if current_permissions.pages.get('tutorial_page', True) %}
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
|
|
||||||
{% endif %}
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
{% endif %}
|
|
||||||
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
|
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
|
||||||
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
|
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
|
||||||
{% if current_permissions.pages.get('library_loans_admin', True) %}
|
{% if current_permissions.pages.get('library_loans_admin', True) %}
|
||||||
@@ -1630,8 +1680,15 @@
|
|||||||
const libraryNavList = document.getElementById('libraryNavList');
|
const libraryNavList = document.getElementById('libraryNavList');
|
||||||
const libNavOverflowAnchor = document.getElementById('lib-nav-overflow-anchor');
|
const libNavOverflowAnchor = document.getElementById('lib-nav-overflow-anchor');
|
||||||
|
|
||||||
function initNavbarOverflow(navList, navOverflowAnchor) {
|
function initNavbarOverflow(navList, navOverflowAnchor, options) {
|
||||||
if (!navList || !navOverflowAnchor) return;
|
if (!navList) return;
|
||||||
|
|
||||||
|
const moreToggleId = options && options.moreToggleId ? options.moreToggleId : '';
|
||||||
|
const moreToggle = moreToggleId ? document.getElementById(moreToggleId) : null;
|
||||||
|
const moreControlItem = moreToggle ? moreToggle.closest('li.nav-item.dropdown') : null;
|
||||||
|
const moreMenu = moreControlItem ? moreControlItem.querySelector(':scope > ul.dropdown-menu') : null;
|
||||||
|
const moreLabelDefault = moreToggle ? moreToggle.textContent.trim() : 'Mehr Optionen';
|
||||||
|
const moreMenuOriginal = moreMenu ? moreMenu.innerHTML : '';
|
||||||
|
|
||||||
const navRoot = navList.closest('nav.navbar');
|
const navRoot = navList.closest('nav.navbar');
|
||||||
const navCollapse = navList.closest('.navbar-collapse');
|
const navCollapse = navList.closest('.navbar-collapse');
|
||||||
@@ -1659,45 +1716,35 @@
|
|||||||
return Array.from(navList.children).filter(function(li){
|
return Array.from(navList.children).filter(function(li){
|
||||||
if (!(li instanceof HTMLElement)) return false;
|
if (!(li instanceof HTMLElement)) return false;
|
||||||
if (!li.classList.contains('nav-item')) return false;
|
if (!li.classList.contains('nav-item')) return false;
|
||||||
if (li.id === navOverflowAnchor.id) return false;
|
if (navOverflowAnchor && li.id === navOverflowAnchor.id) return false;
|
||||||
if (li.dataset.overflowControl === 'true') return false;
|
if (li.dataset.overflowControl === 'true') return false;
|
||||||
if (li.dataset.navFixed === 'true') return false;
|
if (li.dataset.navFixed === 'true') return false;
|
||||||
|
if (moreControlItem && li === moreControlItem) return false;
|
||||||
return li.style.display !== 'none';
|
return li.style.display !== 'none';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearOverflowControls() {
|
function clearOverflowControls() {
|
||||||
if (!navList) return;
|
if (!moreMenu) return;
|
||||||
navList.querySelectorAll('[data-overflow-control="true"]').forEach(function(node){
|
moreMenu.innerHTML = moreMenuOriginal;
|
||||||
node.remove();
|
if (moreControlItem) {
|
||||||
});
|
moreControlItem.style.display = '';
|
||||||
|
}
|
||||||
|
if (moreToggle) {
|
||||||
|
moreToggle.textContent = moreLabelDefault;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreAllNavItems() {
|
function restoreAllNavItems() {
|
||||||
if (!navList) return;
|
if (!navList) return;
|
||||||
navList.querySelectorAll('li.nav-item').forEach(function(li){
|
navList.querySelectorAll('li.nav-item').forEach(function(li){
|
||||||
if (li.id === navOverflowAnchor.id) return;
|
if (navOverflowAnchor && li.id === navOverflowAnchor.id) return;
|
||||||
if (li.dataset.overflowControl === 'true') return;
|
if (li.dataset.overflowControl === 'true') return;
|
||||||
li.style.display = '';
|
li.style.display = '';
|
||||||
});
|
});
|
||||||
clearOverflowControls();
|
clearOverflowControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createOverflowControl() {
|
|
||||||
const li = document.createElement('li');
|
|
||||||
li.className = 'nav-item dropdown';
|
|
||||||
li.dataset.overflowControl = 'true';
|
|
||||||
|
|
||||||
const toggleId = navOverflowAnchor.id + '-toggle';
|
|
||||||
const menuId = navOverflowAnchor.id + '-menu';
|
|
||||||
|
|
||||||
li.innerHTML =
|
|
||||||
'<a class="nav-link dropdown-toggle" href="#" id="' + toggleId + '" role="button" data-bs-toggle="dropdown" aria-expanded="false" aria-controls="' + menuId + '">⋮ Weitere</a>' +
|
|
||||||
'<ul class="dropdown-menu" aria-labelledby="' + toggleId + '" id="' + menuId + '"></ul>';
|
|
||||||
|
|
||||||
return li;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNavCandidatePriority(item) {
|
function getNavCandidatePriority(item) {
|
||||||
if (!(item instanceof HTMLElement)) {
|
if (!(item instanceof HTMLElement)) {
|
||||||
return -1;
|
return -1;
|
||||||
@@ -1780,33 +1827,70 @@
|
|||||||
|
|
||||||
function rebuildOverflowControl(hiddenSources) {
|
function rebuildOverflowControl(hiddenSources) {
|
||||||
clearOverflowControls();
|
clearOverflowControls();
|
||||||
if (!navList || !navOverflowAnchor || hiddenSources.length === 0) return;
|
if (!moreMenu || hiddenSources.length === 0) return;
|
||||||
|
|
||||||
const control = createOverflowControl();
|
|
||||||
const menu = control.querySelector('ul.dropdown-menu');
|
|
||||||
const controlLink = control.querySelector(':scope > a.nav-link');
|
|
||||||
if (controlLink) {
|
|
||||||
controlLink.textContent = '⋮ Weitere (' + hiddenSources.length + ')';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const overflowFragment = document.createDocumentFragment();
|
||||||
hiddenSources.forEach(function(source){
|
hiddenSources.forEach(function(source){
|
||||||
appendSourceToOverflowMenu(source, menu);
|
appendSourceToOverflowMenu(source, overflowFragment);
|
||||||
});
|
});
|
||||||
|
|
||||||
const trailingDivider = menu.querySelector('li:last-child .dropdown-divider');
|
const overflowWrap = document.createElement('div');
|
||||||
if (trailingDivider) {
|
overflowWrap.appendChild(overflowFragment);
|
||||||
|
const trailingDivider = overflowWrap.querySelector('li:last-child .dropdown-divider');
|
||||||
|
if (trailingDivider && trailingDivider.parentElement) {
|
||||||
trailingDivider.parentElement.remove();
|
trailingDivider.parentElement.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
navList.insertBefore(control, navOverflowAnchor);
|
const overflowEntries = Array.from(overflowWrap.childNodes);
|
||||||
|
if (overflowEntries.length > 0) {
|
||||||
|
const marker = document.createElement('li');
|
||||||
|
marker.innerHTML = '<h6 class="dropdown-header">Ausgeblendete Navigation</h6>';
|
||||||
|
const firstExistingNode = moreMenu.firstChild;
|
||||||
|
moreMenu.insertBefore(marker, firstExistingNode);
|
||||||
|
|
||||||
|
let insertAfter = marker;
|
||||||
|
overflowEntries.forEach(function(node){
|
||||||
|
moreMenu.insertBefore(node, insertAfter.nextSibling);
|
||||||
|
insertAfter = node;
|
||||||
|
});
|
||||||
|
|
||||||
|
const divider = document.createElement('li');
|
||||||
|
divider.innerHTML = '<hr class="dropdown-divider">';
|
||||||
|
moreMenu.insertBefore(divider, insertAfter.nextSibling);
|
||||||
|
|
||||||
|
if (moreToggle) {
|
||||||
|
moreToggle.textContent = moreLabelDefault + ' (' + hiddenSources.length + ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moreControlItem) {
|
||||||
|
moreControlItem.style.display = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function adaptNavbarByWidth() {
|
function adaptNavbarByWidth() {
|
||||||
if (!navList || !navOverflowAnchor) return;
|
if (!navList) return;
|
||||||
|
|
||||||
|
const isMobileViewport = window.matchMedia('(max-width: 991.98px)').matches;
|
||||||
|
|
||||||
|
function isNavOverflowing(bufferPx) {
|
||||||
|
const buffer = typeof bufferPx === 'number' ? bufferPx : 0;
|
||||||
|
const collapseOverflow = navCollapse ? (navCollapse.scrollWidth > (navCollapse.clientWidth - buffer)) : false;
|
||||||
|
const containerOverflow = navContainer ? (navContainer.scrollWidth > (navContainer.clientWidth - buffer)) : false;
|
||||||
|
const listOverflow = navList.scrollWidth > (navList.clientWidth - buffer);
|
||||||
|
return collapseOverflow || containerOverflow || listOverflow;
|
||||||
|
}
|
||||||
|
|
||||||
applyCompactMode();
|
applyCompactMode();
|
||||||
|
|
||||||
if (window.innerWidth < 992) {
|
// Desktop/tablet-large: keep complete navigation visible.
|
||||||
|
if (!isMobileViewport) {
|
||||||
|
restoreAllNavItems();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile menu is collapsed: avoid hiding links preemptively.
|
||||||
|
if (navCollapse && !navCollapse.classList.contains('show')) {
|
||||||
restoreAllNavItems();
|
restoreAllNavItems();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1815,8 +1899,9 @@
|
|||||||
|
|
||||||
const hiddenSources = [];
|
const hiddenSources = [];
|
||||||
let candidates = collectTopLevelNavSources();
|
let candidates = collectTopLevelNavSources();
|
||||||
|
const overflowBuffer = 12;
|
||||||
|
|
||||||
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
|
while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
|
||||||
const toHide = pickNextNavItemToHide(candidates);
|
const toHide = pickNextNavItemToHide(candidates);
|
||||||
if (!toHide) {
|
if (!toHide) {
|
||||||
break;
|
break;
|
||||||
@@ -1828,8 +1913,9 @@
|
|||||||
|
|
||||||
rebuildOverflowControl(hiddenSources);
|
rebuildOverflowControl(hiddenSources);
|
||||||
|
|
||||||
|
// One final pass in case the overflow menu label/count itself changed row width.
|
||||||
candidates = collectTopLevelNavSources();
|
candidates = collectTopLevelNavSources();
|
||||||
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
|
while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
|
||||||
const toHide = pickNextNavItemToHide(candidates);
|
const toHide = pickNextNavItemToHide(candidates);
|
||||||
if (!toHide) {
|
if (!toHide) {
|
||||||
break;
|
break;
|
||||||
@@ -1867,12 +1953,12 @@
|
|||||||
|
|
||||||
// Initialize overflow control for inventory navbar
|
// Initialize overflow control for inventory navbar
|
||||||
if (navList && navOverflowAnchor) {
|
if (navList && navOverflowAnchor) {
|
||||||
initNavbarOverflow(navList, navOverflowAnchor);
|
initNavbarOverflow(navList, navOverflowAnchor, { moreToggleId: 'invMoreDropdown' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize overflow control for library navbar
|
// Initialize overflow control for library navbar
|
||||||
if (libraryNavList && libNavOverflowAnchor) {
|
if (libraryNavList && libNavOverflowAnchor) {
|
||||||
initNavbarOverflow(libraryNavList, libNavOverflowAnchor);
|
initNavbarOverflow(libraryNavList, libNavOverflowAnchor, { moreToggleId: 'libMoreDropdown' });
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+237
-1
@@ -208,12 +208,23 @@
|
|||||||
<script>
|
<script>
|
||||||
// View mode persistence
|
// View mode persistence
|
||||||
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
|
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
|
||||||
|
const MOBILE_LIBRARY_MAX_WIDTH = 900;
|
||||||
|
const MOBILE_LIBRARY_MIN_ITEMS = 24;
|
||||||
|
|
||||||
|
function isMobileViewport() {
|
||||||
|
return window.matchMedia(`(max-width: ${MOBILE_LIBRARY_MAX_WIDTH}px)`).matches;
|
||||||
|
}
|
||||||
|
|
||||||
function setViewMode(mode) {
|
function setViewMode(mode) {
|
||||||
localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode);
|
localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode);
|
||||||
const container = document.getElementById('itemsContainer');
|
const container = document.getElementById('itemsContainer');
|
||||||
const tableHeader = document.getElementById('tableHeader');
|
const tableHeader = document.getElementById('tableHeader');
|
||||||
const items = container.querySelectorAll('.item-card');
|
const renderedItems = Array.from(container.querySelectorAll('.item-card'));
|
||||||
|
const virtualizationState = window.mobileLibraryVirtualizationState || null;
|
||||||
|
const detachedItems = virtualizationState && virtualizationState.active && Array.isArray(virtualizationState.items)
|
||||||
|
? virtualizationState.items.filter(item => !container.contains(item))
|
||||||
|
: [];
|
||||||
|
const items = [...renderedItems, ...detachedItems.filter(item => !container.contains(item))];
|
||||||
|
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
const cardContent = item.querySelector('.item-content.card-mode');
|
const cardContent = item.querySelector('.item-content.card-mode');
|
||||||
@@ -236,6 +247,229 @@ function setViewMode(mode) {
|
|||||||
document.getElementById('viewModeToggle').classList.toggle('open', mode === 'table');
|
document.getElementById('viewModeToggle').classList.toggle('open', mode === 'table');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initMobileWindowedLibraryLoading() {
|
||||||
|
const container = document.getElementById('itemsContainer');
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.mobileLibraryVirtualizationState) {
|
||||||
|
window.mobileLibraryVirtualizationState = {
|
||||||
|
active: false,
|
||||||
|
items: [],
|
||||||
|
topSpacer: null,
|
||||||
|
bottomSpacer: null,
|
||||||
|
averageItemHeight: Math.max(Math.round(window.innerHeight * 0.35), 230),
|
||||||
|
lastStart: -1,
|
||||||
|
lastEnd: -1,
|
||||||
|
framePending: false,
|
||||||
|
scrollListener: null,
|
||||||
|
resizeListener: null,
|
||||||
|
initialized: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const state = window.mobileLibraryVirtualizationState;
|
||||||
|
|
||||||
|
function restoreAllImages() {
|
||||||
|
state.items.forEach(item => {
|
||||||
|
const image = item.querySelector('img.item-image');
|
||||||
|
if (!image) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalSrc = image.dataset.mobileSrc || '';
|
||||||
|
if (!image.getAttribute('src') && originalSrc) {
|
||||||
|
image.setAttribute('src', originalSrc);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateImageMemory(startIndex, endIndex) {
|
||||||
|
const imageBuffer = 4;
|
||||||
|
state.items.forEach((item, index) => {
|
||||||
|
const image = item.querySelector('img.item-image');
|
||||||
|
if (!image) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!image.dataset.mobileSrc) {
|
||||||
|
image.dataset.mobileSrc = image.getAttribute('src') || '';
|
||||||
|
}
|
||||||
|
image.loading = 'lazy';
|
||||||
|
image.decoding = 'async';
|
||||||
|
|
||||||
|
const shouldKeepLoaded = index >= startIndex - imageBuffer && index < endIndex + imageBuffer;
|
||||||
|
if (shouldKeepLoaded) {
|
||||||
|
if (!image.getAttribute('src') && image.dataset.mobileSrc) {
|
||||||
|
image.setAttribute('src', image.dataset.mobileSrc);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image.getAttribute('src')) {
|
||||||
|
image.removeAttribute('src');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVisibleWindow() {
|
||||||
|
if (!state.active || !state.topSpacer || !state.bottomSpacer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportHeight = window.innerHeight || 800;
|
||||||
|
const scrollTop = window.scrollY || window.pageYOffset || 0;
|
||||||
|
const renderBuffer = viewportHeight * 1.5;
|
||||||
|
|
||||||
|
let startIndex = Math.max(0, Math.floor((scrollTop - renderBuffer) / state.averageItemHeight));
|
||||||
|
let endIndex = Math.min(state.items.length, Math.ceil((scrollTop + viewportHeight + renderBuffer) / state.averageItemHeight));
|
||||||
|
|
||||||
|
if (endIndex - startIndex < 14) {
|
||||||
|
endIndex = Math.min(state.items.length, startIndex + 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startIndex === state.lastStart && endIndex === state.lastEnd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.lastStart = startIndex;
|
||||||
|
state.lastEnd = endIndex;
|
||||||
|
|
||||||
|
while (state.topSpacer.nextSibling && state.topSpacer.nextSibling !== state.bottomSpacer) {
|
||||||
|
state.topSpacer.nextSibling.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let index = startIndex; index < endIndex; index += 1) {
|
||||||
|
fragment.appendChild(state.items[index]);
|
||||||
|
}
|
||||||
|
container.insertBefore(fragment, state.bottomSpacer);
|
||||||
|
|
||||||
|
let measuredHeightSum = 0;
|
||||||
|
let measuredCount = 0;
|
||||||
|
for (let index = startIndex; index < endIndex; index += 1) {
|
||||||
|
const height = state.items[index].getBoundingClientRect().height;
|
||||||
|
if (height > 0) {
|
||||||
|
measuredHeightSum += height;
|
||||||
|
measuredCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (measuredCount > 0) {
|
||||||
|
const measuredAverage = measuredHeightSum / measuredCount;
|
||||||
|
state.averageItemHeight = Math.round((state.averageItemHeight * 3 + measuredAverage) / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.topSpacer.style.height = `${Math.max(0, startIndex * state.averageItemHeight)}px`;
|
||||||
|
state.bottomSpacer.style.height = `${Math.max(0, (state.items.length - endIndex) * state.averageItemHeight)}px`;
|
||||||
|
updateImageMemory(startIndex, endIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleWindowRender() {
|
||||||
|
if (!state.active || state.framePending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.framePending = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
state.framePending = false;
|
||||||
|
renderVisibleWindow();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateVirtualization() {
|
||||||
|
if (state.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(state.items) || state.items.length === 0) {
|
||||||
|
state.items = Array.from(container.querySelectorAll('.library-item'));
|
||||||
|
}
|
||||||
|
if (state.items.length <= MOBILE_LIBRARY_MIN_ITEMS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.topSpacer = document.createElement('div');
|
||||||
|
state.topSpacer.className = 'mobile-window-spacer top';
|
||||||
|
state.bottomSpacer = document.createElement('div');
|
||||||
|
state.bottomSpacer.className = 'mobile-window-spacer bottom';
|
||||||
|
|
||||||
|
state.items.forEach(item => item.remove());
|
||||||
|
container.appendChild(state.topSpacer);
|
||||||
|
container.appendChild(state.bottomSpacer);
|
||||||
|
|
||||||
|
state.lastStart = -1;
|
||||||
|
state.lastEnd = -1;
|
||||||
|
state.framePending = false;
|
||||||
|
state.active = true;
|
||||||
|
|
||||||
|
if (!state.scrollListener) {
|
||||||
|
state.scrollListener = () => scheduleWindowRender();
|
||||||
|
window.addEventListener('scroll', state.scrollListener, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleWindowRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deactivateVirtualization() {
|
||||||
|
if (!state.active) {
|
||||||
|
if (!Array.isArray(state.items) || state.items.length === 0) {
|
||||||
|
state.items = Array.from(container.querySelectorAll('.library-item'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.scrollListener) {
|
||||||
|
window.removeEventListener('scroll', state.scrollListener);
|
||||||
|
state.scrollListener = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.topSpacer && state.topSpacer.parentNode === container) {
|
||||||
|
state.topSpacer.remove();
|
||||||
|
}
|
||||||
|
if (state.bottomSpacer && state.bottomSpacer.parentNode === container) {
|
||||||
|
state.bottomSpacer.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
state.items.forEach(item => fragment.appendChild(item));
|
||||||
|
container.appendChild(fragment);
|
||||||
|
|
||||||
|
restoreAllImages();
|
||||||
|
|
||||||
|
state.topSpacer = null;
|
||||||
|
state.bottomSpacer = null;
|
||||||
|
state.lastStart = -1;
|
||||||
|
state.lastEnd = -1;
|
||||||
|
state.framePending = false;
|
||||||
|
state.active = false;
|
||||||
|
|
||||||
|
const currentMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
|
||||||
|
setViewMode(currentMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncVirtualizationWithViewport() {
|
||||||
|
if (!Array.isArray(state.items) || state.items.length === 0) {
|
||||||
|
state.items = Array.from(container.querySelectorAll('.library-item'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldVirtualize = isMobileViewport() && state.items.length > MOBILE_LIBRARY_MIN_ITEMS;
|
||||||
|
if (shouldVirtualize) {
|
||||||
|
activateVirtualization();
|
||||||
|
scheduleWindowRender();
|
||||||
|
} else {
|
||||||
|
deactivateVirtualization();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.initialized) {
|
||||||
|
state.resizeListener = () => syncVirtualizationWithViewport();
|
||||||
|
window.addEventListener('resize', state.resizeListener, { passive: true });
|
||||||
|
state.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncVirtualizationWithViewport();
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize view mode
|
// Initialize view mode
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const savedMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
|
const savedMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
|
||||||
@@ -311,6 +545,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
if (e.target === this) this.style.display = 'none';
|
if (e.target === this) this.style.display = 'none';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
initMobileWindowedLibraryLoading();
|
||||||
});
|
});
|
||||||
|
|
||||||
function displayLibraryItemDetail(item) {
|
function displayLibraryItemDetail(item) {
|
||||||
|
|||||||
+93
-4
@@ -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):
|
||||||
|
|||||||
@@ -33,11 +33,12 @@ LOG_FILE="${LOG_FILE:-$LOG_DIR/backup.log}"
|
|||||||
COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}"
|
COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}"
|
||||||
KEEP_DAYS="${KEEP_DAYS:-7}"
|
KEEP_DAYS="${KEEP_DAYS:-7}"
|
||||||
MIN_KEEP="${MIN_KEEP:-7}"
|
MIN_KEEP="${MIN_KEEP:-7}"
|
||||||
DB_NAME="${DB_NAME:-Inventarsystem}"
|
DB_NAME="${DB_NAME:-inventar_default}"
|
||||||
MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}"
|
MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}"
|
||||||
INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
|
INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
|
||||||
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}"
|
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}"
|
||||||
BACKUP_MODE="${BACKUP_MODE:-auto}"
|
BACKUP_MODE="${BACKUP_MODE:-auto}"
|
||||||
|
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose-multitenant.yml}"
|
||||||
DOCKER_AVAILABLE=0
|
DOCKER_AVAILABLE=0
|
||||||
DOCKER_COMPOSE_CMD=()
|
DOCKER_COMPOSE_CMD=()
|
||||||
USE_NULL_OUTPUT=false
|
USE_NULL_OUTPUT=false
|
||||||
@@ -58,6 +59,8 @@ Options:
|
|||||||
--keep-days <N> Age-based retention in days (default: $KEEP_DAYS; 0 disables age filter)
|
--keep-days <N> Age-based retention in days (default: $KEEP_DAYS; 0 disables age filter)
|
||||||
--min-keep <N> Always keep at least this many backups (default: $MIN_KEEP)
|
--min-keep <N> Always keep at least this many backups (default: $MIN_KEEP)
|
||||||
--mode <auto|host|docker> Backup mode (default: $BACKUP_MODE)
|
--mode <auto|host|docker> Backup mode (default: $BACKUP_MODE)
|
||||||
|
--multitenant Use docker-compose-multitenant.yml (default)
|
||||||
|
--singletenant Use docker-compose.yml
|
||||||
-h|--help Show this help and exit
|
-h|--help Show this help and exit
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -87,6 +90,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
MIN_KEEP="$2"; shift 2;;
|
MIN_KEEP="$2"; shift 2;;
|
||||||
--mode)
|
--mode)
|
||||||
BACKUP_MODE="$2"; shift 2;;
|
BACKUP_MODE="$2"; shift 2;;
|
||||||
|
--multitenant)
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"; shift;;
|
||||||
|
--singletenant)
|
||||||
|
COMPOSE_FILE="docker-compose.yml"; shift;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage; exit 0;;
|
usage; exit 0;;
|
||||||
*)
|
*)
|
||||||
@@ -102,12 +109,12 @@ log_message() {
|
|||||||
init_docker_compose() {
|
init_docker_compose() {
|
||||||
if docker compose version >/dev/null 2>&1; then
|
if docker compose version >/dev/null 2>&1; then
|
||||||
DOCKER_AVAILABLE=1
|
DOCKER_AVAILABLE=1
|
||||||
DOCKER_COMPOSE_CMD=(docker compose -f "$PROJECT_DIR/docker-compose.yml")
|
DOCKER_COMPOSE_CMD=(docker compose -f "$PROJECT_DIR/$COMPOSE_FILE")
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if sudo docker compose version >/dev/null 2>&1; then
|
if sudo docker compose version >/dev/null 2>&1; then
|
||||||
DOCKER_AVAILABLE=1
|
DOCKER_AVAILABLE=1
|
||||||
DOCKER_COMPOSE_CMD=(sudo docker compose -f "$PROJECT_DIR/docker-compose.yml")
|
DOCKER_COMPOSE_CMD=(sudo docker compose -f "$PROJECT_DIR/$COMPOSE_FILE")
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -92,6 +64,8 @@ services:
|
|||||||
args:
|
args:
|
||||||
PYTHON_VERSION: "3.13"
|
PYTHON_VERSION: "3.13"
|
||||||
OPTIMIZATION_LEVEL: 2
|
OPTIMIZATION_LEVEL: 2
|
||||||
|
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", "-"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
@@ -100,13 +74,14 @@ 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:
|
||||||
- "8000"
|
- "8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.json:/app/config.json:ro
|
- ./config.json:/app/config.json:ro
|
||||||
- ./Web:/app/Web:ro
|
|
||||||
- app_uploads:/app/Web/uploads:cached
|
- app_uploads:/app/Web/uploads:cached
|
||||||
- app_thumbnails:/app/Web/thumbnails:cached
|
- app_thumbnails:/app/Web/thumbnails:cached
|
||||||
- app_previews:/app/Web/previews:cached
|
- app_previews:/app/Web/previews:cached
|
||||||
|
|||||||
+26
-72
@@ -1,87 +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
|
|
||||||
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
|
||||||
- ./Web:/app/Web:ro
|
- ./credentials.json:/etc/cloudflared/credentials.json
|
||||||
- app_uploads:/app/Web/uploads
|
depends_on:
|
||||||
- app_thumbnails:/app/Web/thumbnails
|
- app
|
||||||
- app_previews:/app/Web/previews
|
|
||||||
- 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:
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
tunnel: homeserver
|
||||||
|
credentials-file: /etc/cloudflared/credentials.json
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
- service: https://nginx:443
|
||||||
|
originRequest:
|
||||||
|
noTLSVerify: true
|
||||||
|
- service: http_status:404
|
||||||
+29
-10
@@ -22,6 +22,7 @@ LEGACY_BACKUP_ARCHIVE=""
|
|||||||
CLEANUP_OLD_SERVICES=true
|
CLEANUP_OLD_SERVICES=true
|
||||||
CLEANUP_OLD_REMOVE_CRON=false
|
CLEANUP_OLD_REMOVE_CRON=false
|
||||||
TMP_DIR=""
|
TMP_DIR=""
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
|
||||||
cleanup_tmp_dir() {
|
cleanup_tmp_dir() {
|
||||||
if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then
|
if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then
|
||||||
@@ -50,14 +51,13 @@ refresh_start_script_from_main() {
|
|||||||
pin_compose_app_image() {
|
pin_compose_app_image() {
|
||||||
local tag="$1"
|
local tag="$1"
|
||||||
local compose_file
|
local compose_file
|
||||||
compose_file="$PROJECT_DIR/docker-compose.yml"
|
|
||||||
|
|
||||||
if [ ! -f "$compose_file" ]; then
|
for compose_file in "$PROJECT_DIR/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose.yml"; do
|
||||||
echo "Warning: $compose_file not found; cannot pin app image"
|
if [ ! -f "$compose_file" ]; then
|
||||||
return 0
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
python3 - <<'PY' "$compose_file" "$tag"
|
python3 - <<'PY' "$compose_file" "$tag"
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -151,6 +151,8 @@ if in_app_service and build_found and app_service_indent is not None:
|
|||||||
with open(compose_file, "w", encoding="utf-8") as f:
|
with open(compose_file, "w", encoding="utf-8") as f:
|
||||||
f.writelines(out)
|
f.writelines(out)
|
||||||
PY
|
PY
|
||||||
|
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
install_docker_if_missing() {
|
install_docker_if_missing() {
|
||||||
@@ -173,6 +175,8 @@ Options:
|
|||||||
--remove-legacy-system Remove old host MongoDB/system after successful import
|
--remove-legacy-system Remove old host MongoDB/system after successful import
|
||||||
--skip-cleanup-old Do not run cleanup-old.sh after install
|
--skip-cleanup-old Do not run cleanup-old.sh after install
|
||||||
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
|
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
|
||||||
|
--multitenant Use docker-compose-multitenant.yml (default)
|
||||||
|
--singletenant Use docker-compose.yml
|
||||||
--legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME)
|
--legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME)
|
||||||
--legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
|
--legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
|
||||||
--legacy-system-dir <path> Optional old system directory to remove after migration
|
--legacy-system-dir <path> Optional old system directory to remove after migration
|
||||||
@@ -199,6 +203,14 @@ parse_args() {
|
|||||||
CLEANUP_OLD_REMOVE_CRON=true
|
CLEANUP_OLD_REMOVE_CRON=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--multitenant)
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--singletenant)
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--legacy-db-name)
|
--legacy-db-name)
|
||||||
LEGACY_DB_NAME="$2"
|
LEGACY_DB_NAME="$2"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -280,14 +292,21 @@ backup_legacy_database() {
|
|||||||
|
|
||||||
restore_legacy_backup_into_docker() {
|
restore_legacy_backup_into_docker() {
|
||||||
local i
|
local i
|
||||||
|
local compose_path
|
||||||
|
|
||||||
if [ "$MIGRATE_LEGACY_DB" != "true" ] || [ -z "$LEGACY_BACKUP_ARCHIVE" ]; then
|
if [ "$MIGRATE_LEGACY_DB" != "true" ] || [ -z "$LEGACY_BACKUP_ARCHIVE" ]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
compose_path="$PROJECT_DIR/$COMPOSE_FILE"
|
||||||
|
if [ ! -f "$compose_path" ]; then
|
||||||
|
echo "Error: compose file not found: $compose_path"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Waiting for Docker MongoDB to become ready..."
|
echo "Waiting for Docker MongoDB to become ready..."
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
if sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T mongodb mongosh --quiet --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1; then
|
if sudo docker compose -f "$compose_path" exec -T mongodb mongosh --quiet --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
@@ -299,7 +318,7 @@ restore_legacy_backup_into_docker() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Importing legacy backup into Docker MongoDB..."
|
echo "Importing legacy backup into Docker MongoDB..."
|
||||||
sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T mongodb mongorestore --archive --gzip --drop --nsInclude "${LEGACY_DB_NAME}.*" < "$LEGACY_BACKUP_ARCHIVE"
|
sudo docker compose -f "$compose_path" exec -T mongodb mongorestore --archive --gzip --drop --nsInclude "${LEGACY_DB_NAME}.*" < "$LEGACY_BACKUP_ARCHIVE"
|
||||||
echo "Legacy DB import completed."
|
echo "Legacy DB import completed."
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,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"
|
||||||
|
|||||||
+63
-12
@@ -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,64 @@ 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 " -h, --help Show this help message"
|
||||||
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"
|
||||||
|
echo " ./manage-tenant.sh -h"
|
||||||
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
|
||||||
@@ -32,17 +75,25 @@ COMMAND=$1
|
|||||||
TENANT_ID=$2
|
TENANT_ID=$2
|
||||||
|
|
||||||
case "$COMMAND" in
|
case "$COMMAND" in
|
||||||
|
-h|--help)
|
||||||
|
show_help
|
||||||
|
;;
|
||||||
add)
|
add)
|
||||||
if [ -z "$TENANT_ID" ]; then
|
if [ -z "$TENANT_ID" ]; then
|
||||||
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
@@ -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
|
||||||
|
|||||||
+51
-5
@@ -1,8 +1,54 @@
|
|||||||
#!/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" "$@"
|
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
|
||||||
"$SCRIPT_DIR/start.sh"
|
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--multitenant)
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--singletenant)
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "Error: docker command not found. Install Docker first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Rebuilding and/or restarting app container using $COMPOSE_FILE..."
|
||||||
|
compose_args=(-f "$COMPOSE_FILE")
|
||||||
|
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
|
||||||
|
compose_args+=( -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" )
|
||||||
|
fi
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
compose_args+=( --env-file "$ENV_FILE" )
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$SCRIPT_DIR/Dockerfile" ]; then
|
||||||
|
docker compose "${compose_args[@]}" up -d --build app
|
||||||
|
else
|
||||||
|
echo "Warning: Dockerfile not found in $SCRIPT_DIR. Skipping build and restarting existing app container."
|
||||||
|
if ! docker compose "${compose_args[@]}" up -d --no-build app; then
|
||||||
|
echo "Warning: app image not found or not available locally. Attempting docker compose pull app..."
|
||||||
|
docker compose "${compose_args[@]}" pull app
|
||||||
|
docker compose "${compose_args[@]}" up -d --no-build app
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Cleaning up unused Docker images..."
|
||||||
|
docker image prune -f
|
||||||
|
|
||||||
|
echo "App restart complete."
|
||||||
|
|||||||
+14
-3
@@ -6,12 +6,13 @@ LOG_DIR="$SCRIPT_DIR/logs"
|
|||||||
LOG_FILE="$LOG_DIR/restore.log"
|
LOG_FILE="$LOG_DIR/restore.log"
|
||||||
WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)"
|
WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)"
|
||||||
|
|
||||||
DB_NAME="${INVENTAR_MONGODB_DB:-Inventarsystem}"
|
DB_NAME="${INVENTAR_MONGODB_DB:-inventar_default}"
|
||||||
SOURCE_PATH=""
|
SOURCE_PATH=""
|
||||||
BACKUP_DATE=""
|
BACKUP_DATE=""
|
||||||
DROP_DATABASE=false
|
DROP_DATABASE=false
|
||||||
RESTART_SERVICES=false
|
RESTART_SERVICES=false
|
||||||
STAGED_PATH=""
|
STAGED_PATH=""
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
|
||||||
BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups"
|
BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups"
|
||||||
BACKUP_ROOT_SYSTEM="/var/backups"
|
BACKUP_ROOT_SYSTEM="/var/backups"
|
||||||
@@ -56,6 +57,8 @@ Options:
|
|||||||
- latest: newest from local/system backup roots
|
- latest: newest from local/system backup roots
|
||||||
--drop-database Drop target DB before import (recommended for full restore)
|
--drop-database Drop target DB before import (recommended for full restore)
|
||||||
--restart-services Restart stack after restore
|
--restart-services Restart stack after restore
|
||||||
|
--multitenant Use docker-compose-multitenant.yml (default)
|
||||||
|
--singletenant Use docker-compose.yml
|
||||||
--list List detected backup candidates
|
--list List detected backup candidates
|
||||||
--help Show this help
|
--help Show this help
|
||||||
|
|
||||||
@@ -68,12 +71,12 @@ EOF
|
|||||||
|
|
||||||
setup_compose() {
|
setup_compose() {
|
||||||
if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
||||||
DOCKER_COMPOSE=(docker compose)
|
DOCKER_COMPOSE=(docker compose -f "$SCRIPT_DIR/$COMPOSE_FILE")
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$SUDO" ] && $SUDO docker compose version >/dev/null 2>&1 && $SUDO docker info >/dev/null 2>&1; then
|
if [ -n "$SUDO" ] && $SUDO docker compose version >/dev/null 2>&1 && $SUDO docker info >/dev/null 2>&1; then
|
||||||
DOCKER_COMPOSE=($SUDO docker compose)
|
DOCKER_COMPOSE=($SUDO docker compose -f "$SCRIPT_DIR/$COMPOSE_FILE")
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -459,6 +462,14 @@ parse_args() {
|
|||||||
RESTART_SERVICES=true
|
RESTART_SERVICES=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--multitenant)
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--singletenant)
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--list)
|
--list)
|
||||||
list_backups
|
list_backups
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -7,18 +7,27 @@ cd "$SCRIPT_DIR"
|
|||||||
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
|
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
|
||||||
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
|
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
|
||||||
DIST_DIR="$SCRIPT_DIR/dist"
|
DIST_DIR="$SCRIPT_DIR/dist"
|
||||||
|
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
|
||||||
|
|
||||||
SUDO=""
|
SUDO=""
|
||||||
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
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.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
|
||||||
@@ -28,6 +37,7 @@ Options:
|
|||||||
--no-cron Do not create or update cron jobs
|
--no-cron Do not create or update cron jobs
|
||||||
--with-cron Create/update cron jobs (default)
|
--with-cron Create/update cron jobs (default)
|
||||||
--multitenant Use the multi-tenant architecture deployment
|
--multitenant Use the multi-tenant architecture deployment
|
||||||
|
--singletenant Use the legacy single-tenant compose deployment
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -47,6 +57,10 @@ parse_args() {
|
|||||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--singletenant)
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage
|
usage
|
||||||
exit 0
|
exit 0
|
||||||
@@ -106,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
|
||||||
@@ -130,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
|
||||||
}
|
}
|
||||||
@@ -150,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
|
||||||
@@ -191,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
|
||||||
@@ -201,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"
|
||||||
@@ -312,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,
|
||||||
@@ -451,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,14 +489,44 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
write_runtime_compose_override() {
|
||||||
|
cat > "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
|
||||||
|
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: ${APP_IMAGE_VALUE}
|
||||||
|
build: null
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ -n "$HTTP_PORTS_VALUE" ]; then
|
||||||
|
local ports_array
|
||||||
|
read -r -a ports_array <<<"$HTTP_PORTS_VALUE"
|
||||||
|
if [ "${#ports_array[@]}" -gt 1 ]; then
|
||||||
|
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
|
||||||
|
ports:
|
||||||
|
EOF
|
||||||
|
for port in "${ports_array[@]}"; do
|
||||||
|
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
|
||||||
|
- "$port:8000"
|
||||||
|
EOF
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
verify_stack_health() {
|
verify_stack_health() {
|
||||||
local compose_args running_services retry_count=0
|
local compose_args running_services retry_count=0
|
||||||
compose_args=(-f "$COMPOSE_FILE" --env-file "$ENV_FILE")
|
compose_args=(-f "$COMPOSE_FILE")
|
||||||
|
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
|
||||||
|
compose_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
|
||||||
|
fi
|
||||||
|
compose_args+=(--env-file "$ENV_FILE")
|
||||||
|
|
||||||
# Try health check with optional restart on first failure
|
# Try health check with optional restart on first failure
|
||||||
while [[ $retry_count -lt 2 ]]; do
|
while [[ $retry_count -lt 2 ]]; do
|
||||||
@@ -564,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
|
||||||
@@ -580,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
|
||||||
@@ -592,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,20 +570,32 @@ 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
|
||||||
|
|
||||||
echo "Starting Inventarsystem Docker stack (app + mongodb)..."
|
echo "Starting Inventarsystem Docker stack (app + mongodb)..."
|
||||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --remove-orphans
|
compose_up_args=(-f "$COMPOSE_FILE")
|
||||||
|
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
|
||||||
|
compose_up_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
|
||||||
|
fi
|
||||||
|
compose_up_args+=(--env-file "$ENV_FILE")
|
||||||
|
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"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ set -euo pipefail
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
COMPOSE_FILE="docker-compose.yml"
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -12,6 +12,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--singletenant)
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
|
|||||||
ENV_FILE="$PROJECT_DIR/.docker-build.env"
|
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"
|
||||||
|
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
|
||||||
@@ -35,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 "$@"
|
||||||
@@ -44,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
|
||||||
@@ -64,88 +72,113 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $0 [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--multitenant Use docker-compose-multitenant.yml (default)
|
||||||
|
--singletenant Use docker-compose.yml
|
||||||
|
-h, --help Show this help message
|
||||||
EOF
|
EOF
|
||||||
log_message "Recreated missing nginx config at $config_path"
|
}
|
||||||
fi
|
|
||||||
|
parse_args() {
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--multitenant)
|
||||||
|
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--singletenant)
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_message "ERROR: Unknown option: $1"
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
archive_logs() {
|
archive_logs() {
|
||||||
@@ -311,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"
|
||||||
|
|
||||||
@@ -329,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
|
||||||
@@ -360,13 +388,19 @@ deploy() {
|
|||||||
local tag="$1"
|
local tag="$1"
|
||||||
local meta_file="$2"
|
local meta_file="$2"
|
||||||
local app_image="${APP_IMAGE_REPO}:${tag}"
|
local app_image="${APP_IMAGE_REPO}:${tag}"
|
||||||
|
local compose_path
|
||||||
|
|
||||||
|
compose_path="$PROJECT_DIR/$COMPOSE_FILE"
|
||||||
|
if [ ! -f "$compose_path" ]; then
|
||||||
|
log_message "ERROR: compose file not found: $compose_path"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
cd "$PROJECT_DIR"
|
cd "$PROJECT_DIR"
|
||||||
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
|
||||||
@@ -385,27 +419,27 @@ EOF
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker compose --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 --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=(--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
|
||||||
@@ -413,20 +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 "$@"
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
@@ -514,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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user