Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0edb87c347 | |||
| 678cc61cef | |||
| f26ab5c5fb | |||
| abb09fbdb5 | |||
| 96f5b9e3cd | |||
| 3ab2b075da | |||
| 821d4d0ad8 | |||
| 6cc8dabf72 | |||
| 0d7fa2c511 | |||
| 4a18460178 | |||
| d2b3d4f6ea | |||
| f3d327f28f | |||
| 2b05d8c952 | |||
| 6cb41c1277 | |||
| f0d02d6af1 | |||
| 6f5e24104b | |||
| 43406c29d1 | |||
| 7873c45cfc | |||
| 14c4192306 | |||
| 1efc7e01be | |||
| d567ba583b | |||
| 5a5af5375d | |||
| a60eb6eebf | |||
| 23b7a4cd2f | |||
| e6fd485049 | |||
| 15d9eba987 | |||
| 05d6d299da | |||
| ab9db1211c | |||
| 45b69e5ddb | |||
| 4971bc859b | |||
| b7f55b0de0 | |||
| 9c24e79bba | |||
| 79c325329c | |||
| 8f81ffb4c5 | |||
| 1d7692ea01 | |||
| 038390b8cd |
+1
-2
@@ -1,4 +1,3 @@
|
||||
NUITKA_BUILD=0
|
||||
INVENTAR_HTTP_PORT=80
|
||||
INVENTAR_HTTPS_PORT=443
|
||||
INVENTAR_HTTP_PORT=10000
|
||||
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
|
||||
run: |
|
||||
mkdir -p release-bundle
|
||||
mkdir -p release-bundle/docker/nginx
|
||||
cat > release-bundle/docker-compose.yml <<EOF
|
||||
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:
|
||||
image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:latest}
|
||||
pull_policy: never
|
||||
image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}}
|
||||
container_name: inventarsystem-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${INVENTAR_HTTP_PORT:-10000}:8000"
|
||||
depends_on:
|
||||
mongodb:
|
||||
condition: service_healthy
|
||||
- mongodb
|
||||
- redis
|
||||
environment:
|
||||
INVENTAR_MONGODB_HOST: mongodb
|
||||
INVENTAR_MONGODB_PORT: "27017"
|
||||
@@ -211,6 +186,33 @@ jobs:
|
||||
- app_backups:/data/backups
|
||||
- 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:
|
||||
mongodb_data:
|
||||
app_uploads:
|
||||
@@ -219,9 +221,9 @@ jobs:
|
||||
app_qrcodes:
|
||||
app_backups:
|
||||
app_logs:
|
||||
redis_data:
|
||||
EOF
|
||||
|
||||
cp docker/nginx/default.conf release-bundle/docker/nginx/default.conf
|
||||
cp start.sh release-bundle/start.sh
|
||||
cp stop.sh release-bundle/stop.sh
|
||||
cp restart.sh release-bundle/restart.sh
|
||||
@@ -232,7 +234,6 @@ jobs:
|
||||
|
||||
# Multitenant scripts & docs
|
||||
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 run-tenant-cmd.sh release-bundle/run-tenant-cmd.sh
|
||||
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 │
|
||||
│ Tenant: t1 │ │ Tenant: t2 │ │ Tenant: t3 │
|
||||
│ 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
|
||||
|
||||
**Fragen?**
|
||||
|
||||
+18
-6
@@ -1313,9 +1313,17 @@ def _initialize_scheduler():
|
||||
if lock_age > 300: # 5 minutes - indicates a stale lock from a previous container run
|
||||
os.remove(scheduler_lock_path)
|
||||
app.logger.info(f"Removed stale scheduler lock file (age: {lock_age:.0f}s)")
|
||||
except Exception:
|
||||
pass # If we can't clean up, continue anyway
|
||||
|
||||
except Exception as e:
|
||||
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 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)
|
||||
@@ -1324,7 +1332,7 @@ def _initialize_scheduler():
|
||||
except FileExistsError:
|
||||
should_start = False
|
||||
app.logger.warning("Scheduler lock exists - another process is already running the scheduler")
|
||||
|
||||
|
||||
if should_start:
|
||||
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)
|
||||
@@ -7108,8 +7116,8 @@ def register():
|
||||
name = (request.form.get('name') or '').strip()
|
||||
last_name = (request.form.get('last-name') or '').strip()
|
||||
|
||||
# Always generate username from abbreviation logic and auto-extend on collisions.
|
||||
username = us.build_unique_username_from_name(name, last_name)
|
||||
# Generate a truly anonymized username for new users.
|
||||
username = us.build_anonymous_username()
|
||||
|
||||
permission_preset = (request.form.get('permission_preset') or 'standard_user').strip()
|
||||
use_custom_permissions = request.form.get('use_custom_permissions') == 'on'
|
||||
@@ -10806,6 +10814,10 @@ def get_optimal_image_quality(img, target_size_kb=80):
|
||||
# PUSH NOTIFICATION API ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@app.route('/health')
|
||||
def health_check():
|
||||
return 'OK', 200
|
||||
|
||||
@app.route('/api/push/subscribe', methods=['POST'])
|
||||
def subscribe_to_push():
|
||||
"""
|
||||
|
||||
@@ -13,6 +13,6 @@ redis
|
||||
reportlab
|
||||
python-barcode
|
||||
openpyxl
|
||||
cryptography
|
||||
cryptography>=42.0.0
|
||||
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'])
|
||||
|
||||
# Optional feature modules
|
||||
LIBRARY_MODULE_ENABLED = bool(_get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
|
||||
STUDENT_CARDS_MODULE_ENABLED = bool(_get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
|
||||
TENANT_CONFIGS = _get(_conf, ['tenants'], {})
|
||||
|
||||
|
||||
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_MAX_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'max_borrow_days'], DEFAULTS['modules']['student_cards']['max_borrow_days']))
|
||||
|
||||
|
||||
+106
-8
@@ -206,6 +206,82 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
body.tutorial-tooltips-active [data-tutorial-tip] {
|
||||
position: relative;
|
||||
border-radius: 10px;
|
||||
outline: 1px dashed rgba(59, 130, 246, 0.35);
|
||||
outline-offset: 2px;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
body.tutorial-tooltips-active [data-tutorial-tip]::before {
|
||||
content: '💡';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -26px;
|
||||
transform: translateY(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #0f172a;
|
||||
font-size: 0.75rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body.tutorial-tooltips-active [data-tutorial-tip]::after {
|
||||
content: attr(data-tutorial-tip);
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(6px);
|
||||
min-width: 220px;
|
||||
max-width: 280px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
color: #f8fafc;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.22);
|
||||
z-index: 9999;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
body.tutorial-tooltips-active [data-tutorial-tip]:hover::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(-50%) translateY(10px);
|
||||
}
|
||||
|
||||
.tutorial-tooltip-indicator {
|
||||
position: fixed;
|
||||
top: 92px;
|
||||
right: 16px;
|
||||
z-index: 2000;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
border: 1px solid rgba(56, 189, 248, 0.25);
|
||||
color: #0f172a;
|
||||
font-size: 0.95rem;
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
body.tutorial-tooltips-active .tutorial-tooltip-indicator {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* iPad/Tablet responsive adjustments */
|
||||
@media (max-width: 1024px) {
|
||||
.navbar .container-fluid {
|
||||
@@ -952,24 +1028,24 @@
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="inventoryNavList">
|
||||
{% if current_permissions.pages.get('home', True) %}
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<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') }}" data-tutorial-tip="Starten Sie hier mit dem Materialbestand und suchen Sie nach Artikeln.">Artikel</a>
|
||||
</li>
|
||||
{% 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>
|
||||
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}" data-tutorial-tip="Ihre aktuellen Ausleihen und Rückgaben finden Sie hier.">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>
|
||||
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}" data-tutorial-tip="Öffnen Sie das Tutorial, um das System Schritt für Schritt kennenzulernen.">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) %}
|
||||
<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') }}" data-tutorial-tip="Hier können Sie neue Artikel ins Inventar einpflegen.">➕ Hochladen</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
@@ -1071,24 +1147,24 @@
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="libraryNavList">
|
||||
{% if current_permissions.pages.get('library_view', True) %}
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<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') }}" data-tutorial-tip="Die Bibliothek zeigt Ihnen alle Medien und verfügbaren Bücher.">Medien</a>
|
||||
</li>
|
||||
{% 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>
|
||||
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}" data-tutorial-tip="Hier sehen Sie Ihre bibliotheksbezogenen Ausleihen.">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>
|
||||
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}" data-tutorial-tip="Nutzen Sie das Tutorial, um die Bibliotheksfunktionen kennenzulernen.">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) %}
|
||||
<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') }}" data-tutorial-tip="Neue Bibliotheksmedien können hier aufgenommen werden.">📖 Hochladen</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
@@ -1999,6 +2075,28 @@
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
const username = {{ (session['username'] if 'username' in session else '')|tojson }};
|
||||
const tooltipModeKey = username ? ('inventarsystem_tutorial_tooltips_enabled_' + username) : 'inventarsystem_tutorial_tooltips_enabled';
|
||||
const enabled = localStorage.getItem(tooltipModeKey) === '1';
|
||||
|
||||
function applyTooltipMode(active) {
|
||||
document.body.classList.toggle('tutorial-tooltips-active', active);
|
||||
let indicator = document.getElementById('tutorialTooltipIndicator');
|
||||
if (!indicator) {
|
||||
indicator = document.createElement('div');
|
||||
indicator.id = 'tutorialTooltipIndicator';
|
||||
indicator.className = 'tutorial-tooltip-indicator';
|
||||
indicator.textContent = 'Tutorial-Modus aktiv: Tooltips sind eingeschaltet';
|
||||
document.body.appendChild(indicator);
|
||||
}
|
||||
indicator.style.display = active ? 'inline-flex' : 'none';
|
||||
}
|
||||
|
||||
applyTooltipMode(enabled);
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -189,6 +189,38 @@
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-toggle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(224, 242, 254, 0.9);
|
||||
border: 1px solid #bae6fd;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-toggle label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.tutorial-tooltip-toggle input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
accent-color: #2563eb;
|
||||
}
|
||||
|
||||
.tooltip-description {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.workflow-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -230,6 +262,13 @@
|
||||
<span class="tutorial-pill">Format: Ruhig und geführt</span>
|
||||
<span class="tutorial-pill">Benutzer: {{ username }}</span>
|
||||
</div>
|
||||
<div class="tutorial-tooltip-toggle">
|
||||
<label for="toggleTutorialTooltips">
|
||||
<input type="checkbox" id="toggleTutorialTooltips" />
|
||||
Tutorial-Tooltips für das gesamte System aktivieren
|
||||
</label>
|
||||
<p class="tooltip-description">Wenn aktiviert, erscheinen auf allen Seiten erklärende Hinweise und Icons für die wichtigsten Navigationselemente.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="tutorial-grid">
|
||||
@@ -438,8 +477,32 @@
|
||||
renderStep();
|
||||
});
|
||||
|
||||
const tooltipToggle = document.getElementById('toggleTutorialTooltips');
|
||||
const tooltipModeKey = 'inventarsystem_tutorial_tooltips_enabled_{{ username }}';
|
||||
|
||||
function setTooltipMode(enabled) {
|
||||
localStorage.setItem(tooltipModeKey, enabled ? '1' : '0');
|
||||
document.body.classList.toggle('tutorial-tooltips-active', enabled);
|
||||
}
|
||||
|
||||
function loadTooltipMode() {
|
||||
const saved = localStorage.getItem(tooltipModeKey);
|
||||
const enabled = saved === '1';
|
||||
if (tooltipToggle) {
|
||||
tooltipToggle.checked = enabled;
|
||||
}
|
||||
setTooltipMode(enabled);
|
||||
}
|
||||
|
||||
if (tooltipToggle) {
|
||||
tooltipToggle.addEventListener('change', function () {
|
||||
setTooltipMode(this.checked);
|
||||
});
|
||||
}
|
||||
|
||||
loadState();
|
||||
renderStep();
|
||||
loadTooltipMode();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+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 functools import wraps
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import settings as cfg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Tenant registry: maps subdomain/tenant_id to database name
|
||||
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:
|
||||
"""
|
||||
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):
|
||||
self.tenant_id = None
|
||||
self.db_name = None
|
||||
self.subdomain = None
|
||||
self.port = None
|
||||
self.config = {}
|
||||
|
||||
def resolve_tenant(self):
|
||||
"""
|
||||
Resolve tenant from request context.
|
||||
Priority: Header > Subdomain > Default
|
||||
Priority: Header > Port mapping > Subdomain > Default
|
||||
"""
|
||||
if not has_request_context():
|
||||
return None
|
||||
@@ -40,10 +116,21 @@ class TenantContext:
|
||||
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
|
||||
if tenant_from_header:
|
||||
self.tenant_id = tenant_from_header
|
||||
self.config = get_tenant_config(tenant_from_header)
|
||||
return self._get_db_name(tenant_from_header)
|
||||
|
||||
# Priority 2: Subdomain extraction
|
||||
# Priority 2: Port-based tenant mapping
|
||||
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('.')
|
||||
|
||||
# Extract subdomain from host
|
||||
@@ -56,10 +143,12 @@ class TenantContext:
|
||||
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
|
||||
self.subdomain = potential_subdomain
|
||||
self.tenant_id = potential_subdomain
|
||||
self.config = get_tenant_config(potential_subdomain)
|
||||
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.config = get_tenant_config('default')
|
||||
return self._get_db_name('default')
|
||||
|
||||
def _get_db_name(self, tenant_id):
|
||||
|
||||
+92
-12
@@ -13,6 +13,8 @@ Provides methods for creating, validating, and retrieving user information.
|
||||
import hashlib
|
||||
import copy
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
from bson.objectid import ObjectId
|
||||
import settings as cfg
|
||||
from settings import MongoClient
|
||||
@@ -57,6 +59,12 @@ def build_name_synonym(first_name, last_name=''):
|
||||
return combined[:6].title()
|
||||
|
||||
|
||||
def _generate_anonymous_username(prefix='user', length=8):
|
||||
"""Generate a random anonymized username using lowercase letters and digits."""
|
||||
alphabet = string.ascii_lowercase + string.digits
|
||||
return prefix + ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
def build_username_from_name(first_name, last_name=''):
|
||||
"""
|
||||
Build a deterministic username abbreviation from first and last name.
|
||||
@@ -73,6 +81,22 @@ def build_username_from_name(first_name, last_name=''):
|
||||
return alias.lower()
|
||||
|
||||
|
||||
def build_anonymous_username():
|
||||
"""
|
||||
Build a truly anonymized username that is not derived from the user's
|
||||
first name or last name.
|
||||
"""
|
||||
for _ in range(10):
|
||||
candidate = _generate_anonymous_username()
|
||||
if not get_user(candidate):
|
||||
return candidate
|
||||
|
||||
suffix = 2
|
||||
while get_user(f"user{suffix}"):
|
||||
suffix += 1
|
||||
return f"user{suffix}"
|
||||
|
||||
|
||||
def build_unique_username_from_name(first_name, last_name=''):
|
||||
"""
|
||||
Build a unique username based on the abbreviation logic.
|
||||
@@ -417,13 +441,41 @@ def check_nm_pwd(username, password):
|
||||
Returns:
|
||||
dict: User document if credentials are valid, None otherwise
|
||||
"""
|
||||
db_name = cfg.MONGODB_DB
|
||||
tenant_db = None
|
||||
try:
|
||||
from tenant import get_tenant_context
|
||||
ctx = get_tenant_context()
|
||||
if ctx and ctx.tenant_id:
|
||||
tenant_db = ctx.db_name or ctx.resolve_tenant()
|
||||
db_name = tenant_db
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
users = db['users']
|
||||
hashed_password = hashlib.sha512(password.encode()).hexdigest()
|
||||
user = users.find_one({'Username': username, 'Password': hashed_password})
|
||||
client.close()
|
||||
return user
|
||||
try:
|
||||
hashed_password = hashing(password)
|
||||
|
||||
def find_user_in_db(database_name):
|
||||
db = client[database_name]
|
||||
users = db['users']
|
||||
return users.find_one({'Username': username, 'Password': hashed_password}) or users.find_one({'username': username, 'Password': hashed_password})
|
||||
|
||||
user = find_user_in_db(db_name)
|
||||
if user:
|
||||
return user
|
||||
|
||||
# Fallback: tenant context may not be available yet, so search all tenant databases.
|
||||
for database_name in client.list_database_names():
|
||||
if database_name == db_name or not database_name.startswith('inventar_'):
|
||||
continue
|
||||
user = find_user_in_db(database_name)
|
||||
if user:
|
||||
return user
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def add_user(
|
||||
@@ -471,7 +523,7 @@ def add_user(
|
||||
'Admin': False,
|
||||
'active_ausleihung': None,
|
||||
'name': name_alias,
|
||||
'last_name': '',
|
||||
'last_name': last_name.strip() if last_name else '',
|
||||
'IsStudent': bool(is_student),
|
||||
'PermissionPreset': permission_defaults['preset'],
|
||||
'ActionPermissions': permission_defaults['actions'],
|
||||
@@ -564,11 +616,39 @@ def get_user(username):
|
||||
dict: User document or None if not found
|
||||
"""
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
users = db['users']
|
||||
users_return = users.find_one({'Username': username})
|
||||
client.close()
|
||||
return users_return
|
||||
try:
|
||||
def find_in_db(database_name):
|
||||
db = client[database_name]
|
||||
users = db['users']
|
||||
return users.find_one({'Username': username}) or users.find_one({'username': username})
|
||||
|
||||
# Try current tenant first when available
|
||||
try:
|
||||
from tenant import get_tenant_context
|
||||
ctx = get_tenant_context()
|
||||
if ctx and ctx.db_name:
|
||||
user = find_in_db(ctx.db_name)
|
||||
if user:
|
||||
return user
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to default configured database
|
||||
user = find_in_db(cfg.MONGODB_DB)
|
||||
if user:
|
||||
return user
|
||||
|
||||
# Last fallback: search all tenant databases if the user belongs to a tenant-specific DB
|
||||
for database_name in client.list_database_names():
|
||||
if database_name == cfg.MONGODB_DB or not database_name.startswith('inventar_'):
|
||||
continue
|
||||
user = find_in_db(database_name)
|
||||
if user:
|
||||
return user
|
||||
|
||||
return None
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def check_admin(username):
|
||||
|
||||
+4
-2
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"dbg": false,
|
||||
"key": "InventarsystemSecureKey2026XYZ789abcdef012",
|
||||
"ver": "0.0.2",
|
||||
"ver": "0.6.44",
|
||||
"host": "0.0.0.0",
|
||||
"port": 443,
|
||||
"port": 8000,
|
||||
|
||||
"mongodb": {
|
||||
"host": "localhost",
|
||||
@@ -50,6 +50,8 @@
|
||||
}
|
||||
},
|
||||
|
||||
"tenants": {},
|
||||
|
||||
"allowed_extensions": [
|
||||
"png", "jpg", "jpeg", "gif",
|
||||
"hevc", "heif",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# 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
|
||||
#
|
||||
# Usage:
|
||||
# docker-compose -f docker-compose-multitenant.yml up -d
|
||||
#
|
||||
# Scale example: To support 10 tenants with 20 users each:
|
||||
# docker-compose -f docker-compose-multitenant.yml up -d --scale app=10
|
||||
# Example: Start the stack and expose the app on host port 10000
|
||||
# INVENTAR_HTTP_PORT=10000 docker-compose -f docker-compose-multitenant.yml up -d
|
||||
|
||||
services:
|
||||
# Management Container for multi-tenant scripts
|
||||
@@ -21,33 +21,6 @@ services:
|
||||
- .:/workspace
|
||||
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:
|
||||
image: redis:7-alpine
|
||||
container_name: inventarsystem-redis
|
||||
@@ -69,7 +42,6 @@ services:
|
||||
image: mongo:7.0
|
||||
container_name: inventarsystem-mongodb
|
||||
restart: unless-stopped
|
||||
command: mongod --wiredTigerCacheSizeGB 2
|
||||
expose:
|
||||
- "27017"
|
||||
volumes:
|
||||
@@ -102,6 +74,8 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${INVENTAR_HTTP_PORT:-10000}:8000"
|
||||
networks:
|
||||
- inventar-net
|
||||
expose:
|
||||
@@ -132,11 +106,16 @@ services:
|
||||
INVENTAR_DELETED_ARCHIVE_FOLDER: /data/deleted-archives
|
||||
|
||||
# Performance Tuning
|
||||
INVENTAR_WORKER_CLASS: gevent
|
||||
INVENTAR_WORKERS: "4"
|
||||
INVENTAR_THREADS: "2"
|
||||
INVENTAR_WORKER_TIMEOUT: "30"
|
||||
INVENTAR_WORKER_CONNECTIONS: "100"
|
||||
INVENTAR_WORKER_CLASS: ${INVENTAR_WORKER_CLASS:-gevent}
|
||||
INVENTAR_WORKERS: "${INVENTAR_WORKERS:-4}"
|
||||
INVENTAR_THREADS: "${INVENTAR_THREADS:-2}"
|
||||
INVENTAR_WORKER_TIMEOUT: "${INVENTAR_WORKER_TIMEOUT:-30}"
|
||||
INVENTAR_WORKER_CONNECTIONS: "${INVENTAR_WORKER_CONNECTIONS:-100}"
|
||||
|
||||
# Auto-scaled runtime settings
|
||||
INVENTAR_APP_CPUS: ${INVENTAR_APP_CPUS:-1.0}
|
||||
INVENTAR_APP_MEM_LIMIT: ${INVENTAR_APP_MEM_LIMIT:-256m}
|
||||
INVENTAR_APP_MEM_SWAP_LIMIT: ${INVENTAR_APP_MEM_SWAP_LIMIT:-512m}
|
||||
|
||||
# Multi-Tenant
|
||||
INVENTAR_MULTITENANT_ENABLED: "true"
|
||||
@@ -157,9 +136,9 @@ services:
|
||||
# Resource Limits (per instance)
|
||||
# With 10 instances: each gets ~150MB, totaling ~1.5GB
|
||||
# Adjust based on server resources
|
||||
mem_limit: 256m
|
||||
memswap_limit: 512m
|
||||
cpus: "1.0"
|
||||
mem_limit: "${INVENTAR_APP_MEM_LIMIT:-256m}"
|
||||
memswap_limit: "${INVENTAR_APP_MEM_SWAP_LIMIT:-512m}"
|
||||
cpus: "${INVENTAR_APP_CPUS:-1.0}"
|
||||
|
||||
# Health check for load balancer
|
||||
healthcheck:
|
||||
|
||||
+26
-73
@@ -1,88 +1,41 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: inventarsystem-nginx
|
||||
app:
|
||||
build: .
|
||||
container_name: inventory-app
|
||||
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:
|
||||
app:
|
||||
condition: service_started
|
||||
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
|
||||
- redis
|
||||
|
||||
mongodb:
|
||||
image: mongo:7.0
|
||||
container_name: inventarsystem-mongodb
|
||||
image: mongo:latest
|
||||
container_name: 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
|
||||
- mongo_data:/data/db
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: inventarsystem-redis
|
||||
image: redis:alpine
|
||||
container_name: redis
|
||||
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:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
working_dir: /app/Web
|
||||
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
container_name: inventarsystem-app
|
||||
cloudflared:
|
||||
image: cloudflare/cloudflared:latest
|
||||
container_name: cloudflared
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
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"
|
||||
# Der Tunnel-Name 'homeserver' muss zu deiner credentials.json passen
|
||||
command: tunnel run homeserver
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
- app_uploads:/app/Web/uploads
|
||||
- app_thumbnails:/app/Web/thumbnails
|
||||
- app_previews:/app/Web/previews
|
||||
- app_qrcodes:/app/Web/QRCodes
|
||||
- app_backups:/data/backups
|
||||
- app_logs:/data/logs
|
||||
- app_deleted_archives:/data/deleted-archives
|
||||
- ./config.yml:/etc/cloudflared/config.yml
|
||||
- ./credentials.json:/etc/cloudflared/credentials.json
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
volumes:
|
||||
mongodb_data:
|
||||
app_uploads:
|
||||
app_thumbnails:
|
||||
app_previews:
|
||||
app_qrcodes:
|
||||
app_backups:
|
||||
app_logs:
|
||||
app_deleted_archives:
|
||||
redis_data:
|
||||
mongo_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
|
||||
+2
-2
@@ -473,8 +473,8 @@ EOF
|
||||
if [ ! -f "$PROJECT_DIR/.docker-build.env" ]; then
|
||||
cat > "$TMP_DIR/.docker-build.env" <<EOF
|
||||
NUITKA_BUILD=0
|
||||
INVENTAR_HTTP_PORT=80
|
||||
INVENTAR_HTTPS_PORT=443
|
||||
INVENTAR_HTTP_PORT=10000
|
||||
INVENTAR_HTTPS_PORT=10001
|
||||
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag
|
||||
EOF
|
||||
sudo install -m 644 "$TMP_DIR/.docker-build.env" "$PROJECT_DIR/.docker-build.env"
|
||||
|
||||
+313
-29
@@ -1,4 +1,6 @@
|
||||
#!/bin/sh
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
IFS=$'\n\t'
|
||||
# Script to manage multitenant deployment
|
||||
# Allows adding, removing, and restarting tenants without downtime for others
|
||||
|
||||
@@ -7,52 +9,291 @@ if [ ! -f "docker-compose-multitenant.yml" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CONFIG_FILE="$PWD/config.json"
|
||||
|
||||
show_help() {
|
||||
echo "Usage: ./manage-tenant.sh [COMMAND] [OPTIONS]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " add <tenant_id> Add a new tenant (initializes database)"
|
||||
echo " remove <tenant_id> Remove a tenant completely (deletes data!)"
|
||||
echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
|
||||
echo " restart-all Restart all application containers (zero-downtime reload)"
|
||||
echo " list List active tenants"
|
||||
echo " add <tenant_id> [port] Add a new tenant (initializes database)"
|
||||
echo " remove <tenant_id> Remove a tenant completely (deletes data!)"
|
||||
echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
|
||||
echo " restart-all Restart all application containers (zero-downtime reload)"
|
||||
echo " list List active tenants"
|
||||
echo " -h, --help Show this help message"
|
||||
echo ""
|
||||
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 restart-all"
|
||||
echo " ./manage-tenant.sh -h"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
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
|
||||
}
|
||||
|
||||
update_runtime_ports() {
|
||||
local new_port="$1"
|
||||
local env_file="$PWD/.docker-build.env"
|
||||
local current_ports=""
|
||||
local port_list=()
|
||||
local unique_ports=()
|
||||
local first_port=""
|
||||
if [ -n "$new_port" ]; then
|
||||
if [ -f "$env_file" ]; then
|
||||
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
||||
if [ -z "$current_ports" ]; then
|
||||
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$current_ports" ]; then
|
||||
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
|
||||
fi
|
||||
port_list+=("$new_port")
|
||||
|
||||
for port in "${port_list[@]}"; do
|
||||
if [ -n "$port" ] && ! printf '%s\n' "${unique_ports[@]}" | grep -qx "$port"; then
|
||||
unique_ports+=("$port")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#unique_ports[@]} -eq 0 ]; then
|
||||
unique_ports=("$new_port")
|
||||
fi
|
||||
|
||||
first_port="${unique_ports[0]}"
|
||||
local ports_csv
|
||||
ports_csv="$(IFS=,; echo "${unique_ports[*]}")"
|
||||
|
||||
if [ ! -f "$env_file" ]; then
|
||||
cat > "$env_file" <<EOF
|
||||
NUITKA_BUILD=0
|
||||
INVENTAR_HTTP_PORT=$first_port
|
||||
INVENTAR_HTTP_PORTS=$ports_csv
|
||||
EOF
|
||||
else
|
||||
if grep -q '^INVENTAR_HTTP_PORTS=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^INVENTAR_HTTP_PORTS=.*|INVENTAR_HTTP_PORTS=$ports_csv|" "$env_file"
|
||||
else
|
||||
printf '\nINVENTAR_HTTP_PORTS=%s\n' "$ports_csv" >> "$env_file"
|
||||
fi
|
||||
if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file"
|
||||
else
|
||||
printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Updated runtime env ports: $ports_csv"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_tenant_port() {
|
||||
local tenant_id="$1"
|
||||
local config_path="$CONFIG_FILE"
|
||||
local port
|
||||
|
||||
port="$(python3 - "$config_path" "$tenant_id" <<'PY'
|
||||
import json, sys, os
|
||||
path, tenant_id = sys.argv[1], sys.argv[2]
|
||||
if not os.path.isfile(path):
|
||||
sys.exit(1)
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
tenants = cfg.get('tenants', {})
|
||||
if not isinstance(tenants, dict) or tenant_id not in tenants or not isinstance(tenants[tenant_id], dict):
|
||||
sys.exit(2)
|
||||
removed = tenants.pop(tenant_id, None)
|
||||
cfg['tenants'] = tenants
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cfg, f, indent=4, ensure_ascii=False)
|
||||
if removed is not None:
|
||||
port = removed.get('port')
|
||||
if port is not None:
|
||||
print(port)
|
||||
PY
|
||||
)"
|
||||
|
||||
local status=$?
|
||||
if [ $status -eq 1 ]; then
|
||||
echo "Error: config file not found: $config_path"
|
||||
return 1
|
||||
fi
|
||||
if [ $status -eq 2 ]; then
|
||||
return 2
|
||||
fi
|
||||
|
||||
if [ -n "$port" ]; then
|
||||
echo "$port"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
remove_runtime_port() {
|
||||
local removed_port="$1"
|
||||
local env_file="$PWD/.docker-build.env"
|
||||
local current_ports=""
|
||||
local port_list=()
|
||||
local new_ports=()
|
||||
local first_port=""
|
||||
|
||||
if [ ! -f "$env_file" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
||||
if [ -z "$current_ports" ]; then
|
||||
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
||||
fi
|
||||
|
||||
if [ -z "$current_ports" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
|
||||
for port in "${port_list[@]}"; do
|
||||
if [ -n "$port" ] && [ "$port" != "$removed_port" ]; then
|
||||
new_ports+=("$port")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#new_ports[@]} -eq 0 ]; then
|
||||
sed -i '/^INVENTAR_HTTP_PORTS=/d;/^INVENTAR_HTTP_PORT=/d' "$env_file"
|
||||
return 0
|
||||
fi
|
||||
|
||||
first_port="${new_ports[0]}"
|
||||
local ports_csv="$(IFS=,; echo "${new_ports[*]}")"
|
||||
|
||||
if grep -q '^INVENTAR_HTTP_PORTS=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^INVENTAR_HTTP_PORTS=.*|INVENTAR_HTTP_PORTS=$ports_csv|" "$env_file"
|
||||
else
|
||||
printf '\nINVENTAR_HTTP_PORTS=%s\n' "$ports_csv" >> "$env_file"
|
||||
fi
|
||||
|
||||
if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file"
|
||||
else
|
||||
printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -z "${1:-}" ]; then
|
||||
show_help
|
||||
fi
|
||||
|
||||
COMMAND=$1
|
||||
TENANT_ID=$2
|
||||
COMMAND="$1"
|
||||
TENANT_ID="${2:-}"
|
||||
|
||||
case "$COMMAND" in
|
||||
-h|--help)
|
||||
show_help
|
||||
;;
|
||||
add)
|
||||
if [ -z "$TENANT_ID" ]; then
|
||||
echo "Error: Please provide a tenant_id."
|
||||
exit 1
|
||||
fi
|
||||
echo "Adding new tenant '$TENANT_ID'..."
|
||||
# Add Nginx configuration
|
||||
if [ -f "docker/nginx/multitenant.conf" ]; then
|
||||
echo "Assuming dynamic routing based on subdomain ($TENANT_ID)..."
|
||||
|
||||
PORT_ARG="$3"
|
||||
if [ -n "$PORT_ARG" ]; then
|
||||
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
|
||||
echo "Error: Port must be a numeric value."
|
||||
exit 1
|
||||
fi
|
||||
register_tenant_port "$TENANT_ID" "$PORT_ARG"
|
||||
update_runtime_ports "$PORT_ARG"
|
||||
fi
|
||||
|
||||
|
||||
echo "Adding new tenant '$TENANT_ID'..."
|
||||
# Initialize tenant database via Python inside container
|
||||
echo "Initializing database for $TENANT_ID..."
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
docker exec $APP_CONTAINER python3 -c "
|
||||
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient; import hashlib
|
||||
import sys, re; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient; import hashlib
|
||||
tenant_id = sys.argv[1].lower()
|
||||
sanitized = ''.join(c for c in tenant_id if c.isalnum() or c == '_')
|
||||
db_name = f'inventar_{sanitized}'
|
||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
|
||||
db = client[db_name]
|
||||
hashed_pw = hashlib.sha512('admin123'.encode()).hexdigest()
|
||||
db.users.insert_one({'Username': 'admin', 'Password': hashed_pw, 'Role': 'admin', 'Name': 'Admin', 'Nachname': 'User'})
|
||||
if db.users.count_documents({'Username': 'admin'}) == 0:
|
||||
db.users.insert_one({
|
||||
'Username': 'admin',
|
||||
'Password': hashed_pw,
|
||||
'Admin': True,
|
||||
'active_ausleihung': None,
|
||||
'name': 'Admin',
|
||||
'last_name': 'User',
|
||||
'IsStudent': False,
|
||||
'PermissionPreset': 'full_access',
|
||||
'ActionPermissions': {
|
||||
'can_borrow': True,
|
||||
'can_insert': True,
|
||||
'can_edit': True,
|
||||
'can_delete': True,
|
||||
'can_manage_users': True,
|
||||
'can_manage_settings': True,
|
||||
'can_view_logs': True,
|
||||
},
|
||||
'PagePermissions': {
|
||||
'home': True,
|
||||
'tutorial_page': True,
|
||||
'my_borrowed_items': True,
|
||||
'notifications_view': True,
|
||||
'impressum': True,
|
||||
'license': True,
|
||||
'library_view': True,
|
||||
'terminplan': True,
|
||||
'home_admin': True,
|
||||
'upload_admin': True,
|
||||
'library_admin': True,
|
||||
'admin_borrowings': True,
|
||||
'library_loans_admin': True,
|
||||
'admin_damaged_items': True,
|
||||
'admin_audit_dashboard': True,
|
||||
'logs': True,
|
||||
'manage_filters': True,
|
||||
'manage_locations': True,
|
||||
},
|
||||
})
|
||||
print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123')
|
||||
" "$TENANT_ID"
|
||||
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
|
||||
@@ -74,14 +315,28 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
docker exec $APP_CONTAINER python3 -c "
|
||||
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient
|
||||
import sys; sys.path.insert(0, '/app/Web'); from tenant import TenantContext; import settings; from pymongo import MongoClient
|
||||
ctx = TenantContext()
|
||||
db_name = ctx._get_db_name(sys.argv[1])
|
||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||
client.drop_database(f'{settings.MONGODB_DB}_{sys.argv[1]}')
|
||||
client.drop_database(db_name)
|
||||
print(f'Database for tenant {sys.argv[1]} dropped.')
|
||||
" "$TENANT_ID"
|
||||
echo "Tenant '$TENANT_ID' removed successfully."
|
||||
echo "Tenant '$TENANT_ID' database removed."
|
||||
else
|
||||
echo "Error: Application container not running."
|
||||
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
|
||||
fi
|
||||
|
||||
port_to_remove=""
|
||||
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
|
||||
if [ -n "$port_to_remove" ]; then
|
||||
remove_runtime_port "$port_to_remove"
|
||||
echo "Removed tenant '$TENANT_ID' from config.json and cleaned runtime port $port_to_remove."
|
||||
else
|
||||
echo "Removed tenant '$TENANT_ID' from config.json. No port mapping was present."
|
||||
fi
|
||||
else
|
||||
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
|
||||
fi
|
||||
else
|
||||
echo "Removal canceled."
|
||||
@@ -118,17 +373,46 @@ print(f'Tenant {sys.argv[1]} session cache cleared. Tenant restarted.')
|
||||
;;
|
||||
|
||||
list)
|
||||
echo "Listing active tenants (Databases):"
|
||||
echo "Listing configured tenants (config.json):"
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
python3 - "$CONFIG_FILE" <<'PY'
|
||||
import json, sys
|
||||
path = sys.argv[1]
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
tenants = cfg.get('tenants', {})
|
||||
if isinstance(tenants, dict) and tenants:
|
||||
for tid, conf in tenants.items():
|
||||
port = conf.get('port') if isinstance(conf, dict) else None
|
||||
if port is not None:
|
||||
print(f'- {tid} (port {port})')
|
||||
else:
|
||||
print(f'- {tid}')
|
||||
else:
|
||||
print(' (no tenants configured)')
|
||||
PY
|
||||
else
|
||||
echo " (config.json not found)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Listing active tenant databases (MongoDB):"
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
docker exec $APP_CONTAINER python3 -c "
|
||||
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient
|
||||
docker exec -i "$APP_CONTAINER" python3 - <<'PY'
|
||||
import sys
|
||||
sys.path.insert(0, '/app/Web')
|
||||
import settings
|
||||
from pymongo import MongoClient
|
||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||
prefix = f'{settings.MONGODB_DB}_'
|
||||
prefix = 'inventar_'
|
||||
dbs = [d for d in client.list_database_names() if d.startswith(prefix)]
|
||||
for db in dbs:
|
||||
print(f'- {db.replace(prefix, \"\")}')
|
||||
"
|
||||
if dbs:
|
||||
for db in dbs:
|
||||
print(f'- {db.replace(prefix, "")}')
|
||||
else:
|
||||
print(' (no tenant databases found)')
|
||||
PY
|
||||
else
|
||||
echo "Error: Application container not running."
|
||||
fi
|
||||
|
||||
+2
-2
@@ -12,6 +12,6 @@ redis
|
||||
reportlab
|
||||
python-barcode
|
||||
openpyxl
|
||||
cryptography
|
||||
cryptography>=42.0.0
|
||||
pywebpush
|
||||
py-vapid==1.9.0
|
||||
py-vapid>=1.9.0
|
||||
|
||||
+106
-4
@@ -1,7 +1,109 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
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" "$@"
|
||||
"$SCRIPT_DIR/start.sh" "$@"
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
|
||||
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
|
||||
|
||||
parse_port_list() {
|
||||
local raw="$1"
|
||||
local port
|
||||
local ports=()
|
||||
raw="${raw//,/ }"
|
||||
for port in $raw; do
|
||||
port="${port//[[:space:]]/}"
|
||||
if [ -n "$port" ] && printf '%s\n' "$port" | grep -qE '^[0-9]+$'; then
|
||||
ports+=("$port")
|
||||
fi
|
||||
done
|
||||
printf '%s\n' "${ports[@]}"
|
||||
}
|
||||
|
||||
write_runtime_override() {
|
||||
local current_ports=""
|
||||
local app_image=""
|
||||
local ports=()
|
||||
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
app_image="$(awk -F= '/^INVENTAR_APP_IMAGE=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
|
||||
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
|
||||
if [ -z "$current_ports" ]; then
|
||||
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$app_image" ]; then
|
||||
if [ -n "$current_ports" ]; then
|
||||
mapfile -t ports < <(parse_port_list "$current_ports")
|
||||
fi
|
||||
|
||||
cat > "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
|
||||
services:
|
||||
app:
|
||||
image: $app_image
|
||||
build: null
|
||||
EOF
|
||||
if [ ${#ports[@]} -gt 1 ]; then
|
||||
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
|
||||
ports:
|
||||
EOF
|
||||
for port in "${ports[@]}"; do
|
||||
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
|
||||
- "$port:8000"
|
||||
EOF
|
||||
done
|
||||
fi
|
||||
else
|
||||
rm -f "$RUNTIME_COMPOSE_OVERRIDE_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
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..."
|
||||
write_runtime_override
|
||||
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,12 +14,20 @@ if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
IS_ROOT="false"
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
IS_ROOT="true"
|
||||
fi
|
||||
|
||||
NUITKA_BUILD_VALUE="0"
|
||||
HTTP_PORT_VALUE="8001"
|
||||
HTTPS_PORT_VALUE="8443"
|
||||
HTTP_PORT_VALUE="10000"
|
||||
HTTP_PORTS_VALUE=""
|
||||
DEFAULT_TENANT_PORT_START="${INVENTAR_TENANT_PORT_START:-10000}"
|
||||
CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}"
|
||||
APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-$APP_IMAGE_REPO:latest}"
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
COMPOSE_PROFILES_VALUE=""
|
||||
MIN_DOCKER_FREE_MB="${INVENTAR_MIN_DOCKER_FREE_MB:-1024}"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -112,7 +120,11 @@ ensure_runtime_dependencies() {
|
||||
local missing=()
|
||||
|
||||
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
|
||||
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
@@ -136,14 +148,20 @@ ensure_runtime_dependencies() {
|
||||
fi
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
echo "Installing missing dependencies: ${missing[*]}"
|
||||
apt_install "${missing[@]}"
|
||||
if [ "$IS_ROOT" = "true" ]; then
|
||||
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
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true
|
||||
if [ "$IS_ROOT" = "true" ] && command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl enable --now docker >/dev/null 2>&1 || true
|
||||
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
|
||||
}
|
||||
@@ -156,6 +174,11 @@ setup_boot_autostart_service() {
|
||||
return 0
|
||||
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
|
||||
return 0
|
||||
fi
|
||||
@@ -197,6 +220,11 @@ setup_scheduled_jobs() {
|
||||
return 0
|
||||
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
|
||||
echo "Warning: crontab not available, skipping nightly update setup"
|
||||
return 0
|
||||
@@ -207,101 +235,17 @@ setup_scheduled_jobs() {
|
||||
backup_line="30 2 * * * cd $SCRIPT_DIR && ./backup.sh --mode auto >> $SCRIPT_DIR/logs/backup.log 2>&1"
|
||||
|
||||
local existing_cron
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
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
|
||||
echo "$backup_line"
|
||||
echo "$update_line"
|
||||
} | 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
|
||||
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
|
||||
echo "$backup_line"
|
||||
echo "$update_line"
|
||||
} | crontab -
|
||||
|
||||
echo "Nightly backup scheduled at 02:30"
|
||||
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() {
|
||||
local config_path backup_path
|
||||
config_path="$SCRIPT_DIR/config.json"
|
||||
@@ -318,7 +262,7 @@ ensure_runtime_config_json() {
|
||||
"ver": "2.6.5",
|
||||
"dbg": false,
|
||||
"host": "0.0.0.0",
|
||||
"port": 443,
|
||||
"port": 8000,
|
||||
"mongodb": {
|
||||
"host": "mongodb",
|
||||
"port": 27017,
|
||||
@@ -457,106 +401,174 @@ find_free_port() {
|
||||
echo "$port"
|
||||
}
|
||||
|
||||
stack_owns_host_port() {
|
||||
local requested_port="$1"
|
||||
local container_port="$2"
|
||||
local mapped_port
|
||||
|
||||
mapped_port="$(docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" port nginx "$container_port" 2>/dev/null | tail -n1 || true)"
|
||||
if [ -z "$mapped_port" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
mapped_port="${mapped_port##*:}"
|
||||
[ "$mapped_port" = "$requested_port" ]
|
||||
}
|
||||
|
||||
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
|
||||
parse_port_list() {
|
||||
local raw="$1"
|
||||
local port
|
||||
local ports=()
|
||||
raw="${raw//,/ }"
|
||||
for port in $raw; do
|
||||
port="${port//[[:space:]]/}"
|
||||
if [ -n "$port" ] && printf '%s\n' "$port" | grep -qE '^[0-9]+$'; then
|
||||
ports+=("$port")
|
||||
fi
|
||||
done
|
||||
printf '%s\n' "${ports[@]}"
|
||||
}
|
||||
|
||||
configure_host_ports() {
|
||||
local requested_http requested_https
|
||||
local requested_http
|
||||
local requested_ports
|
||||
local ports=()
|
||||
local occupied_ports=()
|
||||
|
||||
requested_http=""
|
||||
requested_ports=""
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
requested_http="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
|
||||
fi
|
||||
if [ -z "$requested_http" ]; then
|
||||
requested_http="8001"
|
||||
requested_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
|
||||
fi
|
||||
|
||||
if stack_owns_host_port "$requested_http" "80"; then
|
||||
HTTP_PORT_VALUE="$requested_http"
|
||||
elif port_in_use "$requested_http"; then
|
||||
|
||||
if [ -n "${INVENTAR_HTTP_PORTS:-}" ]; then
|
||||
requested_ports="$INVENTAR_HTTP_PORTS"
|
||||
fi
|
||||
|
||||
if ! port_in_use "$requested_http"; then
|
||||
HTTP_PORT_VALUE="$requested_http"
|
||||
echo "Freed HTTP port $requested_http by stopping host nginx service"
|
||||
else
|
||||
HTTP_PORT_VALUE="$(find_free_port 8080)"
|
||||
echo "HTTP port is in use. Using fallback HTTP port: $HTTP_PORT_VALUE"
|
||||
if [ -n "${INVENTAR_HTTP_PORT:-}" ] && [ -z "$requested_ports" ]; then
|
||||
requested_ports="$INVENTAR_HTTP_PORT"
|
||||
fi
|
||||
|
||||
if [ -n "$requested_ports" ]; then
|
||||
mapfile -t ports < <(parse_port_list "$requested_ports")
|
||||
fi
|
||||
|
||||
if [ ${#ports[@]} -gt 0 ]; then
|
||||
HTTP_PORTS_VALUE="${ports[*]}"
|
||||
HTTP_PORT_VALUE="${ports[0]}"
|
||||
|
||||
for port in "${ports[@]}"; do
|
||||
if port_in_use "$port"; then
|
||||
occupied_ports+=("$port")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#occupied_ports[@]} -gt 0 ]; then
|
||||
if [ ${#ports[@]} -eq 1 ]; then
|
||||
HTTP_PORT_VALUE="$(find_free_port "$DEFAULT_TENANT_PORT_START")"
|
||||
echo "Host port ${ports[0]} is already occupied. Assigned new tenant port: $HTTP_PORT_VALUE"
|
||||
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
|
||||
else
|
||||
echo "Error: requested host port(s) already in use: ${occupied_ports[*]}"
|
||||
echo "Remove the occupied port(s) from INVENTAR_HTTP_PORTS or stop the conflicting service."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
HTTP_PORT_VALUE="$requested_http"
|
||||
HTTP_PORT_VALUE="$DEFAULT_TENANT_PORT_START"
|
||||
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
|
||||
|
||||
if port_in_use "$HTTP_PORT_VALUE"; then
|
||||
HTTP_PORT_VALUE="$(find_free_port "$DEFAULT_TENANT_PORT_START")"
|
||||
echo "Host port $DEFAULT_TENANT_PORT_START is already occupied. Assigned new tenant port: $HTTP_PORT_VALUE"
|
||||
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
|
||||
fi
|
||||
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
|
||||
|
||||
docker_root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
|
||||
if [ -z "$docker_root" ]; then
|
||||
docker_root="/var/lib/docker"
|
||||
fi
|
||||
|
||||
if [ ! -d "$docker_root" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
available_kb="$(df -Pk "$docker_root" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
|
||||
if [ -z "$available_kb" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
available_mb=$((available_kb / 1024))
|
||||
|
||||
if [ "$available_mb" -lt "$MIN_DOCKER_FREE_MB" ]; then
|
||||
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
|
||||
}
|
||||
|
||||
detect_server_capacity() {
|
||||
local cpus mem_kb mem_gb app_workers app_cpus app_mem app_mem_swap
|
||||
|
||||
cpus="$(nproc 2>/dev/null || echo 1)"
|
||||
mem_kb="$(awk '/MemAvailable:/ {print $2; exit}' /proc/meminfo 2>/dev/null || awk '/MemTotal:/ {print $2; exit}' /proc/meminfo || echo 0)"
|
||||
mem_gb=$(( (mem_kb + 1024*1024 - 1) / (1024*1024) ))
|
||||
|
||||
if [ "$cpus" -ge 8 ] && [ "$mem_gb" -ge 16 ]; then
|
||||
app_workers=8
|
||||
app_cpus="2.0"
|
||||
app_mem="512m"
|
||||
app_mem_swap="1024m"
|
||||
elif [ "$cpus" -ge 4 ] && [ "$mem_gb" -ge 8 ]; then
|
||||
app_workers=4
|
||||
app_cpus="1.0"
|
||||
app_mem="384m"
|
||||
app_mem_swap="768m"
|
||||
elif [ "$cpus" -ge 2 ] && [ "$mem_gb" -ge 4 ]; then
|
||||
app_workers=2
|
||||
app_cpus="0.75"
|
||||
app_mem="320m"
|
||||
app_mem_swap="640m"
|
||||
else
|
||||
app_workers=1
|
||||
app_cpus="0.5"
|
||||
app_mem="256m"
|
||||
app_mem_swap="512m"
|
||||
fi
|
||||
|
||||
requested_https=""
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
requested_https="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
|
||||
local env_app_cpus env_app_mem env_app_mem_swap env_workers
|
||||
env_app_cpus="$(awk -F= '/^INVENTAR_APP_CPUS=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
|
||||
env_app_mem="$(awk -F= '/^INVENTAR_APP_MEM_LIMIT=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
|
||||
env_app_mem_swap="$(awk -F= '/^INVENTAR_APP_MEM_SWAP_LIMIT=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
|
||||
env_workers="$(awk -F= '/^INVENTAR_WORKERS=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
|
||||
|
||||
[ -n "$env_app_cpus" ] && app_cpus="$env_app_cpus"
|
||||
[ -n "$env_app_mem" ] && app_mem="$env_app_mem"
|
||||
[ -n "$env_app_mem_swap" ] && app_mem_swap="$env_app_mem_swap"
|
||||
[ -n "$env_workers" ] && app_workers="$env_workers"
|
||||
fi
|
||||
|
||||
if [ -z "$requested_https" ]; then
|
||||
requested_https="8443"
|
||||
fi
|
||||
INVENTAR_APP_CPUS="${INVENTAR_APP_CPUS:-$app_cpus}"
|
||||
INVENTAR_APP_MEM_LIMIT="${INVENTAR_APP_MEM_LIMIT:-$app_mem}"
|
||||
INVENTAR_APP_MEM_SWAP_LIMIT="${INVENTAR_APP_MEM_SWAP_LIMIT:-$app_mem_swap}"
|
||||
INVENTAR_WORKERS="${INVENTAR_WORKERS:-$app_workers}"
|
||||
INVENTAR_THREADS="${INVENTAR_THREADS:-2}"
|
||||
|
||||
if stack_owns_host_port "$requested_https" "443"; then
|
||||
HTTPS_PORT_VALUE="$requested_https"
|
||||
elif port_in_use "$requested_https"; then
|
||||
|
||||
|
||||
if ! port_in_use "$requested_https"; then
|
||||
HTTPS_PORT_VALUE="$requested_https"
|
||||
echo "Freed HTTPS port $requested_https by stopping host nginx service"
|
||||
return
|
||||
fi
|
||||
|
||||
HTTPS_PORT_VALUE="$(find_free_port 8443)"
|
||||
echo "HTTPS port is in use. Using fallback HTTPS port: $HTTPS_PORT_VALUE"
|
||||
else
|
||||
HTTPS_PORT_VALUE="$requested_https"
|
||||
fi
|
||||
echo "Detected host capacity: CPUs=$cpus, RAM=${mem_gb}GB"
|
||||
echo "Configured app runtime: INVENTAR_WORKERS=$INVENTAR_WORKERS, INVENTAR_APP_CPUS=$INVENTAR_APP_CPUS, INVENTAR_APP_MEM_LIMIT=$INVENTAR_APP_MEM_LIMIT"
|
||||
}
|
||||
|
||||
write_env_file() {
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
NUITKA_BUILD=$NUITKA_BUILD_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_CPUS=$INVENTAR_APP_CPUS
|
||||
INVENTAR_APP_MEM_LIMIT=$INVENTAR_APP_MEM_LIMIT
|
||||
INVENTAR_APP_MEM_SWAP_LIMIT=$INVENTAR_APP_MEM_SWAP_LIMIT
|
||||
INVENTAR_WORKERS=$INVENTAR_WORKERS
|
||||
INVENTAR_THREADS=$INVENTAR_THREADS
|
||||
INVENTAR_WORKER_TIMEOUT=${INVENTAR_WORKER_TIMEOUT:-30}
|
||||
INVENTAR_WORKER_CONNECTIONS=${INVENTAR_WORKER_CONNECTIONS:-100}
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -565,10 +577,25 @@ write_runtime_compose_override() {
|
||||
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", "-"]
|
||||
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "${INVENTAR_WORKERS:-2}", "--threads", "${INVENTAR_THREADS:-2}", "--timeout", "${INVENTAR_WORKER_TIMEOUT:-30}", "--graceful-timeout", "20", "--worker-connections", "${INVENTAR_WORKER_CONNECTIONS:-100}", "--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() {
|
||||
@@ -585,10 +612,10 @@ verify_stack_health() {
|
||||
for _ in $(seq 1 60); do
|
||||
running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)"
|
||||
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
|
||||
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."
|
||||
return 0
|
||||
fi
|
||||
@@ -601,8 +628,8 @@ verify_stack_health() {
|
||||
if [[ $retry_count -eq 0 ]]; then
|
||||
echo "Health check failed. Attempting to restart containers..."
|
||||
docker compose "${compose_args[@]}" ps || true
|
||||
docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb || true
|
||||
docker compose "${compose_args[@]}" restart app nginx mongodb
|
||||
docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb || true
|
||||
docker compose "${compose_args[@]}" restart app redis mongodb
|
||||
sleep 3
|
||||
((retry_count++))
|
||||
else
|
||||
@@ -613,7 +640,7 @@ verify_stack_health() {
|
||||
# Final failure
|
||||
echo "Error: stack health check failed after restart attempt."
|
||||
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
|
||||
}
|
||||
|
||||
@@ -621,13 +648,13 @@ parse_args "$@"
|
||||
|
||||
ensure_runtime_dependencies
|
||||
setup_boot_autostart_service
|
||||
ensure_tls_certificates
|
||||
ensure_nginx_config_mount_source
|
||||
ensure_runtime_config_json
|
||||
setup_scheduled_jobs
|
||||
configure_nuitka_mode
|
||||
resolve_app_image
|
||||
configure_host_ports
|
||||
ensure_min_docker_disk_space
|
||||
detect_server_capacity
|
||||
ensure_app_image_loaded
|
||||
write_env_file
|
||||
write_runtime_compose_override
|
||||
@@ -638,9 +665,16 @@ if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
|
||||
compose_up_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
|
||||
fi
|
||||
compose_up_args+=(--env-file "$ENV_FILE")
|
||||
docker compose "${compose_up_args[@]}" up -d --remove-orphans
|
||||
if [ -n "$COMPOSE_PROFILES_VALUE" ]; then
|
||||
export COMPOSE_PROFILES="$COMPOSE_PROFILES_VALUE"
|
||||
fi
|
||||
if ! docker compose "${compose_up_args[@]}" up -d --remove-orphans; then
|
||||
echo "Docker Compose startup failed once. Waiting briefly and retrying..."
|
||||
sleep 5
|
||||
docker compose "${compose_up_args[@]}" up -d --remove-orphans
|
||||
fi
|
||||
|
||||
verify_stack_health
|
||||
|
||||
echo "Stack started."
|
||||
echo "Open: https://<server-ip>:$HTTPS_PORT_VALUE"
|
||||
echo "Open: http://<server-ip>:$HTTP_PORT_VALUE"
|
||||
|
||||
@@ -16,6 +16,8 @@ ENV_FILE="$PROJECT_DIR/.docker-build.env"
|
||||
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
|
||||
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"
|
||||
chmod 777 "$LOG_DIR" 2>/dev/null || true
|
||||
@@ -36,6 +38,11 @@ if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
IS_ROOT="false"
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
IS_ROOT="true"
|
||||
fi
|
||||
|
||||
apt_install() {
|
||||
$SUDO apt-get update -y
|
||||
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"
|
||||
@@ -45,7 +52,7 @@ ensure_runtime_dependencies() {
|
||||
local missing=()
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
missing+=(docker.io)
|
||||
missing+=(docker)
|
||||
fi
|
||||
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
@@ -65,87 +72,77 @@ ensure_runtime_dependencies() {
|
||||
fi
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
log_message "Installing missing dependencies: ${missing[*]}"
|
||||
apt_install "${missing[@]}"
|
||||
if [ "$IS_ROOT" = "true" ]; then
|
||||
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
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true
|
||||
if [ "$IS_ROOT" = "true" ] && command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl enable --now docker >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_tls_certificates() {
|
||||
local cert_dir cert_path key_path cn
|
||||
cert_dir="$PROJECT_DIR/certs"
|
||||
cert_path="$cert_dir/inventarsystem.crt"
|
||||
key_path="$cert_dir/inventarsystem.key"
|
||||
ensure_min_root_disk_space() {
|
||||
local available_kb available_mb
|
||||
|
||||
mkdir -p "$cert_dir"
|
||||
|
||||
if [ -f "$cert_path" ] && [ -f "$key_path" ]; then
|
||||
if ! command -v df >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
cn="${TLS_CN:-localhost}"
|
||||
log_message "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="$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"
|
||||
available_kb="$(df -Pk "$PROJECT_DIR" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
|
||||
if [ -z "$available_kb" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ ! -f "$config_path" ]; then
|
||||
cat > "$config_path" <<'EOF'
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
return 301 https://$host$request_uri;
|
||||
available_mb=$((available_kb / 1024))
|
||||
if [ "$available_mb" -lt "$MIN_ROOT_FREE_MB" ]; then
|
||||
log_message "ERROR: Low disk space on filesystem containing $PROJECT_DIR"
|
||||
log_message "Available: ${available_mb} MB; required minimum: ${MIN_ROOT_FREE_MB} MB"
|
||||
log_message "Free disk space and rerun update."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name _;
|
||||
cleanup_old_dist_artifacts() {
|
||||
local keep_count
|
||||
keep_count="$DIST_KEEP_COUNT"
|
||||
|
||||
ssl_certificate /etc/nginx/certs/inventarsystem.crt;
|
||||
ssl_certificate_key /etc/nginx/certs/inventarsystem.key;
|
||||
if [ ! -d "$DIST_DIR" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
client_max_body_size 50M;
|
||||
if ! [[ "$keep_count" =~ ^[0-9]+$ ]]; then
|
||||
keep_count=2
|
||||
fi
|
||||
|
||||
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;
|
||||
}
|
||||
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}')
|
||||
if [ "${#archives[@]}" -le "$keep_count" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
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>';
|
||||
}
|
||||
local index old_archive deleted=0
|
||||
for (( index=keep_count; index<${#archives[@]}; index++ )); do
|
||||
old_archive="${archives[$index]}"
|
||||
if rm -f "$old_archive"; then
|
||||
deleted=$((deleted + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$deleted" -gt 0 ]; then
|
||||
log_message "Cleaned up $deleted old dist image archive(s)"
|
||||
fi
|
||||
}
|
||||
EOF
|
||||
log_message "Recreated missing nginx config at $config_path"
|
||||
|
||||
cleanup_docker_dangling_images() {
|
||||
if docker image prune -f >> "$LOG_FILE" 2>&1; then
|
||||
log_message "Cleaned up dangling Docker images"
|
||||
else
|
||||
log_message "WARNING: Could not prune dangling Docker images"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -347,9 +344,7 @@ download_and_extract_bundle() {
|
||||
tar -xzf "$archive" -C "$tmp_dir"
|
||||
|
||||
# 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/nginx/default.conf" "$PROJECT_DIR/docker/nginx/default.conf"
|
||||
cp -f "$tmp_dir/start.sh" "$PROJECT_DIR/start.sh"
|
||||
cp -f "$tmp_dir/stop.sh" "$PROJECT_DIR/stop.sh"
|
||||
|
||||
@@ -365,9 +360,6 @@ download_and_extract_bundle() {
|
||||
if [ -f "$tmp_dir/docker-compose-multitenant.yml" ]; then
|
||||
cp -f "$tmp_dir/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose-multitenant.yml"
|
||||
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
|
||||
cp -f "$tmp_dir/manage-tenant.sh" "$PROJECT_DIR/manage-tenant.sh"
|
||||
fi
|
||||
@@ -408,8 +400,7 @@ deploy() {
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
NUITKA_BUILD=0
|
||||
INVENTAR_HTTP_PORT=80
|
||||
INVENTAR_HTTPS_PORT=443
|
||||
INVENTAR_HTTP_PORT=10000
|
||||
INVENTAR_APP_IMAGE=$app_image
|
||||
EOF
|
||||
elif grep -q '^INVENTAR_APP_IMAGE=' "$ENV_FILE"; then
|
||||
@@ -428,27 +419,27 @@ EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull nginx mongodb >> "$LOG_FILE" 2>&1
|
||||
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull app mongodb >> "$LOG_FILE" 2>&1
|
||||
docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
|
||||
docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
|
||||
}
|
||||
|
||||
verify_stack_health() {
|
||||
local compose_args running_services
|
||||
local https_port
|
||||
local http_port
|
||||
compose_args=(-f "$PROJECT_DIR/$COMPOSE_FILE" --env-file "$ENV_FILE")
|
||||
https_port="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')"
|
||||
if [ -z "$https_port" ]; then
|
||||
https_port="443"
|
||||
http_port="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')"
|
||||
if [ -z "$http_port" ]; then
|
||||
http_port="10000"
|
||||
fi
|
||||
|
||||
for _ in $(seq 1 60); do
|
||||
running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)"
|
||||
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
|
||||
# Primary check: HTTP endpoint responds (most reliable)
|
||||
if curl -kfsS "https://127.0.0.1:$https_port" >/dev/null 2>&1; then
|
||||
# Primary check: health endpoint responds (most reliable)
|
||||
if curl -fsS "http://127.0.0.1:$http_port/health" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
@@ -456,22 +447,41 @@ verify_stack_health() {
|
||||
done
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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() {
|
||||
parse_args "$@"
|
||||
|
||||
cleanup_server_space
|
||||
|
||||
ensure_runtime_dependencies
|
||||
ensure_tls_certificates
|
||||
ensure_nginx_config_mount_source
|
||||
|
||||
require_cmd curl
|
||||
require_cmd tar
|
||||
require_cmd docker
|
||||
require_cmd python3
|
||||
|
||||
ensure_min_root_disk_space
|
||||
archive_logs
|
||||
create_backup
|
||||
|
||||
@@ -559,6 +569,8 @@ main() {
|
||||
fi
|
||||
|
||||
echo "$latest_tag" > "$STATE_FILE"
|
||||
cleanup_old_dist_artifacts
|
||||
cleanup_docker_dangling_images
|
||||
log_message "Update completed successfully to release $latest_tag"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user