changes to the processing

This commit is contained in:
2026-08-26 12:08:54 +02:00
parent 6cf8f435f5
commit 1bc96ae2e6
2 changed files with 397 additions and 250 deletions
+235 -127
View File
@@ -9,171 +9,276 @@ SECRETS_FILE="$SCRIPT_DIR/.mongo-secrets.env"
RESET_MONGO_DATA=0 RESET_MONGO_DATA=0
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--reset-mongo-data|-r) --reset-mongo-data|-r)
RESET_MONGO_DATA=1 RESET_MONGO_DATA=1
;; ;;
esac esac
done done
ensure_mongo_secrets() { ensure_mongo_secrets() {
if [ -f "$SECRETS_FILE" ]; then if [ -f "$SECRETS_FILE" ]; then
# shellcheck disable=SC1090 # shellcheck disable=SC1090
source "$SECRETS_FILE" source "$SECRETS_FILE"
fi fi
if [ -z "${MONGO_INITDB_ROOT_PASSWORD:-}" ]; then if [ -z "${MONGO_INITDB_ROOT_PASSWORD:-}" ]; then
MONGO_INITDB_ROOT_PASSWORD="$(openssl rand -hex 24)" MONGO_INITDB_ROOT_PASSWORD="$(openssl rand -hex 24)"
fi fi
if [ -z "${MONGO_APP_PASSWORD:-}" ]; then if [ -z "${MONGO_APP_PASSWORD:-}" ]; then
MONGO_APP_PASSWORD="$(openssl rand -hex 24)" MONGO_APP_PASSWORD="$(openssl rand -hex 24)"
fi fi
cat > "$SECRETS_FILE" <<EOF cat > "$SECRETS_FILE" <<EOF
MONGO_INITDB_ROOT_USERNAME=${MONGO_INITDB_ROOT_USERNAME:-website_root} MONGO_INITDB_ROOT_USERNAME=${MONGO_INITDB_ROOT_USERNAME:-website_root}
MONGO_INITDB_ROOT_PASSWORD=$MONGO_INITDB_ROOT_PASSWORD MONGO_INITDB_ROOT_PASSWORD=$MONGO_INITDB_ROOT_PASSWORD
MONGO_APP_USER=${MONGO_APP_USER:-website_app} MONGO_APP_USER=${MONGO_APP_USER:-website_app}
MONGO_APP_PASSWORD=$MONGO_APP_PASSWORD MONGO_APP_PASSWORD=$MONGO_APP_PASSWORD
EOF EOF
chmod 600 "$SECRETS_FILE" chmod 600 "$SECRETS_FILE"
# shellcheck disable=SC1090 # shellcheck disable=SC1090
source "$SECRETS_FILE" source "$SECRETS_FILE"
export MONGO_INITDB_ROOT_USERNAME MONGO_INITDB_ROOT_PASSWORD MONGO_APP_USER MONGO_APP_PASSWORD export MONGO_INITDB_ROOT_USERNAME MONGO_INITDB_ROOT_PASSWORD MONGO_APP_USER MONGO_APP_PASSWORD
} }
ensure_mongo_app_user() { ensure_mongo_app_user() {
if "${DOCKER_CMD[@]}" compose --env-file "$SECRETS_FILE" exec -T mongodb mongosh --quiet \ if "${DOCKER_CMD[@]}" compose --env-file "$SECRETS_FILE" exec -T mongodb mongosh --quiet \
--username "$MONGO_INITDB_ROOT_USERNAME" \ --username "$MONGO_INITDB_ROOT_USERNAME" \
--password "$MONGO_INITDB_ROOT_PASSWORD" \ --password "$MONGO_INITDB_ROOT_PASSWORD" \
--authenticationDatabase admin \ --authenticationDatabase admin \
--eval ' --eval '
const appDatabase = process.env.MONGO_DB_NAME || "Invario_Website"; const appDatabase = process.env.MONGO_DB_NAME || "Invario_Website";
const appUser = process.env.MONGO_APP_USER || "website_app"; const appUser = process.env.MONGO_APP_USER || "website_app";
const appPassword = process.env.MONGO_APP_PASSWORD; const appPassword = process.env.MONGO_APP_PASSWORD;
if (!appPassword) { if (!appPassword) {
throw new Error("Missing MONGO_APP_PASSWORD"); throw new Error("Missing MONGO_APP_PASSWORD");
} }
const appDb = db.getSiblingDB(appDatabase); const appDb = db.getSiblingDB(appDatabase);
if (!appDb.getUser(appUser)) { if (!appDb.getUser(appUser)) {
appDb.createUser({ appDb.createUser({
user: appUser, user: appUser,
pwd: appPassword, pwd: appPassword,
roles: [{ role: "readWrite", db: appDatabase }], roles: [{ role: "readWrite", db: appDatabase }],
}); });
print("created app user"); print("created app user");
} else { } else {
appDb.updateUser(appUser, { appDb.updateUser(appUser, {
pwd: appPassword, pwd: appPassword,
roles: [{ role: "readWrite", db: appDatabase }], roles: [{ role: "readWrite", db: appDatabase }],
}); });
print("updated app user"); print("updated app user");
} }
' '
then then
return 0 return 0
fi fi
echo "[WARNUNG] App-User konnte nicht synchronisiert werden; der bestehende Mongo-Volume-Stand verwendet wahrscheinlich andere Root-Credentials. Falls das ein frischer Dev-Stand ist, starte mit MONGO_RESET_DATA=1 neu." >&2 echo "[WARNUNG] App-User konnte nicht synchronisiert werden; der bestehende Mongo-Volume-Stand verwendet wahrscheinlich andere Root-Credentials. Falls das ein frischer Dev-Stand ist, starte mit MONGO_RESET_DATA=1 neu." >&2
return 0 return 0
} }
wait_for_mongo_ready() { wait_for_mongo_ready() {
local timeout_seconds=180 local timeout_seconds=180
local interval=2 local interval=2
local elapsed=0 local elapsed=0
local mongo_container_id="" local mongo_container_id=""
local container_state="" local container_state=""
local health_status="" local health_status=""
mongo_container_id="$("${DOCKER_CMD[@]}" compose --env-file "$SECRETS_FILE" ps -q mongodb 2>/dev/null || true)" mongo_container_id="$("${DOCKER_CMD[@]}" compose --env-file "$SECRETS_FILE" ps -q mongodb 2>/dev/null || true)"
if [ -z "$mongo_container_id" ]; then if [ -z "$mongo_container_id" ]; then
echo "[FEHLER] MongoDB-Container wurde nicht gefunden." >&2 echo "[FEHLER] MongoDB-Container wurde nicht gefunden." >&2
return 1 return 1
fi fi
while [ $elapsed -lt $timeout_seconds ]; do while [ $elapsed -lt $timeout_seconds ]; do
container_state="$(${DOCKER_CMD[@]} inspect -f '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}}' "$mongo_container_id" 2>/dev/null || true)" container_state="$(${DOCKER_CMD[@]} inspect -f '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}}' "$mongo_container_id" 2>/dev/null || true)"
health_status="${container_state#* }" health_status="${container_state#* }"
if [ "$health_status" = "healthy" ]; then if [ "$health_status" = "healthy" ]; then
return 0 return 0
fi fi
if [ "${container_state%% *}" = "running" ] && [ -z "$health_status" ]; then if [ "${container_state%% *}" = "running" ] && [ -z "$health_status" ]; then
return 0 return 0
fi fi
if [ "${container_state%% *}" = "exited" ] || [ "${container_state%% *}" = "dead" ]; then if [ "${container_state%% *}" = "exited" ] || [ "${container_state%% *}" = "dead" ]; then
break break
fi fi
sleep $interval sleep $interval
elapsed=$((elapsed + interval)) elapsed=$((elapsed + interval))
done done
echo "[FEHLER] MongoDB wurde nicht rechtzeitig gestartet. Status: ${container_state:-unbekannt}" >&2 echo "[FEHLER] MongoDB wurde nicht rechtzeitig gestartet. Status: ${container_state:-unbekannt}" >&2
"${DOCKER_CMD[@]}" compose --env-file "$SECRETS_FILE" ps mongodb >&2 || true "${DOCKER_CMD[@]}" compose --env-file "$SECRETS_FILE" ps mongodb >&2 || true
"${DOCKER_CMD[@]}" compose --env-file "$SECRETS_FILE" logs --tail 80 mongodb >&2 || true "${DOCKER_CMD[@]}" compose --env-file "$SECRETS_FILE" logs --tail 80 mongodb >&2 || true
return 1 return 1
} }
resolve_docker_cmd() { resolve_docker_cmd() {
if docker info >/dev/null 2>&1; then if docker info >/dev/null 2>&1; then
DOCKER_CMD=(docker) DOCKER_CMD=(docker)
return 0 return 0
fi fi
if sudo -n docker info >/dev/null 2>&1; then if sudo -n docker info >/dev/null 2>&1; then
DOCKER_CMD=(sudo docker) DOCKER_CMD=(sudo docker)
return 0 return 0
fi fi
if command -v sudo >/dev/null 2>&1 && [ -t 0 ]; then if command -v sudo >/dev/null 2>&1 && [ -t 0 ]; then
echo "Docker-Zugriff ohne Gruppe erkannt. Verwende sudo docker (ggf. Passwortabfrage)." echo "Docker-Zugriff ohne Gruppe erkannt. Verwende sudo docker (ggf. Passwortabfrage)."
if sudo docker info >/dev/null 2>&1; then if sudo docker info >/dev/null 2>&1; then
DOCKER_CMD=(sudo docker) DOCKER_CMD=(sudo docker)
return 0 return 0
fi fi
fi fi
echo "[FEHLER] Kein Zugriff auf den Docker Daemon (/var/run/docker.sock)." >&2 echo "[FEHLER] Kein Zugriff auf den Docker Daemon (/var/run/docker.sock)." >&2
echo "Führe das Script mit sudo aus oder füge deinen User zur docker-Gruppe hinzu:" >&2 echo "Führe das Script mit sudo aus oder füge deinen User zur docker-Gruppe hinzu:" >&2
echo " sudo usermod -aG docker $USER" >&2 echo " sudo usermod -aG docker $USER" >&2
echo "Danach neu einloggen und Script erneut starten." >&2 echo "Danach neu einloggen und Script erneut starten." >&2
exit 3 exit 3
} }
compose() { compose() {
"${DOCKER_CMD[@]}" compose --env-file "$SECRETS_FILE" "$@" "${DOCKER_CMD[@]}" compose --env-file "$SECRETS_FILE" "$@"
} }
is_truthy() { is_truthy() {
case "${1:-}" in case "${1:-}" in
1|true|TRUE|yes|YES|on|ON) 1|true|TRUE|yes|YES|on|ON)
return 0 return 0
;; ;;
*) *)
return 1 return 1
;; ;;
esac esac
} }
reset_mongo_data_if_requested() { reset_mongo_data_if_requested() {
if ! is_truthy "${MONGO_RESET_DATA:-0}" && [ "$RESET_MONGO_DATA" -ne 1 ]; then if ! is_truthy "${MONGO_RESET_DATA:-0}" && [ "$RESET_MONGO_DATA" -ne 1 ]; then
return 0 return 0
fi fi
echo "[WARNUNG] Setze den MongoDB-Volume-Stand zurück, damit die aktuellen Secrets neu initialisiert werden." >&2 echo "[WARNUNG] Setze den MongoDB-Volume-Stand zurück, damit die aktuellen Secrets neu initialisiert werden." >&2
"${DOCKER_CMD[@]}" compose --env-file "$SECRETS_FILE" down -v --remove-orphans "${DOCKER_CMD[@]}" compose --env-file "$SECRETS_FILE" down -v --remove-orphans
} }
build_website_image() { build_website_image() {
if compose build website; then if compose build website; then
return 0 return 0
fi fi
echo "[WARNUNG] Standard-Build fehlgeschlagen, versuche Legacy-Build ohne BuildKit erneut..." >&2 echo "[WARNUNG] Standard-Build fehlgeschlagen, versuche Legacy-Build ohne BuildKit erneut..." >&2
DOCKER_BUILDKIT=0 COMPOSE_DOCKER_CLI_BUILD=0 compose build website DOCKER_BUILDKIT=0 COMPOSE_DOCKER_CLI_BUILD=0 compose build website
} }
# ============================================================================
# WATCHER LOGIK FÜR ALLE TENANT AKTIONEN (add, remove, restart)
# ============================================================================
watcher_loop() {
local trigger_dir="$1"
echo "[INFO] Tenant Watcher gestartet. Lausche in $trigger_dir..."
while true; do
for file in "$trigger_dir"/*.json; do
# Überspringen, wenn keine Dateien da sind
[ -f "$file" ] || continue
echo "[INFO] Verarbeite Trigger: $file"
# JSON auslesen (mit jq oder grep als Fallback)
if command -v jq >/dev/null 2>&1; then
ACTION=$(jq -r '.action // "add"' "$file" 2>/dev/null || echo "add")
SLUG=$(jq -r '.slug // empty' "$file" 2>/dev/null || true)
PORT=$(jq -r '.port // empty' "$file" 2>/dev/null || true)
PASS=$(jq -r '.password // empty' "$file" 2>/dev/null || true)
else
# Fallback, falls jq nicht installiert ist
ACTION=$(grep -o '"action": *"[^"]*"' "$file" | cut -d'"' -f4 || echo "add")
[ -z "$ACTION" ] && ACTION="add"
SLUG=$(grep -o '"slug": *"[^"]*"' "$file" | cut -d'"' -f4 || true)
PORT=$(grep -o '"port": *[0-9]*' "$file" | cut -d':' -f2 | tr -d ' ' || true)
PASS=$(grep -o '"password": *"[^"]*"' "$file" | cut -d'"' -f4 || true)
fi
if [ -z "$SLUG" ]; then
echo "[ERROR] Ungültige Payload in $file (Slug fehlt). Wird gelöscht." >&2
rm -f "$file"
continue
fi
case "$ACTION" in
"add")
if [ -n "$PORT" ] && [ -n "$PASS" ]; then
if "$SCRIPT_DIR/manage-tenant.sh" add "$SLUG" "$PORT" "$PASS"; then
echo "[SUCCESS] Tenant bereitgestellt: $SLUG"
else
echo "[ERROR] Fehler beim Bereitstellen von: $SLUG" >&2
fi
else
echo "[ERROR] Fehlende Port/Passwort-Daten für add in $file" >&2
fi
;;
"remove")
if "$SCRIPT_DIR/manage-tenant.sh" remove "$SLUG"; then
echo "[SUCCESS] Tenant gelöscht: $SLUG"
else
echo "[ERROR] Fehler beim Löschen von: $SLUG" >&2
fi
;;
"restart")
if "$SCRIPT_DIR/manage-tenant.sh" restart-tenant "$SLUG"; then
echo "[SUCCESS] Tenant neu gestartet: $SLUG"
else
echo "[ERROR] Fehler beim Neustart von: $SLUG" >&2
fi
;;
*)
echo "[ERROR] Unbekannte Aktion ($ACTION) in $file" >&2
;;
esac
# Nach Verarbeitung Datei restlos löschen
rm -f "$file"
done
sleep 2
done
}
start_tenant_watcher() {
local trigger_dir="$SCRIPT_DIR/triggers"
local pid_file="$SCRIPT_DIR/.watcher.pid"
local log_file="$SCRIPT_DIR/watcher.log"
# Erstelle Trigger-Verzeichnis
if [ ! -d "$trigger_dir" ]; then
mkdir -p "$trigger_dir"
chmod 777 "$trigger_dir"
fi
# Beende alten Watcher, falls dieser noch läuft
if [ -f "$pid_file" ]; then
local old_pid
old_pid=$(cat "$pid_file")
if kill -0 "$old_pid" 2>/dev/null; then
echo "Beende alten Watcher-Prozess (PID: $old_pid)..."
kill "$old_pid" || true
fi
fi
# Starte den Watcher im Hintergrund
echo "Starte asynchronen Tenant-Watcher im Hintergrund..."
watcher_loop "$trigger_dir" >> "$log_file" 2>&1 &
# Speichere die PID
echo $! > "$pid_file"
echo "Watcher läuft! Logs findest du in: $log_file"
}
# ============================================================================
export SESSION_COOKIE_SECURE="0" export SESSION_COOKIE_SECURE="0"
export INSTANCE_TLS_MODE="development" export INSTANCE_TLS_MODE="development"
export INSTANCE_PARENT_DOMAIN="${INSTANCE_PARENT_DOMAIN:-meine-domain}" export INSTANCE_PARENT_DOMAIN="${INSTANCE_PARENT_DOMAIN:-meine-domain}"
@@ -189,8 +294,8 @@ ensure_mongo_secrets
# Ensure required runtime files exist before building # Ensure required runtime files exist before building
if [ ! -f "$SCRIPT_DIR/gunicorn.conf.py" ]; then if [ ! -f "$SCRIPT_DIR/gunicorn.conf.py" ]; then
echo "[FEHLER] gunicorn.conf.py fehlt in $SCRIPT_DIR. Bitte prüfen." >&2 echo "[FEHLER] gunicorn.conf.py fehlt in $SCRIPT_DIR. Bitte prüfen." >&2
exit 2 exit 2
fi fi
resolve_docker_cmd resolve_docker_cmd
@@ -203,7 +308,7 @@ wait_for_mongo_ready
ensure_mongo_app_user ensure_mongo_app_user
compose up -d --no-deps website compose up -d --no-deps website
# Wait for website to become healthy (simple HTTP check) # Wait for website to become healthy
check_url="http://localhost:4999" check_url="http://localhost:4999"
timeout_seconds=60 timeout_seconds=60
interval=2 interval=2
@@ -211,22 +316,25 @@ elapsed=0
echo "Warte auf Website (${check_url}) bis ${timeout_seconds}s..." echo "Warte auf Website (${check_url}) bis ${timeout_seconds}s..."
while [ $elapsed -lt $timeout_seconds ]; do while [ $elapsed -lt $timeout_seconds ]; do
if curl -sS --max-time 2 "$check_url" >/dev/null 2>&1; then if curl -sS --max-time 2 "$check_url" >/dev/null 2>&1; then
echo "Website erreichbar nach ${elapsed}s" echo "Website erreichbar nach ${elapsed}s"
break break
fi fi
sleep $interval sleep $interval
elapsed=$((elapsed + interval)) elapsed=$((elapsed + interval))
done done
if [ $elapsed -ge $timeout_seconds ]; then if [ $elapsed -ge $timeout_seconds ]; then
echo "[FEHLER] Website nicht erreichbar nach ${timeout_seconds}s. Sammle Diagnosedaten..." echo "[FEHLER] Website nicht erreichbar nach ${timeout_seconds}s. Sammle Diagnosedaten..."
echo "--- docker compose ps ---" echo "--- docker compose ps ---"
compose ps || true compose ps || true
echo "--- docker logs website (tail 200) ---" echo "--- docker logs website (tail 200) ---"
"${DOCKER_CMD[@]}" logs --tail 200 website-website-1 || true "${DOCKER_CMD[@]}" logs --tail 200 website-website-1 || true
echo "Bitte prüfe Container-Logs und nginx-Konfiguration." echo "Bitte prüfe Container-Logs und nginx-Konfiguration."
fi fi
echo "Website stack is running on http://localhost:4999" echo "Website stack is running on http://localhost:4999"
echo "Provisioning creates self-signed certs per subdomain when needed." echo "Provisioning creates self-signed certs per subdomain when needed."
# STARTET DEN WATCHER DIREKT HIER IM SCRIPT
start_tenant_watcher
+162 -123
View File
@@ -139,6 +139,8 @@ INSTANCE_PROVISION_SCRIPT = os.environ.get(
os.path.abspath(os.path.join(BASE_DIR, "provision_instance.sh")), os.path.abspath(os.path.join(BASE_DIR, "provision_instance.sh")),
) )
TRIGGER_DIR = "/app/triggers"
def _env_int(name: str, default: int) -> int: def _env_int(name: str, default: int) -> int:
value = os.environ.get(name) value = os.environ.get(name)
@@ -2167,32 +2169,46 @@ def book_option_package():
return redirect(url_for("booking_payment", packages=packages_raw, flow=booking_flow)) return redirect(url_for("booking_payment", packages=packages_raw, flow=booking_flow))
def background_booking_provisioning(app_instance, prov_id, subdomain, modules_to_provision, user_id, school_name, user_email): def background_booking_provisioning(app_instance, prov_id, subdomain, modules_to_provision, user_id, school_name, user_email):
"""Hintergrund-Worker that actually deploys the instance using the correct API""" """Hintergrund-Worker, der die Tenant-Bereitstellung über das Host-Watcher-Triggersystem auslöst."""
with app_instance.app_context(): with app_instance.app_context():
req_client, req_col = _get_collection("instance_requests") req_client, req_col = _get_collection("instance_requests")
try: try:
# 1. Set Status to In Progress # 1. Status auf "in_progress" setzen
req_col.update_one({"_id": ObjectId(prov_id)}, {"$set": {"provision_status": "in_progress"}}) req_col.update_one({"_id": ObjectId(prov_id)}, {"$set": {"provision_status": "in_progress"}})
# 2. Dynamically determine next Port and Password # 2. Naechsten Port und Admin-Passwort ermitteln
existing_tenants = Instance.list() inst_client_check, inst_col_check = _get_collection("instances")
used_ports = [t['port'] for t in existing_tenants if 'port' in t] try:
existing_instances = list(inst_col_check.find({}, {"https_port": 1, "port": 1}))
used_ports = [
doc.get("https_port") or doc.get("port")
for doc in existing_instances
if doc.get("https_port") or doc.get("port")
]
finally:
inst_client_check.close()
next_port = max(used_ports) + 1 if used_ports else 10002 next_port = max(used_ports) + 1 if used_ports else 10002
admin_password = generate_secure_password() admin_password = generate_secure_password()
# 3. Create Tenant Shell # 3. Trigger-Datei fuer den Host-Watcher schreiben (ersetzt Instance.new & Instance.edit)
success = Instance.new(subdomain, next_port, admin_password) os.makedirs(TRIGGER_DIR, exist_ok=True)
if not success: trigger_file = os.path.join(TRIGGER_DIR, f"{subdomain}.json")
raise Exception("Tenant shell creation failed (Instance.new returned False)")
# 4. Enable selected Modules trigger_payload = {
if "starter" not in modules_to_provision: "request_id": str(prov_id),
Instance.edit(subdomain, "starter") "slug": subdomain,
"port": next_port,
"password": admin_password,
"modules": list(modules_to_provision)
}
with open(trigger_file, "w") as f:
json.dump(trigger_payload, f)
for mod in modules_to_provision: print(f"[INFO] Trigger-Datei fuer Host-Watcher erstellt: {trigger_file}")
Instance.edit(subdomain, mod)
# 4. Instanz-Eintrag in der Datenbank registrieren
# 5. Register Instance in Database
domain = f"{subdomain}.invario-software.de" domain = f"{subdomain}.invario-software.de"
instance_doc = { instance_doc = {
"owner_id": user_id, "owner_id": user_id,
@@ -2203,7 +2219,8 @@ def background_booking_provisioning(app_instance, prov_id, subdomain, modules_to
"status": "ready", "status": "ready",
"nginx_status": "active", "nginx_status": "active",
"admin_username": "admin", "admin_username": "admin",
"admin_password": admin_password "admin_password": admin_password,
"created_at": _utc_now_iso()
} }
inst_client, inst_col = _get_collection("instances") inst_client, inst_col = _get_collection("instances")
@@ -2212,7 +2229,7 @@ def background_booking_provisioning(app_instance, prov_id, subdomain, modules_to
finally: finally:
inst_client.close() inst_client.close()
# 6. Send Email Notification # 5. Bestaetigungs-E-Mail versenden
try: try:
if user_email: if user_email:
send_accreditation_email( send_accreditation_email(
@@ -2224,9 +2241,9 @@ def background_booking_provisioning(app_instance, prov_id, subdomain, modules_to
contract_pdf_path=f"/var/contracts/{subdomain}_contract.pdf" contract_pdf_path=f"/var/contracts/{subdomain}_contract.pdf"
) )
except Exception as mail_err: except Exception as mail_err:
print(f"[WARNING] Email sending failed: {mail_err}") print(f"[WARNING] E-Mail-Versand fehlgeschlagen: {mail_err}")
# 7. Mark Request as Ready for the Frontend # 6. Status in instance_requests auf "ready" setzen
req_col.update_one( req_col.update_one(
{"_id": ObjectId(prov_id)}, {"_id": ObjectId(prov_id)},
{"$set": { {"$set": {
@@ -2239,10 +2256,10 @@ def background_booking_provisioning(app_instance, prov_id, subdomain, modules_to
"updated_at": _utc_now_iso() "updated_at": _utc_now_iso()
}} }}
) )
print(f"[SUCCESS] Provisioning completed for {subdomain}") print(f"[SUCCESS] Provisioning-Trigger erfolgreich gesendet fuer {subdomain}")
except Exception as e: except Exception as e:
print(f"[ERROR] Async Provisioning failed for {subdomain}:") print(f"[ERROR] Async Provisioning fehlgeschlagen fuer {subdomain}:")
traceback.print_exc() traceback.print_exc()
req_col.update_one({"_id": ObjectId(prov_id)}, {"$set": {"provision_status": "failed", "last_error": str(e)}}) req_col.update_one({"_id": ObjectId(prov_id)}, {"$set": {"provision_status": "failed", "last_error": str(e)}})
finally: finally:
@@ -2372,22 +2389,23 @@ def booking_payment():
flash(f"Gesprächstermin für {selected_package} wurde an das Team gesendet.", "success") flash(f"Gesprächstermin für {selected_package} wurde an das Team gesendet.", "success")
return redirect(url_for("user_chat")) return redirect(url_for("user_chat"))
# FLOW 2: Kostenpflichtige Direkt-Buchung mit Provisioning # FLOW 2: Kostenpflichtige Direkt-Buchung mit Provisioning via Watcher
if booking_flow == "payment": if booking_flow == "payment":
booking_number = f"BOOK-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}" booking_number = f"BOOK-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}"
raw_base = form_data.get("school_name") or session.get("username") or booking_number raw_base = form_data.get("school_name") or session.get("username") or booking_number
base = _slugify_subdomain(raw_base).lower()[:51] base = _slugify_subdomain(raw_base).lower()[:51]
# Prüfung auf doppelte Subdomains via MongoDB 'instances' Collection
inst_check_client, inst_check_col = _get_collection("instances")
try: try:
existing_tenants = Instance.list() or [] existing_docs = list(inst_check_col.find({}, {"subdomain": 1}))
existing_subdomains = [ existing_subdomains = [d.get("subdomain") for d in existing_docs if d.get("subdomain")]
t.get("subdomain") or t.get("tenant_slug")
for t in existing_tenants if isinstance(t, dict)
]
except Exception as e: except Exception as e:
print(f"[WARNING] Konnte bestehende Instanzen nicht abfragen: {e}") print(f"[WARNING] Konnte bestehende Subdomains nicht prüfen: {e}")
existing_subdomains = [] existing_subdomains = []
finally:
inst_check_client.close()
if base in existing_subdomains: if base in existing_subdomains:
flash("Die gewünschte Subdomain (aus dem Schulnamen) existiert bereits. Bitte wähle einen anderen Schulnamen.", "error") flash("Die gewünschte Subdomain (aus dem Schulnamen) existiert bereits. Bitte wähle einen anderen Schulnamen.", "error")
@@ -2424,16 +2442,16 @@ def booking_payment():
modules_to_provision = {pkg for pkg in package_list if pkg in valid_modules} modules_to_provision = {pkg for pkg in package_list if pkg in valid_modules}
app_obj = current_app._get_current_object() app_obj = current_app._get_current_object()
user_id = session.get("user_id") user_id = session.get("user_id")
user_email = session.get("email") or form_data.get("contact_email") user_email = session.get("email") or form_data.get("contact_email")
# Startet den asynchronen Thread, der die Trigger-Datei ablegt
thread = threading.Thread( thread = threading.Thread(
target=background_booking_provisioning, target=background_booking_provisioning,
args=(app_obj, prov_id, subdomain, modules_to_provision, user_id, form_data["school_name"], user_email) args=(app_obj, prov_id, subdomain, modules_to_provision, user_id, form_data["school_name"], user_email)
) )
thread.start() thread.start()
print(f"[INFO] Background provisioning gestartet für Subdomain: {subdomain}") print(f"[INFO] Background Provisioning Trigger gestartet für Subdomain: {subdomain}")
flash(f"Buchung für {selected_package} wurde erfasst. Die Instanz wird jetzt eingerichtet.", "success") flash(f"Buchung für {selected_package} wurde erfasst. Die Instanz wird jetzt eingerichtet.", "success")
return redirect(url_for("my_instance_management")) return redirect(url_for("my_instance_management"))
@@ -3691,93 +3709,104 @@ def generate_secure_password(length=14):
def _utc_now_iso(): def _utc_now_iso():
return datetime.now(timezone.utc).isoformat() return datetime.now(timezone.utc).isoformat()
def run_tenant_provisioning_async(app, user_id, tenant_slug, school_name, user_email, prov_id): def _update_request_status(request_id, status, error_message=None):
with app.app_context(): """
# Hilfsfunktion zum Aktualisieren des Status Updates the status of a provisioning request in the database.
def _update_prov_status(status, extra_fields=None): """
req_client, req_col = _get_collection("instance_requests") client, col = _get_collection("instance_requests")
try: try:
update_data = { update_fields = {
"provision_status": status, "provision_status": status,
"updated_at": _utc_now_iso() "updated_at": datetime.utcnow()
} }
if extra_fields: if error_message:
update_data.update(extra_fields) update_fields["last_error"] = error_message
req_col.update_one(
{"_id": ObjectId(prov_id)},
{"$set": update_data}
)
finally:
req_client.close()
# 1. Status auf "in_progress" setzen
_update_prov_status("in_progress")
# GESAMTEN PROZESS IN TRY-EXCEPT PACKEN
try:
# 2. Port dynamisch ermitteln
existing_tenants = Instance.list()
used_ports = [t['port'] for t in existing_tenants if 'port' in t]
next_port = max(used_ports) + 1 if used_ports else 10002
admin_password = generate_secure_password()
# 3. Skript für Tenant-Erstellung ausführen
success = Instance.new(tenant_slug, next_port, admin_password)
if not success:
_update_prov_status("failed", {"last_error": "Tenant shell creation failed"})
return
# Module aktivieren
Instance.edit(tenant_slug, "starter")
# 4. Instanz in der Datenbank anlegen
domain = f"{tenant_slug}.invario-software.de"
instance_doc = {
"owner_id": user_id,
"school_name": school_name,
"subdomain": tenant_slug,
"domain": domain,
"https_port": next_port,
"status": "ready",
"nginx_status": "active",
"admin_username": "admin",
"admin_password": admin_password
}
inst_client, inst_col = _get_collection("instances") col.update_one(
try: {"_id": ObjectId(request_id)},
inst_result = inst_col.insert_one(instance_doc) {"$set": update_fields}
created_inst_id = str(inst_result.inserted_id) )
finally: except Exception as e:
inst_client.close() print(f"[ERROR] Failed to update request status: {e}")
finally:
client.close()
# 5. E-Mail versenden (isoliert, damit ein Mail-Fehler nicht die Instanz killt)
try:
send_accreditation_email(
recipient=user_email,
domain=domain,
username="admin",
password=admin_password,
# invoice_pdf_path=f"/var/invoices/{tenant_slug}_invoice.pdf", # Testweise auskommentieren
# contract_pdf_path=f"/var/contracts/{tenant_slug}_contract.pdf" # Testweise auskommentieren
)
except Exception as mail_error:
print(f"[WARNUNG] Mail konnte nicht gesendet werden: {mail_error}")
_update_prov_status("ready", { def _create_instance_record(tenant_slug, port, admin_password):
"instance_id": created_inst_id, """
"url": f"https://{domain}", Creates the final database record for the active instance so the
"username": "admin", frontend knows the domain, port, and credentials.
"password": admin_password """
}) client, col = _get_collection("instances")
try:
# Prevent duplicates if the watcher re-runs
existing = col.find_one({"subdomain": tenant_slug})
if existing:
return existing["_id"]
except Exception as e: # Fetch the base domain from env variables, fallback to localhost if missing
# Wenn irgendetwas im Block abstürzt, Frontend informieren! parent_domain = os.environ.get("INSTANCE_PARENT_DOMAIN", "localhost")
print(f"[ERROR] Provisioning fehlgeschlagen für {tenant_slug}:") full_domain = f"{tenant_slug}.{parent_domain}"
traceback.print_exc()
_update_prov_status("failed", {"last_error": str(e)}) new_instance = {
"subdomain": tenant_slug,
"domain": full_domain,
"port": int(port),
"admin_username": "admin",
"admin_password": admin_password,
"created_at": datetime.utcnow(),
"status": "active"
}
result = col.insert_one(new_instance)
return result.inserted_id
except Exception as e:
print(f"[ERROR] Failed to create instance record: {e}")
finally:
client.close()
def run_tenant_provisioning_async(tenant_slug, next_port, admin_password, user_email, request_id):
"""
Background thread running inside the Flask container.
Drops a trigger payload for the host watcher service.
"""
try:
# 1. Update DB to provisioning state
_update_request_status(request_id, "provisioning")
# 2. Write trigger file for the host watcher
os.makedirs(TRIGGER_DIR, exist_ok=True)
trigger_file = os.path.join(TRIGGER_DIR, f"{tenant_slug}.json")
payload = {
"request_id": str(request_id),
"slug": tenant_slug,
"port": next_port,
"password": admin_password
}
with open(trigger_file, "w") as f:
json.dump(payload, f)
# 3. Register instance record in MongoDB
_create_instance_record(tenant_slug, next_port, admin_password)
# 4. Optional: Send accreditation email safely
try:
send_accreditation_email(
recipient=user_email,
domain=f"{tenant_slug}.yourdomain.com",
username="admin",
password=admin_password
)
except Exception as mail_err:
print(f"[WARN] Mail delivery failed (non-fatal): {mail_err}")
# Mark request as ready
_update_request_status(request_id, "ready")
except Exception as e:
print(f"[ERROR] Tenant provisioning failed: {e}")
_update_request_status(request_id, "failed", error_message=str(e))
@app.route('/booking_payment_async', methods=['POST']) @app.route('/booking_payment_async', methods=['POST'])
@login_required @login_required
@@ -3823,8 +3852,11 @@ def booking_payment_async():
# WICHTIG: Fängt jegliche Python-Fehler ab und sendet sauberes JSON statt HTML-Crash # WICHTIG: Fängt jegliche Python-Fehler ab und sendet sauberes JSON statt HTML-Crash
return jsonify({"success": False, "error": str(e)}), 500 return jsonify({"success": False, "error": str(e)}), 500
@app.route('/instance_request/status') @app.route('/instance_request/status', methods=['GET'])
def tenant_status(): def tenant_status():
"""
Status endpoint checked by the frontend polling mechanism.
"""
slug = request.args.get('slug') slug = request.args.get('slug')
req_id = request.args.get('request_id') req_id = request.args.get('request_id')
@@ -3842,12 +3874,19 @@ def tenant_status():
finally: finally:
req_client.close() req_client.close()
current_status = prov_req.get("provision_status") if prov_req else None if not prov_req:
return jsonify({"status": "pending"}), 200
current_status = prov_req.get("provision_status")
# Fail immediately if background execution crashed
if current_status == "failed": if current_status == "failed":
return jsonify({"status": "failed", "error": prov_req.get("last_error", "Unbekannter Fehler")}), 200 return jsonify({
"status": "failed",
if not prov_req or current_status not in ["ready", "completed"]: "error": prov_req.get("last_error", "Tenant provisioning failed.")
}), 200
if current_status not in ["ready", "completed"]:
return jsonify({"status": "pending"}), 200 return jsonify({"status": "pending"}), 200
inst_client, inst_col = _get_collection("instances") inst_client, inst_col = _get_collection("instances")
@@ -3859,10 +3898,11 @@ def tenant_status():
if not instance: if not instance:
return jsonify({"status": "pending"}), 200 return jsonify({"status": "pending"}), 200
# Perform health check on newly provisioned container endpoint
target_url = f"https://{instance['domain']}/health" target_url = f"https://{instance['domain']}/health"
try: try:
response = requests.get(target_url, timeout=3) res = requests.get(target_url, timeout=3, verify=False)
if response.status_code == 200: if res.status_code == 200:
return jsonify({ return jsonify({
"status": "ready", "status": "ready",
"url": f"https://{instance['domain']}", "url": f"https://{instance['domain']}",
@@ -3873,7 +3913,6 @@ def tenant_status():
pass pass
return jsonify({"status": "pending"}), 200 return jsonify({"status": "pending"}), 200
def main(): def main():
app.run(host="0.0.0.0", port=4999, debug=False) app.run(host="0.0.0.0", port=4999, debug=False)