Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15d9eba987 | |||
| 05d6d299da | |||
| ab9db1211c | |||
| 45b69e5ddb | |||
| 4971bc859b | |||
| b7f55b0de0 | |||
| 9c24e79bba | |||
| 79c325329c | |||
| 8f81ffb4c5 | |||
| 1d7692ea01 | |||
| 038390b8cd | |||
| f2c1dc2ba5 | |||
| 3eeae76e6c |
+1
-1
@@ -1,4 +1,4 @@
|
||||
NUITKA_BUILD=0
|
||||
INVENTAR_HTTP_PORT=80
|
||||
INVENTAR_HTTPS_PORT=443
|
||||
INVENTAR_HTTPS_PORT=8000
|
||||
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:latest
|
||||
|
||||
@@ -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?**
|
||||
|
||||
+38
-4
@@ -339,6 +339,28 @@ def _enforce_user_permissions():
|
||||
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():
|
||||
"""Return a cache-busting asset version tied to deployment state."""
|
||||
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
|
||||
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)
|
||||
@@ -1302,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)
|
||||
@@ -10784,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']))
|
||||
|
||||
|
||||
@@ -10,11 +10,43 @@ 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 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 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:
|
||||
@@ -27,6 +59,7 @@ class TenantContext:
|
||||
self.tenant_id = None
|
||||
self.db_name = None
|
||||
self.subdomain = None
|
||||
self.config = {}
|
||||
|
||||
def resolve_tenant(self):
|
||||
"""
|
||||
@@ -40,6 +73,7 @@ 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
|
||||
@@ -56,10 +90,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
|
||||
self.tenant_id = 'default'
|
||||
self.config = get_tenant_config('default')
|
||||
return self._get_db_name('default')
|
||||
|
||||
def _get_db_name(self, tenant_id):
|
||||
|
||||
+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",
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
tunnel: homeserver
|
||||
credentials-file: /etc/cloudflared/credentials.json
|
||||
|
||||
ingress:
|
||||
# Repo 1: Inventar
|
||||
- hostname: inventar.maximiliangruendinger.de
|
||||
service: http://inventory-app:3000
|
||||
|
||||
# Repo 2: Key-Service (Buchung)
|
||||
- hostname: booking.maximiliangruendinger.de
|
||||
service: http://key-service-app:8080
|
||||
|
||||
- service: http_status:404
|
||||
@@ -9,6 +9,22 @@
|
||||
# docker-compose -f docker-compose-multitenant.yml up -d --scale app=10
|
||||
|
||||
services:
|
||||
cloudflared:
|
||||
image: cloudflare/cloudflared:latest
|
||||
container_name: cloudflared
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- cloudflare
|
||||
command: ["--config", "/etc/cloudflared/config.yml", "tunnel", "run"]
|
||||
volumes:
|
||||
- ./docker/cloudflared/config.yml:/etc/cloudflared/config.yml:ro
|
||||
- /etc/cloudflared/credentials.json:/etc/cloudflared/credentials.json:ro
|
||||
depends_on:
|
||||
nginx:
|
||||
condition: service_started
|
||||
networks:
|
||||
- inventar-net
|
||||
|
||||
# Management Container for multi-tenant scripts
|
||||
tenant-manager:
|
||||
image: docker:cli
|
||||
@@ -32,9 +48,9 @@ services:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "${INVENTAR_HTTP_PORT:-80}:80"
|
||||
- "${INVENTAR_HTTPS_PORT:-443}:443"
|
||||
- "${INVENTAR_HTTPS_PORT:-8000}:8000"
|
||||
volumes:
|
||||
- ./docker/nginx/multitenant.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./docker/nginx/multitenant.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./certs:/etc/nginx/certs:ro
|
||||
- ./docker/nginx/acme:/etc/nginx/acme:ro
|
||||
networks:
|
||||
@@ -69,7 +85,6 @@ services:
|
||||
image: mongo:7.0
|
||||
container_name: inventarsystem-mongodb
|
||||
restart: unless-stopped
|
||||
command: mongod --wiredTigerCacheSizeGB 2
|
||||
expose:
|
||||
- "27017"
|
||||
volumes:
|
||||
@@ -108,7 +123,6 @@ services:
|
||||
- "8000"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
- ./Web:/app/Web
|
||||
- app_uploads:/app/Web/uploads:cached
|
||||
- app_thumbnails:/app/Web/thumbnails:cached
|
||||
- app_previews:/app/Web/previews:cached
|
||||
|
||||
+26
-74
@@ -1,89 +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
|
||||
- ./Web:/app/Web
|
||||
- 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
@@ -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
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Nur die App neu bauen und starten, ohne den Tunnel oder die DB zu killen
|
||||
docker compose up -d --build app
|
||||
|
||||
# Optional: Alles aufräumen, was nicht mehr gebraucht wird
|
||||
docker image prune -f
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
||||
|
||||
@@ -20,6 +20,8 @@ HTTPS_PORT_VALUE="8443"
|
||||
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
|
||||
@@ -439,6 +441,16 @@ resolve_app_image() {
|
||||
fi
|
||||
}
|
||||
|
||||
configure_cloudflared_profile() {
|
||||
if [ -f /etc/cloudflared/credentials.json ]; then
|
||||
COMPOSE_PROFILES_VALUE="cloudflare"
|
||||
echo "Cloudflared tunnel: enabled (homeserver)"
|
||||
else
|
||||
COMPOSE_PROFILES_VALUE=""
|
||||
echo "Cloudflared tunnel: disabled (missing /etc/cloudflared/credentials.json)"
|
||||
fi
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local port="$1"
|
||||
|
||||
@@ -551,6 +563,37 @@ configure_host_ports() {
|
||||
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
|
||||
}
|
||||
|
||||
write_env_file() {
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
NUITKA_BUILD=$NUITKA_BUILD_VALUE
|
||||
@@ -627,7 +670,9 @@ ensure_runtime_config_json
|
||||
setup_scheduled_jobs
|
||||
configure_nuitka_mode
|
||||
resolve_app_image
|
||||
configure_cloudflared_profile
|
||||
configure_host_ports
|
||||
ensure_min_docker_disk_space
|
||||
ensure_app_image_loaded
|
||||
write_env_file
|
||||
write_runtime_compose_override
|
||||
@@ -638,7 +683,14 @@ 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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -113,12 +115,74 @@ ensure_nginx_config_mount_source() {
|
||||
|
||||
if [ ! -f "$config_path" ]; then
|
||||
cat > "$config_path" <<'EOF'
|
||||
server {
|
||||
fi
|
||||
}
|
||||
|
||||
server() {
|
||||
listen 80;
|
||||
server_name _;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
ensure_min_root_disk_space() {
|
||||
local available_kb available_mb
|
||||
|
||||
if ! command -v df >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
available_kb="$(df -Pk "$PROJECT_DIR" 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_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
|
||||
}
|
||||
|
||||
cleanup_old_dist_artifacts() {
|
||||
local keep_count
|
||||
keep_count="$DIST_KEEP_COUNT"
|
||||
|
||||
if [ ! -d "$DIST_DIR" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! [[ "$keep_count" =~ ^[0-9]+$ ]]; then
|
||||
keep_count=2
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name _;
|
||||
@@ -460,9 +524,29 @@ verify_stack_health() {
|
||||
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
|
||||
@@ -472,6 +556,7 @@ main() {
|
||||
require_cmd docker
|
||||
require_cmd python3
|
||||
|
||||
ensure_min_root_disk_space
|
||||
archive_logs
|
||||
create_backup
|
||||
|
||||
@@ -559,6 +644,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