Add admin instance management and system tools

- Implemented sync_dev_hosts.sh for managing local hosts entries for development.
- Created admin_instances.html for managing school instances, including creation and listing.
- Developed admin_system.html for core services management, including restart and backup operations.
- Added my_instance.html for users to view and manage their assigned instances.
- Introduced provision_instance.sh for provisioning new instances with Docker, including Nginx configuration and SSL certificate handling.
This commit is contained in:
2026-04-14 22:12:14 +02:00
parent 5ad0522770
commit c4a483a57c
17 changed files with 2631 additions and 59 deletions
+84 -1
View File
@@ -91,4 +91,87 @@ Die Nginx-Vorlagen liegen unter [nginx](nginx):
- [nginx/gitea.http.conf.template](nginx/gitea.http.conf.template) - [nginx/gitea.http.conf.template](nginx/gitea.http.conf.template)
- [nginx/gitea.https.conf.template](nginx/gitea.https.conf.template) - [nginx/gitea.https.conf.template](nginx/gitea.https.conf.template)
- [nginx/website.http.conf.template](nginx/website.http.conf.template) - [nginx/website.http.conf.template](nginx/website.http.conf.template)
- [nginx/website.https.conf.template](nginx/website.https.conf.template) - [nginx/website.https.conf.template](nginx/website.https.conf.template)
## Admin: Schul-Instanzen fuer Subdomains
Die Website hat jetzt eine Admin-Seite zum Starten von Inventarsystem-Instanzen pro Schule:
- Route: `/admin/instances`
- Admin-Menue: `Schul-Instanzen`
- Ziel-Repository (Standard): `https://github.com/AIIrondev/legendary-octo-garbanzo`
### Ablauf
1. Admin gibt Schulname (und optional Subdomain) ein.
2. Admin weist die Instanz einem konkreten Nutzer zu.
3. Admin wählt eine Version (Image-Tag) pro Nutzer/Instanz.
4. Admin kann das Bibliotheksmodul für die Instanz aktivieren/deaktivieren.
5. Backend ruft `provision_instance.sh` auf.
6. Skript klont/aktualisiert die Instanz unter dem Basisverzeichnis.
7. Skript startet die Instanz (`start.sh --no-cron`) und versucht eine Nginx-Site fuer die Subdomain anzulegen.
8. Status wird in MongoDB (`school_instances`) gespeichert und im Admin-Fenster angezeigt.
Hinweis: Die Zuweisung ist auf `eine Instanz pro Nutzer` begrenzt.
### Wichtige Environment-Variablen (Website)
- `INSTANCE_REPO_URL` (default: `https://github.com/AIIrondev/legendary-octo-garbanzo`)
- `INSTANCE_PARENT_DOMAIN` (default: `meine-domain`)
- `INSTANCE_BASE_DIR` (default: `/opt/inventarsystem-instances`)
- `INSTANCE_PROVISION_SCRIPT` (default: `Website/provision_instance.sh`)
### Berechtigungen
Das Provisioning-Skript fuehrt Docker- und Nginx-Operationen aus. Der Prozess, der die Website ausfuehrt, braucht daher:
- Zugriff auf `docker` (Docker Socket / Gruppe)
- Schreibrechte auf `/etc/nginx/sites-available` und `/etc/nginx/sites-enabled`
- Recht zum `nginx -t` und `nginx -s reload`
Wenn diese Rechte fehlen, wird die Instanz ggf. trotzdem gestartet, aber die Nginx-Verknuepfung muss manuell erfolgen.
### Nutzerfenster: Eigene Instanz verwalten
Eingeloggte Nutzer haben im Nutzerbereich jetzt den Punkt `Meine Instanz`.
- Route: `/my/instance`
- Funktion: Nur zugewiesene Instanz einsehen und nutzen (read-only)
- Zuordnung: ueber `owner_username`
## Admin: System-Tools
Neue Admin-Seite fuer Betriebsaufgaben:
- Route: `/admin/system`
- Funktionen:
- Core-Services neu starten (`website`, `mongodb`)
- Core-Logs als Datei herunterladen
- Pro Instanz: Backup, Update, Restart
- Pro Instanz: Logs als Datei herunterladen
## Betriebsmodi: Development und Production
Im Ordner `Website/` gibt es zwei Launcher-Skripte:
- `./launch_dev.sh`
- setzt `INSTANCE_TLS_MODE=development`
- erzeugt pro Subdomain self-signed Zertifikate (wenn moeglich)
- setzt `SESSION_COOKIE_SECURE=0`
- `./launch_prod.sh`
- setzt `INSTANCE_TLS_MODE=production`
- nutzt Wildcard-Zertifikat ueber:
- `INSTANCE_WILDCARD_CERT_FILE`
- `INSTANCE_WILDCARD_KEY_FILE`
- setzt `SESSION_COOKIE_SECURE=1`
Beispiel Production-Start mit eigener Domain:
```bash
cd Website
export INSTANCE_PARENT_DOMAIN=example.de
export INSTANCE_WILDCARD_CERT_FILE=/etc/nginx/certs/wildcard.example.de.crt
export INSTANCE_WILDCARD_KEY_FILE=/etc/nginx/certs/wildcard.example.de.key
./launch_prod.sh
```
+28 -34
View File
@@ -1,49 +1,43 @@
FROM python:3.12-slim AS builder FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1
WORKDIR /app WORKDIR /app
ARG NUITKA_JOBS=1
ARG NUITKA_LOW_MEMORY=1
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential patchelf \ && apt-get install -y --no-install-recommends \
git \
iproute2 \
docker.io \
curl \
openssl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN set -eu; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) compose_arch="x86_64" ;; \
arm64) compose_arch="aarch64" ;; \
*) echo "Unsupported architecture for compose plugin: $arch"; exit 1 ;; \
esac; \
curl -fsSL "https://download.docker.com/linux/static/stable/${compose_arch}/docker-27.1.1.tgz" \
-o /tmp/docker.tgz; \
tar -xzf /tmp/docker.tgz -C /tmp; \
install -m 0755 /tmp/docker/docker /usr/local/bin/docker; \
rm -rf /tmp/docker /tmp/docker.tgz; \
mkdir -p /usr/libexec/docker/cli-plugins; \
curl -fsSL "https://github.com/docker/compose/releases/download/v2.29.7/docker-compose-linux-${compose_arch}" \
-o /usr/libexec/docker/cli-plugins/docker-compose; \
chmod +x /usr/libexec/docker/cli-plugins/docker-compose
COPY requirements.txt ./ COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt \ RUN pip install --no-cache-dir -r requirements.txt
&& pip install --no-cache-dir nuitka ordered-set zstandard
COPY . . COPY . .
RUN chmod +x /app/provision_instance.sh
# Build a standalone binary with all required templates/static/data assets.
# Defaults are tuned for lower RAM pressure to avoid host crashes.
RUN set -eu; \
EXTRA_FLAGS=""; \
if [ "$NUITKA_LOW_MEMORY" = "1" ]; then \
EXTRA_FLAGS="--low-memory --lto=no"; \
fi; \
python -m nuitka \
--standalone \
--follow-imports \
--assume-yes-for-downloads \
--jobs="$NUITKA_JOBS" \
--remove-output \
--include-data-dir=templates=templates \
--include-data-dir=static=static \
--include-data-dir=data=data \
--output-dir=build \
$EXTRA_FLAGS \
main.py
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=builder /app/build/main.dist ./
EXPOSE 4999 EXPOSE 4999
CMD ["./main.bin"] CMD ["python", "main.py"]
-9
View File
@@ -1,9 +0,0 @@
[
{
"username": "Aiirondev",
"display_name": "Maximilian Gr\u00fcndinger",
"password_hash": "scrypt:32768:8:1$nfN2TTigH6GNrZig$548ac55b59b2c10f2730a4a45b4140c84eba33e2719f95175c05ab1f3a157959ff7a5d11c3d38bd2ea172b0143fa3a0e0e410b8bfcc81b8d91cfbbfc12b30399",
"is_admin": true,
"created_at": "2026-03-23T16:05:42Z"
}
]
+14 -4
View File
@@ -16,9 +16,6 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
NUITKA_JOBS: ${NUITKA_JOBS:-1}
NUITKA_LOW_MEMORY: ${NUITKA_LOW_MEMORY:-1}
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
mongodb: mongodb:
@@ -26,8 +23,21 @@ services:
environment: environment:
MONGO_URI: mongodb://mongodb:27017 MONGO_URI: mongodb://mongodb:27017
MONGO_DB_NAME: Invario_Website MONGO_DB_NAME: Invario_Website
SESSION_COOKIE_SECURE: "0" SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-0}
JWT_SECRET_KEY: change-this-in-production JWT_SECRET_KEY: change-this-in-production
INSTANCE_PROVISION_SCRIPT: /app/provision_instance.sh
INSTANCE_BASE_DIR: /opt/inventarsystem-instances
INSTANCE_PARENT_DOMAIN: ${INSTANCE_PARENT_DOMAIN:-meine-domain}
INSTANCE_REPO_URL: ${INSTANCE_REPO_URL:-https://github.com/AIIrondev/legendary-octo-garbanzo}
INSTANCE_TLS_MODE: ${INSTANCE_TLS_MODE:-development}
INSTANCE_WILDCARD_CERT_FILE: ${INSTANCE_WILDCARD_CERT_FILE:-/etc/nginx/certs/wildcard.meine-domain.crt}
INSTANCE_WILDCARD_KEY_FILE: ${INSTANCE_WILDCARD_KEY_FILE:-/etc/nginx/certs/wildcard.meine-domain.key}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/inventarsystem-instances:/opt/inventarsystem-instances
- /etc/nginx/sites-available:/etc/nginx/sites-available
- /etc/nginx/sites-enabled:/etc/nginx/sites-enabled
- /etc/nginx/certs:/etc/nginx/certs
ports: ports:
- "4999:4999" - "4999:4999"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
export SESSION_COOKIE_SECURE="0"
export INSTANCE_TLS_MODE="development"
export INSTANCE_PARENT_DOMAIN="${INSTANCE_PARENT_DOMAIN:-meine-domain}"
echo "Launching DEVELOPMENT stack"
echo " SESSION_COOKIE_SECURE=$SESSION_COOKIE_SECURE"
echo " INSTANCE_TLS_MODE=$INSTANCE_TLS_MODE"
echo " INSTANCE_PARENT_DOMAIN=$INSTANCE_PARENT_DOMAIN"
docker compose build website
docker compose up -d
if [[ -x "$SCRIPT_DIR/sync_dev_hosts.sh" ]]; then
"$SCRIPT_DIR/sync_dev_hosts.sh" || true
fi
echo "Development stack is running on http://localhost:4999"
echo "Provisioning creates self-signed certs per subdomain when needed."
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
export SESSION_COOKIE_SECURE="1"
export INSTANCE_TLS_MODE="production"
export INSTANCE_PARENT_DOMAIN="${INSTANCE_PARENT_DOMAIN:-meine-domain}"
export INSTANCE_WILDCARD_CERT_FILE="${INSTANCE_WILDCARD_CERT_FILE:-/etc/nginx/certs/wildcard.meine-domain.crt}"
export INSTANCE_WILDCARD_KEY_FILE="${INSTANCE_WILDCARD_KEY_FILE:-/etc/nginx/certs/wildcard.meine-domain.key}"
if [[ ! -f "$INSTANCE_WILDCARD_CERT_FILE" || ! -f "$INSTANCE_WILDCARD_KEY_FILE" ]]; then
echo "Error: Wildcard certificate missing for production mode"
echo " CERT: $INSTANCE_WILDCARD_CERT_FILE"
echo " KEY: $INSTANCE_WILDCARD_KEY_FILE"
exit 1
fi
echo "Launching PRODUCTION stack"
echo " SESSION_COOKIE_SECURE=$SESSION_COOKIE_SECURE"
echo " INSTANCE_TLS_MODE=$INSTANCE_TLS_MODE"
echo " INSTANCE_PARENT_DOMAIN=$INSTANCE_PARENT_DOMAIN"
echo " INSTANCE_WILDCARD_CERT_FILE=$INSTANCE_WILDCARD_CERT_FILE"
echo " INSTANCE_WILDCARD_KEY_FILE=$INSTANCE_WILDCARD_KEY_FILE"
docker compose build website
docker compose up -d
echo "Production stack is running on http://localhost:4999"
echo "Provisioning expects wildcard certificate for all subdomains."
+939 -1
View File
@@ -3,6 +3,10 @@ from flask import Flask, render_template, request, jsonify, flash, redirect, url
import os import os
import json import json
import calendar import calendar
import re
import subprocess
import hashlib
import shutil
from datetime import timedelta, datetime, date from datetime import timedelta, datetime, date
from functools import wraps from functools import wraps
from io import BytesIO from io import BytesIO
@@ -43,6 +47,23 @@ INVOICE_UPLOAD_DIR = os.path.join(BASE_DIR, "static", "uploads", "invoices")
TEAM_UPLOAD_DIR = os.path.join(BASE_DIR, "static", "uploads", "team") TEAM_UPLOAD_DIR = os.path.join(BASE_DIR, "static", "uploads", "team")
MONGO_URI = os.environ.get("MONGO_URI", "mongodb://localhost:27017") MONGO_URI = os.environ.get("MONGO_URI", "mongodb://localhost:27017")
MONGO_DB_NAME = os.environ.get("MONGO_DB_NAME", "Invario_Website") MONGO_DB_NAME = os.environ.get("MONGO_DB_NAME", "Invario_Website")
INSTANCE_REPO_URL = os.environ.get("INSTANCE_REPO_URL", "https://github.com/AIIrondev/legendary-octo-garbanzo")
INSTANCE_PARENT_DOMAIN = os.environ.get("INSTANCE_PARENT_DOMAIN", "meine-domain")
INSTANCE_BASE_DIR = os.environ.get("INSTANCE_BASE_DIR", "/opt/inventarsystem-instances")
INSTANCE_TLS_MODE = os.environ.get("INSTANCE_TLS_MODE", "development")
INSTANCE_VERSION_OPTIONS = os.environ.get("INSTANCE_VERSION_OPTIONS", "latest,v0.3.1")
INSTANCE_WILDCARD_CERT_FILE = os.environ.get(
"INSTANCE_WILDCARD_CERT_FILE",
"/etc/nginx/certs/wildcard.meine-domain.crt",
)
INSTANCE_WILDCARD_KEY_FILE = os.environ.get(
"INSTANCE_WILDCARD_KEY_FILE",
"/etc/nginx/certs/wildcard.meine-domain.key",
)
INSTANCE_PROVISION_SCRIPT = os.environ.get(
"INSTANCE_PROVISION_SCRIPT",
os.path.abspath(os.path.join(BASE_DIR, "provision_instance.sh")),
)
def _issue_access_token() -> str: def _issue_access_token() -> str:
@@ -137,6 +158,571 @@ def _sanitize_text(text: str, max_length: int = 255) -> str:
return text return text
def _slugify_subdomain(value: str) -> str:
cleaned = (value or "").strip().lower()
cleaned = cleaned.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
cleaned = re.sub(r"[^a-z0-9-]+", "-", cleaned)
cleaned = re.sub(r"-{2,}", "-", cleaned).strip("-")
return cleaned[:63]
def _is_valid_subdomain(value: str) -> bool:
if not value or len(value) < 3 or len(value) > 63:
return False
return bool(re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])", value))
def _parse_key_value_output(raw: str) -> dict:
parsed = {}
for line in (raw or "").splitlines():
row = line.strip()
if not row or "=" not in row:
continue
key, value = row.split("=", 1)
parsed[key.strip()] = value.strip()
return parsed
def _parse_instance_version_options() -> list[str]:
values = []
for item in (INSTANCE_VERSION_OPTIONS or "").split(","):
token = _sanitize_text(item or "", 80)
if token and token not in values:
values.append(token)
if not values:
values = ["latest"]
return values
def _list_school_instances() -> list:
rows = []
client = None
try:
client, col = _get_collection("school_instances")
rows = list(col.find().sort([("updated_at", -1), ("created_at", -1)]))
except PyMongoError:
return []
finally:
if client:
client.close()
normalized = []
for row in rows:
normalized.append(
{
"id": str(row.get("_id") or ""),
"school_name": _sanitize_text(row.get("school_name") or "", 120),
"owner_username": _sanitize_text(row.get("owner_username") or "", 80),
"subdomain": _sanitize_text(row.get("subdomain") or "", 63),
"domain": _sanitize_text(row.get("domain") or "", 190),
"https_port": int(row.get("https_port") or 0),
"instance_dir": _sanitize_text(row.get("instance_dir") or "", 300),
"app_image_tag": _sanitize_text(row.get("app_image_tag") or "latest", 80),
"library_enabled": bool(row.get("library_enabled", False)),
"status": _sanitize_text(row.get("status") or "Unbekannt", 40),
"nginx_status": _sanitize_text(row.get("nginx_status") or "unbekannt", 80),
"last_message": _sanitize_text(row.get("last_message") or "", 500),
"updated_at": row.get("updated_at") or "",
}
)
return normalized
def _list_available_instance_users() -> list:
users = _list_users_for_admin()
instances = _list_school_instances()
assigned = {
_sanitize_text(item.get("owner_username") or "", 80).lower()
for item in instances
if _sanitize_text(item.get("owner_username") or "", 80)
}
return [u for u in users if _sanitize_text(u.get("username") or "", 80).lower() not in assigned]
def _list_instances_grouped_by_owner() -> dict[str, list[dict]]:
grouped: dict[str, list[dict]] = {}
for item in _list_school_instances():
owner = _sanitize_text(item.get("owner_username") or "", 80)
domain = _sanitize_text(item.get("domain") or "", 190)
subdomain = _sanitize_text(item.get("subdomain") or "", 63)
if not owner or not domain:
continue
grouped.setdefault(owner, []).append(
{
"domain": domain,
"subdomain": subdomain,
"status": _sanitize_text(item.get("status") or "", 40),
}
)
return grouped
def _upsert_school_instance(data: dict) -> None:
subdomain = _sanitize_text(data.get("subdomain") or "", 63)
if not subdomain:
return
payload = {
"school_name": _sanitize_text(data.get("school_name") or "", 120),
"owner_username": _sanitize_text(data.get("owner_username") or "", 80),
"subdomain": subdomain,
"domain": _sanitize_text(data.get("domain") or "", 190),
"https_port": int(data.get("https_port") or 0),
"instance_dir": _sanitize_text(data.get("instance_dir") or "", 300),
"app_image_tag": _sanitize_text(data.get("app_image_tag") or "latest", 80),
"library_enabled": bool(data.get("library_enabled", False)),
"status": _sanitize_text(data.get("status") or "Unbekannt", 40),
"nginx_status": _sanitize_text(data.get("nginx_status") or "unbekannt", 80),
"last_message": _sanitize_text(data.get("last_message") or "", 500),
"updated_at": _utc_now_iso(),
}
client = None
try:
client, col = _get_collection("school_instances")
col.update_one(
{"subdomain": subdomain},
{"$set": payload, "$setOnInsert": {"created_at": _utc_now_iso()}},
upsert=True,
)
except PyMongoError:
return
finally:
if client:
client.close()
def _run_instance_provision(
action: str,
school_name: str,
subdomain: str,
app_image_tag: str = "latest",
library_enabled: bool = False,
) -> tuple[bool, str, dict]:
script_path = (INSTANCE_PROVISION_SCRIPT or "").strip()
if not script_path:
return False, "INSTANCE_PROVISION_SCRIPT ist nicht gesetzt.", {}
if not os.path.isfile(script_path):
return False, f"Provisioning-Skript nicht gefunden: {script_path}", {}
if not os.access(script_path, os.X_OK):
return False, f"Provisioning-Skript ist nicht ausführbar: {script_path}", {}
command = [
script_path,
"--action",
action,
"--repo",
INSTANCE_REPO_URL,
"--base-dir",
INSTANCE_BASE_DIR,
"--domain",
INSTANCE_PARENT_DOMAIN,
"--tls-mode",
INSTANCE_TLS_MODE,
"--wildcard-cert-file",
INSTANCE_WILDCARD_CERT_FILE,
"--wildcard-key-file",
INSTANCE_WILDCARD_KEY_FILE,
"--app-image-tag",
_sanitize_text(app_image_tag or "latest", 80),
"--library-enabled",
"1" if library_enabled else "0",
"--school-name",
school_name,
"--subdomain",
subdomain,
]
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=900,
check=False,
env={**os.environ, "LC_ALL": "C.UTF-8"},
)
except subprocess.TimeoutExpired:
return False, "Provisioning-Zeitlimit erreicht (15 Minuten).", {}
except Exception as exc:
return False, f"Provisioning konnte nicht gestartet werden: {exc}", {}
output = _parse_key_value_output(result.stdout)
message = output.get("MESSAGE") or output.get("ERROR") or (result.stderr or "").strip()
if result.returncode != 0:
if not message:
message = "Provisioning fehlgeschlagen. Details im Server-Log prüfen."
return False, message, output
return True, message or "Instanz erfolgreich gestartet.", output
def _instance_dir_path(subdomain: str) -> str | None:
key = _sanitize_text(subdomain or "", 63)
if not _is_valid_subdomain(key):
return None
target = os.path.abspath(os.path.join(INSTANCE_BASE_DIR, key))
base_abs = os.path.abspath(INSTANCE_BASE_DIR)
if not target.startswith(base_abs + os.sep):
return None
return target
def _resolve_instance_dir(subdomain: str) -> str | None:
key = _sanitize_text(subdomain or "", 63)
if not _is_valid_subdomain(key):
return None
target = os.path.abspath(os.path.join(INSTANCE_BASE_DIR, key))
base_abs = os.path.abspath(INSTANCE_BASE_DIR)
if not target.startswith(base_abs + os.sep):
return None
if not os.path.isdir(target):
return None
return target
def _run_command(command: list[str], cwd: str | None = None, timeout: int = 900) -> tuple[bool, str]:
try:
result = subprocess.run(
command,
cwd=cwd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
env={**os.environ, "LC_ALL": "C.UTF-8"},
)
except subprocess.TimeoutExpired:
return False, "Befehl hat das Zeitlimit erreicht."
except Exception as exc:
return False, f"Befehl konnte nicht ausgeführt werden: {exc}"
output = ((result.stdout or "") + "\n" + (result.stderr or "")).strip()
if result.returncode != 0:
return False, output or "Befehl ist fehlgeschlagen."
return True, output or "OK"
def _tail_output(text: str, lines: int = 24) -> str:
rows = [line for line in (text or "").splitlines() if line.strip()]
if not rows:
return "Keine Ausgabe"
return "\n".join(rows[-lines:])
def _collect_core_logs() -> tuple[bool, str]:
compose_file = os.path.join(BASE_DIR, "docker-compose.yml")
command = [
"docker",
"compose",
"-f",
compose_file,
"logs",
"--no-color",
"--tail",
"500",
"website",
"mongodb",
]
return _run_command(command, cwd=BASE_DIR, timeout=180)
def _collect_instance_logs(subdomain: str) -> tuple[bool, str]:
instance_dir = _resolve_instance_dir(subdomain)
if not instance_dir:
return False, "Instanzverzeichnis nicht gefunden."
command = [
"docker",
"compose",
"--env-file",
".docker-build.env",
"logs",
"--no-color",
"--tail",
"500",
]
return _run_command(command, cwd=instance_dir, timeout=180)
def _set_instance_library_enabled(instance_dir: str, enabled: bool) -> tuple[bool, str]:
config_path = os.path.join(instance_dir, "config.json")
if not os.path.isfile(config_path):
return False, "config.json nicht gefunden."
try:
with open(config_path, "r", encoding="utf-8") as handle:
config = json.load(handle)
except Exception as exc:
return False, f"config.json konnte nicht gelesen werden: {exc}"
modules = config.get("modules")
if not isinstance(modules, dict):
modules = {}
config["modules"] = modules
library = modules.get("library")
if not isinstance(library, dict):
library = {}
modules["library"] = library
library["enabled"] = bool(enabled)
try:
with open(config_path, "w", encoding="utf-8") as handle:
json.dump(config, handle, indent=2, ensure_ascii=False)
handle.write("\n")
except Exception as exc:
return False, f"config.json konnte nicht geschrieben werden: {exc}"
return True, "Bibliothekseinstellung gespeichert."
def _restart_instance_stack(instance_dir: str) -> tuple[bool, str]:
restart_cmd = [
"docker",
"compose",
"--env-file",
".docker-build.env",
"restart",
"app",
"nginx",
"mongodb",
]
up_cmd = [
"docker",
"compose",
"--env-file",
".docker-build.env",
"up",
"-d",
"--remove-orphans",
]
# restart may fail for stopped services; ensure desired state with up -d afterwards.
_run_command(restart_cmd, cwd=instance_dir, timeout=420)
ok, output = _run_command(up_cmd, cwd=instance_dir, timeout=900)
if ok:
return True, output
# Missing app image is a common restart failure after tag changes.
if "local app image not found" in (output or "").lower() and os.path.isfile(os.path.join(instance_dir, "update.sh")):
upd_ok, upd_out = _run_command(["bash", "./update.sh"], cwd=instance_dir, timeout=2400)
if upd_ok:
ok2, out2 = _run_command(up_cmd, cwd=instance_dir, timeout=900)
if ok2:
return True, f"{upd_out}\n{out2}".strip()
return False, out2
return False, upd_out
return False, output
def _delete_instance_stack(subdomain: str) -> tuple[bool, str]:
target_dir = _instance_dir_path(subdomain)
if not target_dir:
return False, "Ungültige Subdomain für Löschung."
details: list[str] = []
if os.path.isdir(target_dir):
compose_file = os.path.join(target_dir, "docker-compose.yml")
if os.path.isfile(compose_file):
down_cmd = [
"docker",
"compose",
"--env-file",
".docker-build.env",
"down",
"--remove-orphans",
"--volumes",
"--timeout",
"40",
]
down_ok, down_out = _run_command(down_cmd, cwd=target_dir, timeout=900)
if not down_ok:
return False, f"Docker-Stack konnte nicht gestoppt werden.\n{_tail_output(down_out, 12)}"
details.append("Docker-Stack gestoppt und Volumes entfernt.")
try:
shutil.rmtree(target_dir)
details.append("Instanzverzeichnis gelöscht.")
except Exception as exc:
return False, f"Instanzverzeichnis konnte nicht gelöscht werden: {exc}"
else:
details.append("Instanzverzeichnis war bereits entfernt.")
nginx_sites_available = "/etc/nginx/sites-available"
nginx_sites_enabled = "/etc/nginx/sites-enabled"
site_name = f"inventarsystem-{subdomain}.conf"
avail_file = os.path.join(nginx_sites_available, site_name)
enabled_file = os.path.join(nginx_sites_enabled, site_name)
removed_nginx_file = False
for path in (enabled_file, avail_file):
try:
if os.path.lexists(path):
os.remove(path)
removed_nginx_file = True
except Exception:
continue
if removed_nginx_file:
if os.path.isfile("/usr/sbin/nginx") or os.path.isfile("/usr/bin/nginx"):
test_ok, _ = _run_command(["nginx", "-t"], timeout=60)
if test_ok:
_run_command(["nginx", "-s", "reload"], timeout=60)
details.append("Nginx-Site entfernt.")
return True, " ".join(details) if details else "Instanz gelöscht."
def _delete_school_instance(subdomain: str) -> bool:
key = _sanitize_text(subdomain or "", 63)
if not key:
return False
client = None
try:
client, col = _get_collection("school_instances")
result = col.delete_one({"subdomain": key})
return result.deleted_count > 0
except PyMongoError:
return False
finally:
if client:
client.close()
def _instance_db_name(instance_dir: str) -> str:
config_path = os.path.join(instance_dir, "config.json")
try:
with open(config_path, "r", encoding="utf-8") as handle:
cfg = json.load(handle)
value = ((cfg or {}).get("mongodb") or {}).get("db")
if isinstance(value, str) and value.strip():
return value.strip()
except Exception:
pass
return "Inventarsystem"
def _create_instance_admin_user(
subdomain: str,
username: str,
password: str,
first_name: str,
last_name: str,
) -> tuple[bool, str]:
instance_dir = _resolve_instance_dir(subdomain)
if not instance_dir:
return False, "Instanzverzeichnis nicht gefunden."
user_name = _sanitize_text(username, 80)
pwd = (password or "").strip()
first = _sanitize_text(first_name or "Admin", 80) or "Admin"
last = _sanitize_text(last_name or "User", 80) or "User"
if not _validate_username(user_name):
return False, "Ungültiger Benutzername für Instanz-Admin."
if len(pwd) < 8:
return False, "Passwort muss mindestens 8 Zeichen haben."
pwd_hash = hashlib.sha512(pwd.encode("utf-8")).hexdigest()
db_name = _instance_db_name(instance_dir)
js_user = json.dumps(user_name)
js_hash = json.dumps(pwd_hash)
js_first = json.dumps(first)
js_last = json.dumps(last)
eval_script = (
"const username=" + js_user + ";"
"const hash=" + js_hash + ";"
"const first=" + js_first + ";"
"const last=" + js_last + ";"
"const existing=db.users.findOne({Username:username});"
"if(existing){"
"db.users.updateOne({Username:username},{$set:{Password:hash,Admin:true,name:first,last_name:last,updated_at:new Date().toISOString()}});"
"print('UPDATED');"
"}else{"
"db.users.insertOne({Username:username,Password:hash,Admin:true,active_ausleihung:null,name:first,last_name:last,favorites:[]});"
"print('CREATED');"
"}"
)
up_ok, up_out = _run_command(
["docker", "compose", "--env-file", ".docker-build.env", "up", "-d", "mongodb"],
cwd=instance_dir,
timeout=180,
)
if not up_ok:
return False, f"MongoDB der Instanz konnte nicht gestartet werden: {_tail_output(up_out, 10)}"
ok, output = _run_command(
[
"docker",
"compose",
"--env-file",
".docker-build.env",
"exec",
"-T",
"mongodb",
"mongosh",
"--quiet",
"--eval",
eval_script,
db_name,
],
cwd=instance_dir,
timeout=240,
)
if not ok:
return False, f"Instanz-Admin konnte nicht angelegt werden: {_tail_output(output, 12)}"
state = "aktualisiert" if "UPDATED" in output else "erstellt"
return True, f"Instanz-Admin '{user_name}' wurde {state}."
def _get_school_instance_by_subdomain(subdomain: str) -> dict | None:
key = _sanitize_text(subdomain or "", 63)
if not key:
return None
client = None
try:
client, col = _get_collection("school_instances")
return col.find_one({"subdomain": key})
except PyMongoError:
return None
finally:
if client:
client.close()
def _get_instance_for_user(username: str, display_name: str) -> dict | None:
uname = _sanitize_text(username or "", 80)
if not uname:
return None
client = None
try:
client, col = _get_collection("school_instances")
return col.find_one({"owner_username": uname}, sort=[("updated_at", -1), ("created_at", -1)])
except PyMongoError:
return None
finally:
if client:
client.close()
def _activate_test_license_for_user(username: str, school_name: str, package_name: str = "Normal") -> tuple[bool, str]: def _activate_test_license_for_user(username: str, school_name: str, package_name: str = "Normal") -> tuple[bool, str]:
"""Create a one-time test license for the user if none exists yet.""" """Create a one-time test license for the user if none exists yet."""
user_name = _sanitize_text(username or "", 80) user_name = _sanitize_text(username or "", 80)
@@ -697,6 +1283,319 @@ def admin_dashboard():
) )
@app.route('/admin/instances', methods=['GET', 'POST'])
@admin_required
def admin_instances():
version_options = _parse_instance_version_options()
available_users = _list_available_instance_users()
if request.method == 'POST':
action = _sanitize_text(request.form.get("action") or "create", 20).lower()
posted_subdomain = _sanitize_text(request.form.get("subdomain") or "", 120)
school_name = _sanitize_text(request.form.get("school_name") or "", 120)
raw_subdomain = _sanitize_text(request.form.get("subdomain") or "", 120)
owner_username = _sanitize_text(request.form.get("owner_username") or "", 80)
app_image_tag = _sanitize_text(request.form.get("app_image_tag") or "latest", 80)
library_enabled = (request.form.get("library_enabled") or "").strip().lower() in {"1", "on", "true", "yes"}
subdomain = _slugify_subdomain(posted_subdomain or raw_subdomain or school_name)
if action == "delete":
if not _is_valid_subdomain(subdomain):
flash("Ungültige Subdomain für Löschung.", "error")
return redirect(url_for("admin_instances"))
existing_record = _get_school_instance_by_subdomain(subdomain)
target_dir = _instance_dir_path(subdomain)
dir_exists = bool(target_dir and os.path.isdir(target_dir))
if not existing_record and not dir_exists:
flash("Instanz nicht gefunden.", "error")
return redirect(url_for("admin_instances"))
delete_ok, delete_message = _delete_instance_stack(subdomain)
if not delete_ok:
flash(delete_message or "Instanz konnte nicht gelöscht werden.", "error")
return redirect(url_for("admin_instances"))
if existing_record and not _delete_school_instance(subdomain):
flash(
"Instanz wurde technisch gelöscht, aber der Datenbankeintrag konnte nicht entfernt werden.",
"error",
)
return redirect(url_for("admin_instances"))
flash(f"Instanz {subdomain} wurde gelöscht.", "success")
if delete_message:
flash(delete_message, "info")
return redirect(url_for("admin_instances"))
if action == "toggle_library":
target = _get_school_instance_by_subdomain(subdomain)
if not target:
flash("Instanz nicht gefunden.", "error")
return redirect(url_for("admin_instances"))
instance_dir = _resolve_instance_dir(subdomain)
if not instance_dir:
flash("Instanzverzeichnis nicht gefunden.", "error")
return redirect(url_for("admin_instances"))
target_enabled = (request.form.get("library_enabled") or "").strip().lower() in {"1", "on", "true", "yes"}
ok, message = _set_instance_library_enabled(instance_dir, target_enabled)
if not ok:
_upsert_school_instance(
{
"subdomain": subdomain,
"status": "Fehler",
"nginx_status": "error",
"last_message": message,
}
)
flash(message, "error")
return redirect(url_for("admin_instances"))
restart_ok, restart_output = _restart_instance_stack(instance_dir)
client = None
try:
client, col = _get_collection("school_instances")
col.update_one(
{"subdomain": subdomain},
{
"$set": {
"school_name": target.get("school_name") or "",
"owner_username": target.get("owner_username") or "",
"subdomain": subdomain,
"domain": target.get("domain") or f"{subdomain}.{INSTANCE_PARENT_DOMAIN}",
"https_port": int(target.get("https_port") or 0),
"instance_dir": instance_dir,
"app_image_tag": target.get("app_image_tag") or "latest",
"library_enabled": target_enabled,
"status": "Läuft" if restart_ok else "Fehler",
"nginx_status": "ok" if restart_ok else "error",
"last_message": "Bibliothek aktiviert/deaktiviert und Instanz neu gestartet." if restart_ok else _tail_output(restart_output, 18),
"updated_at": _utc_now_iso(),
},
"$setOnInsert": {"created_at": _utc_now_iso()},
},
upsert=True,
)
except PyMongoError:
pass
finally:
if client:
client.close()
if restart_ok:
flash(f"Bibliothek wurde {'aktiviert' if target_enabled else 'deaktiviert'} und die Instanz neu gestartet.", "success")
if restart_output:
flash(_tail_output(restart_output, 12), "info")
else:
flash(f"Bibliothek wurde gespeichert, aber der Neustart ist fehlgeschlagen.\n{_tail_output(restart_output, 18)}", "error")
return redirect(url_for("admin_instances"))
if action not in {"create", "start"}:
flash("Ungültige Aktion für Instanzverwaltung.", "error")
return redirect(url_for("admin_instances"))
if not available_users:
flash("Keine freien Nutzer verfügbar. Bitte zuerst einen neuen Nutzer anlegen oder eine bestehende Instanz löschen.", "error")
return redirect(url_for("admin_instances"))
if not school_name:
flash("Bitte einen Schulnamen angeben.", "error")
return redirect(url_for("admin_instances"))
if not owner_username:
flash("Bitte einen Nutzer zuweisen.", "error")
return redirect(url_for("admin_instances"))
owner_doc = _find_user(owner_username)
if not owner_doc:
flash("Ausgewählter Nutzer wurde nicht gefunden.", "error")
return redirect(url_for("admin_instances"))
if not school_name:
school_name = _sanitize_text(owner_doc.get("display_name") or owner_username, 120)
if app_image_tag not in version_options:
flash("Ungültige Version ausgewählt.", "error")
return redirect(url_for("admin_instances"))
existing_for_owner = _get_instance_for_user(owner_username, "")
if existing_for_owner:
existing_sub = _sanitize_text(existing_for_owner.get("subdomain") or "", 63)
if existing_sub and existing_sub != subdomain:
flash(
f"Nutzer {owner_username} ist bereits der Instanz {existing_sub} zugewiesen. "
"Bitte erst diese Zuweisung ändern.",
"error",
)
return redirect(url_for("admin_instances"))
if not _is_valid_subdomain(subdomain):
flash("Ungültige Subdomain. Erlaubt sind a-z, 0-9 und Bindestriche (3-63 Zeichen).", "error")
return redirect(url_for("admin_instances"))
success, message, details = _run_instance_provision(
action,
school_name,
subdomain,
app_image_tag=app_image_tag,
library_enabled=library_enabled,
)
instance_data = {
"school_name": school_name,
"owner_username": owner_username,
"subdomain": details.get("SUBDOMAIN") or subdomain,
"domain": details.get("DOMAIN") or f"{subdomain}.{INSTANCE_PARENT_DOMAIN}",
"https_port": int((details.get("HTTPS_PORT") or "0") or 0),
"instance_dir": details.get("INSTANCE_DIR") or os.path.join(INSTANCE_BASE_DIR, subdomain),
"app_image_tag": details.get("APP_IMAGE_TAG") or app_image_tag,
"library_enabled": library_enabled if details.get("LIBRARY_ENABLED") is None else details.get("LIBRARY_ENABLED") == "1",
"status": "Läuft" if success else "Fehler",
"nginx_status": details.get("NGINX_STATUS") or ("ok" if success else "error"),
"last_message": message,
}
_upsert_school_instance(instance_data)
if success:
flash(f"Instanz gestartet: {instance_data['domain']}", "success")
if message:
flash(message, "info")
else:
flash(message or "Instanz konnte nicht gestartet werden.", "error")
return redirect(url_for("admin_instances"))
instances = _list_school_instances()
return render_template(
"admin_instances.html",
instances=instances,
users=_list_users_for_admin(),
available_users=available_users,
version_options=version_options,
instance_repo_url=INSTANCE_REPO_URL,
parent_domain=INSTANCE_PARENT_DOMAIN,
base_dir=INSTANCE_BASE_DIR,
provision_script=INSTANCE_PROVISION_SCRIPT,
)
@app.route('/admin/system', methods=['GET', 'POST'])
@admin_required
def admin_system_tools():
if request.method == 'POST':
action = _sanitize_text(request.form.get("action") or "", 40)
subdomain = _sanitize_text(request.form.get("subdomain") or "", 63)
if action == "restart_core":
compose_file = os.path.join(BASE_DIR, "docker-compose.yml")
ok, output = _run_command(
["docker", "compose", "-f", compose_file, "restart", "website", "mongodb"],
cwd=BASE_DIR,
timeout=180,
)
flash("Core-Services wurden neugestartet." if ok else f"Core-Restart fehlgeschlagen:\n{_tail_output(output, 10)}", "success" if ok else "error")
return redirect(url_for("admin_system_tools"))
if action in {"backup_instance", "update_instance", "restart_instance"}:
instance_dir = _resolve_instance_dir(subdomain)
if not instance_dir:
flash("Instanz nicht gefunden oder ungültige Subdomain.", "error")
return redirect(url_for("admin_system_tools"))
if action == "backup_instance":
command = ["bash", "./backup.sh", "--mode", "auto"]
timeout = 1800
title = f"Backup für {subdomain}"
elif action == "update_instance":
command = ["bash", "./update.sh"]
timeout = 2400
title = f"Update für {subdomain}"
else:
title = f"Restart für {subdomain}"
ok, output = _restart_instance_stack(instance_dir)
if ok:
flash(f"{title} erfolgreich.\n{_tail_output(output, 12)}", "success")
_upsert_school_instance(
{
"subdomain": subdomain,
"status": "Läuft",
"nginx_status": "ok",
"last_message": "Instanz über System-Tools neu gestartet.",
}
)
else:
flash(f"{title} fehlgeschlagen.\n{_tail_output(output, 18)}", "error")
_upsert_school_instance(
{
"subdomain": subdomain,
"status": "Fehler",
"nginx_status": "error",
"last_message": _sanitize_text(_tail_output(output, 6), 500),
}
)
return redirect(url_for("admin_system_tools"))
ok, output = _run_command(command, cwd=instance_dir, timeout=timeout)
if ok:
flash(f"{title} erfolgreich.\n{_tail_output(output, 12)}", "success")
else:
flash(f"{title} fehlgeschlagen.\n{_tail_output(output, 18)}", "error")
return redirect(url_for("admin_system_tools"))
if action == "create_instance_admin":
username = _sanitize_text(request.form.get("admin_username") or "", 80)
password = (request.form.get("admin_password") or "").strip()
first_name = _sanitize_text(request.form.get("admin_first_name") or "Admin", 80)
last_name = _sanitize_text(request.form.get("admin_last_name") or "User", 80)
ok, msg = _create_instance_admin_user(subdomain, username, password, first_name, last_name)
flash(msg, "success" if ok else "error")
return redirect(url_for("admin_system_tools"))
flash("Unbekannte System-Aktion.", "error")
return redirect(url_for("admin_system_tools"))
instances = _list_school_instances()
return render_template("admin_system.html", instances=instances)
@app.route('/admin/system/logs/core')
@admin_required
def admin_download_core_logs():
ok, output = _collect_core_logs()
if not ok:
flash(f"Core-Logs konnten nicht geladen werden.\n{_tail_output(output, 14)}", "error")
return redirect(url_for("admin_system_tools"))
filename = f"core-logs-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}.log"
return send_file(
BytesIO(output.encode("utf-8")),
mimetype="text/plain",
as_attachment=True,
download_name=filename,
)
@app.route('/admin/system/logs/instance/<subdomain>')
@admin_required
def admin_download_instance_logs(subdomain):
ok, output = _collect_instance_logs(subdomain)
if not ok:
flash(f"Instanz-Logs konnten nicht geladen werden.\n{_tail_output(output, 14)}", "error")
return redirect(url_for("admin_system_tools"))
safe_name = _slugify_subdomain(subdomain)
filename = f"instance-{safe_name}-logs-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}.log"
return send_file(
BytesIO(output.encode("utf-8")),
mimetype="text/plain",
as_attachment=True,
download_name=filename,
)
@app.route('/admin/appointments/block-day', methods=['POST']) @app.route('/admin/appointments/block-day', methods=['POST'])
@admin_required @admin_required
def admin_block_day(): def admin_block_day():
@@ -951,6 +1850,44 @@ def my_invoices():
return render_template("my_invoices.html", invoices=invoices) return render_template("my_invoices.html", invoices=invoices)
@app.route('/my/instance', methods=['GET', 'POST'])
@login_required
def my_instance_management():
username = _sanitize_text(session.get("username") or "", 80)
display_name = _sanitize_text(session.get("display_name") or username, 120)
current_instance = _get_instance_for_user(username, display_name)
current_subdomain = _sanitize_text((current_instance or {}).get("subdomain") or "", 63)
suggested_subdomain = _slugify_subdomain(display_name or username)
if request.method == 'POST':
flash("Nutzer können Instanzen nicht selbst erstellen oder ändern. Bitte den Administrator kontaktieren.", "error")
return redirect(url_for("my_instance_management"))
instance_doc = _get_instance_for_user(username, display_name)
instance_view = None
if instance_doc:
instance_view = {
"school_name": _sanitize_text(instance_doc.get("school_name") or display_name, 120),
"owner_username": _sanitize_text(instance_doc.get("owner_username") or "", 80),
"subdomain": _sanitize_text(instance_doc.get("subdomain") or "", 63),
"domain": _sanitize_text(instance_doc.get("domain") or "", 190),
"https_port": int(instance_doc.get("https_port") or 0),
"status": _sanitize_text(instance_doc.get("status") or "Unbekannt", 40),
"nginx_status": _sanitize_text(instance_doc.get("nginx_status") or "unbekannt", 80),
"last_message": _sanitize_text(instance_doc.get("last_message") or "", 500),
"updated_at": instance_doc.get("updated_at") or "",
}
return render_template(
"my_instance.html",
instance=instance_view,
suggested_subdomain=suggested_subdomain,
parent_domain=INSTANCE_PARENT_DOMAIN,
default_school_name=display_name,
)
@app.route('/chat', methods=['GET', 'POST']) @app.route('/chat', methods=['GET', 'POST'])
@login_required @login_required
def user_chat(): def user_chat():
@@ -1068,7 +2005,8 @@ def admin_users():
return redirect(url_for("admin_users")) return redirect(url_for("admin_users"))
users = _list_users_for_admin() users = _list_users_for_admin()
return render_template("admin_users.html", users=users) instances_by_owner = _list_instances_grouped_by_owner()
return render_template("admin_users.html", users=users, instances_by_owner=instances_by_owner)
@app.route('/admin/team', methods=['GET', 'POST']) @app.route('/admin/team', methods=['GET', 'POST'])
+483
View File
@@ -0,0 +1,483 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="create"
SCHOOL_NAME=""
SUBDOMAIN=""
REPO_URL=""
BASE_DIR=""
PARENT_DOMAIN=""
HTTPS_PORT=""
HTTP_PORT=""
TLS_MODE="development"
APP_IMAGE_TAG="latest"
LIBRARY_ENABLED="0"
WILDCARD_CERT_FILE="/etc/nginx/certs/wildcard.meine-domain.crt"
WILDCARD_KEY_FILE="/etc/nginx/certs/wildcard.meine-domain.key"
NGINX_SITES_AVAILABLE="/etc/nginx/sites-available"
NGINX_SITES_ENABLED="/etc/nginx/sites-enabled"
print_kv() {
local key="$1"
local value="$2"
printf '%s=%s\n' "$key" "$value"
}
fail() {
local message="$1"
print_kv "MESSAGE" "$message"
print_kv "ERROR" "$message"
exit 1
}
slugify() {
local input="$1"
input="${input,,}"
input="$(echo "$input" | sed -E 's/ä/ae/g; s/ö/oe/g; s/ü/ue/g; s/ß/ss/g')"
input="$(echo "$input" | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-+//; s/-+$//')"
printf '%s' "${input:0:63}"
}
is_valid_subdomain() {
local value="$1"
[[ "$value" =~ ^[a-z0-9]([a-z0-9-]{1,61}[a-z0-9])$ ]]
}
require_cmd() {
local cmd="$1"
command -v "$cmd" >/dev/null 2>&1 || fail "Fehlender Befehl: $cmd"
}
is_port_in_use() {
local port="$1"
# Running inside container namespaces cannot reliably see host listeners,
# but docker CLI can inspect host-published ports via the socket.
if docker ps --format '{{.Ports}}' | grep -Eq "(^|[,[:space:]])((0\.0\.0\.0|\[::\]):)?${port}->"; then
return 0
fi
if command -v ss >/dev/null 2>&1; then
ss -ltn "( sport = :$port )" 2>/dev/null | awk 'NR>1 {print $4}' | grep -q . && return 0
fi
return 1
}
next_free_port() {
local start_port="$1"
local current="$start_port"
while is_port_in_use "$current"; do
current=$((current + 1))
done
printf '%s' "$current"
}
write_env_file() {
local target_dir="$1"
local http_port="$2"
local https_port="$3"
local app_image_tag="$4"
cat > "$target_dir/.docker-build.env" <<EOF
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=$http_port
INVENTAR_HTTPS_PORT=$https_port
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$app_image_tag
EOF
}
set_library_enabled() {
local instance_dir="$1"
local enabled="$2"
local cfg="$instance_dir/config.json"
[ -f "$cfg" ] || return 0
python3 - <<'PY' "$cfg" "$enabled"
import json
import sys
cfg_path = sys.argv[1]
enabled = sys.argv[2] == "1"
with open(cfg_path, "r", encoding="utf-8") as fh:
data = json.load(fh)
modules = data.get("modules")
if not isinstance(modules, dict):
modules = {}
data["modules"] = modules
library = modules.get("library")
if not isinstance(library, dict):
library = {}
modules["library"] = library
library["enabled"] = enabled
with open(cfg_path, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2, ensure_ascii=False)
fh.write("\n")
PY
}
normalize_instance_compose() {
local compose_file="docker-compose.yml"
[ -f "$compose_file" ] || return 0
python3 - <<'PY'
from pathlib import Path
path = Path("docker-compose.yml")
content = path.read_text(encoding="utf-8")
lines = content.splitlines()
out = []
in_nginx = False
in_ports = False
ports_indent = ""
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
indent = len(line) - len(line.lstrip(" "))
if stripped.startswith("nginx:") and indent == 2:
in_nginx = True
in_ports = False
out.append(line)
i += 1
continue
if in_nginx and indent == 2 and stripped.endswith(":") and not stripped.startswith("nginx:"):
in_nginx = False
in_ports = False
if stripped.startswith("container_name:") and indent == 4:
i += 1
continue
if in_nginx and stripped.startswith("ports:") and indent == 4:
out.append(line)
out.append(" - \"${INVENTAR_HTTP_PORT:-80}:80\"")
out.append(" - \"${INVENTAR_HTTPS_PORT:-443}:443\"")
i += 1
in_ports = True
ports_indent = " "
while i < len(lines):
nxt = lines[i]
nxt_stripped = nxt.strip()
nxt_indent = len(nxt) - len(nxt.lstrip(" "))
if nxt_stripped.startswith("-") and nxt_indent >= len(ports_indent):
i += 1
continue
break
continue
out.append(line)
i += 1
normalized = "\n".join(out)
if content.endswith("\n"):
normalized += "\n"
if normalized != content:
path.write_text(normalized, encoding="utf-8")
PY
}
run_instance_start() {
local start_output
local retry_output
local update_output
stack_is_running() {
local running_services
running_services="$(docker compose --env-file .docker-build.env ps --status running --services 2>/dev/null || true)"
printf '%s\n' "$running_services" | grep -Fxq app || return 1
printf '%s\n' "$running_services" | grep -Fxq nginx || return 1
printf '%s\n' "$running_services" | grep -Fxq mongodb || return 1
return 0
}
if start_output="$(INVENTAR_SETUP_CRON=0 INVENTAR_HTTP_PORT="$HTTP_PORT" INVENTAR_HTTPS_PORT="$HTTPS_PORT" bash ./start.sh --no-cron 2>&1)"; then
return 0
fi
if stack_is_running; then
print_kv "MESSAGE" "Instanz gestartet (Healthcheck im Startskript war nicht erreichbar, Dienste laufen)."
return 0
fi
if printf '%s' "$start_output" | grep -qi "local app image not found"; then
if [ ! -x ./update.sh ]; then
fail "start.sh fehlgeschlagen und update.sh fehlt. Letzte Meldung: $(printf '%s' "$start_output" | tail -n1)"
fi
if ! update_output="$(bash ./update.sh 2>&1)"; then
if stack_is_running; then
print_kv "MESSAGE" "Instanz gestartet (update.sh meldete Healthcheck-Fehler, Dienste laufen)."
return 0
fi
fail "update.sh fehlgeschlagen: $(printf '%s' "$update_output" | tail -n1)"
fi
if retry_output="$(INVENTAR_SETUP_CRON=0 INVENTAR_HTTP_PORT="$HTTP_PORT" INVENTAR_HTTPS_PORT="$HTTPS_PORT" bash ./start.sh --no-cron 2>&1)"; then
print_kv "MESSAGE" "Instanz gestartet (Image automatisch per update.sh geladen)."
return 0
fi
fail "Start nach update.sh fehlgeschlagen: $(printf '%s' "$retry_output" | tail -n1)"
fi
fail "start.sh fehlgeschlagen: $(printf '%s' "$start_output" | tail -n1)"
}
setup_or_update_repo() {
local target_dir="$1"
local repo_url="$2"
if [ -d "$target_dir/.git" ]; then
git -C "$target_dir" fetch --all --prune
git -C "$target_dir" pull --ff-only origin main || true
return 0
fi
if [ -d "$target_dir" ] && [ "$(find "$target_dir" -mindepth 1 -maxdepth 1 | wc -l)" -gt 0 ]; then
fail "Zielverzeichnis ist nicht leer: $target_dir"
fi
mkdir -p "$target_dir"
git clone "$repo_url" "$target_dir"
}
write_nginx_site() {
local subdomain="$1"
local full_domain="$2"
local https_port="$3"
local cert_file=""
local key_file=""
local cert_dir="/etc/nginx/certs"
local site_name="inventarsystem-${subdomain}.conf"
local avail_file="$NGINX_SITES_AVAILABLE/$site_name"
local enabled_file="$NGINX_SITES_ENABLED/$site_name"
if [ ! -d "$NGINX_SITES_AVAILABLE" ] || [ ! -d "$NGINX_SITES_ENABLED" ]; then
print_kv "NGINX_STATUS" "skipped"
print_kv "MESSAGE" "Instanz gestartet. Nginx-Verzeichnisse nicht gefunden, Reverse-Proxy manuell anlegen."
return 0
fi
if [ ! -w "$NGINX_SITES_AVAILABLE" ] || [ ! -w "$NGINX_SITES_ENABLED" ]; then
print_kv "NGINX_STATUS" "manual_required"
print_kv "MESSAGE" "Instanz gestartet. Keine Schreibrechte auf Nginx-Konfiguration, bitte als root/sudo ausführen."
return 0
fi
if [ "$TLS_MODE" = "production" ]; then
cert_file="$WILDCARD_CERT_FILE"
key_file="$WILDCARD_KEY_FILE"
if [ ! -f "$cert_file" ] || [ ! -f "$key_file" ]; then
print_kv "NGINX_STATUS" "error"
print_kv "MESSAGE" "Produktivmodus aktiv, aber Wildcard-Zertifikat fehlt: $cert_file / $key_file"
return 0
fi
else
cert_file="$cert_dir/$full_domain.crt"
key_file="$cert_dir/$full_domain.key"
if [ ! -f "$cert_file" ] || [ ! -f "$key_file" ]; then
if ! command -v openssl >/dev/null 2>&1; then
print_kv "NGINX_STATUS" "manual_required"
print_kv "MESSAGE" "Instanz gestartet. openssl fehlt für Development-Zertifikat, Zertifikat bitte manuell anlegen."
return 0
fi
if [ ! -d "$cert_dir" ] || [ ! -w "$cert_dir" ]; then
print_kv "NGINX_STATUS" "manual_required"
print_kv "MESSAGE" "Instanz gestartet. Keine Schreibrechte auf $cert_dir für Development-Zertifikat."
return 0
fi
if ! openssl req -x509 -nodes -newkey rsa:2048 -sha256 \
-days 825 \
-keyout "$key_file" \
-out "$cert_file" \
-subj "/CN=$full_domain" \
-addext "subjectAltName=DNS:$full_domain" >/dev/null 2>&1; then
openssl req -x509 -nodes -newkey rsa:2048 -sha256 \
-days 825 \
-keyout "$key_file" \
-out "$cert_file" \
-subj "/CN=$full_domain" >/dev/null 2>&1 || {
print_kv "NGINX_STATUS" "manual_required"
print_kv "MESSAGE" "Instanz gestartet. Development-Zertifikat konnte nicht erstellt werden."
return 0
}
fi
chmod 600 "$key_file" 2>/dev/null || true
chmod 644 "$cert_file" 2>/dev/null || true
fi
fi
cat > "$avail_file" <<EOF
server {
listen 80;
server_name $full_domain;
return 301 https://\$host\$request_uri;
}
server {
listen 443 ssl;
server_name $full_domain;
ssl_certificate $cert_file;
ssl_certificate_key $key_file;
location / {
proxy_pass https://127.0.0.1:$https_port;
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_ssl_verify off;
proxy_read_timeout 120s;
}
}
EOF
ln -sfn "$avail_file" "$enabled_file"
if ! command -v nginx >/dev/null 2>&1; then
print_kv "NGINX_STATUS" "manual_required"
print_kv "MESSAGE" "Instanz gestartet. Nginx-Site wurde geschrieben, Reload bitte auf dem Host ausführen."
return 0
fi
if nginx -t >/dev/null 2>&1; then
nginx -s reload >/dev/null 2>&1 || true
print_kv "NGINX_STATUS" "ok"
else
print_kv "NGINX_STATUS" "error"
print_kv "MESSAGE" "Instanz gestartet, aber nginx -t ist fehlgeschlagen."
return 0
fi
}
while [ "$#" -gt 0 ]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--school-name)
SCHOOL_NAME="${2:-}"
shift 2
;;
--subdomain)
SUBDOMAIN="${2:-}"
shift 2
;;
--repo)
REPO_URL="${2:-}"
shift 2
;;
--base-dir)
BASE_DIR="${2:-}"
shift 2
;;
--domain)
PARENT_DOMAIN="${2:-}"
shift 2
;;
--https-port)
HTTPS_PORT="${2:-}"
shift 2
;;
--http-port)
HTTP_PORT="${2:-}"
shift 2
;;
--tls-mode)
TLS_MODE="${2:-}"
shift 2
;;
--wildcard-cert-file)
WILDCARD_CERT_FILE="${2:-}"
shift 2
;;
--wildcard-key-file)
WILDCARD_KEY_FILE="${2:-}"
shift 2
;;
--app-image-tag)
APP_IMAGE_TAG="${2:-latest}"
shift 2
;;
--library-enabled)
LIBRARY_ENABLED="${2:-0}"
shift 2
;;
*)
fail "Unbekannter Parameter: $1"
;;
esac
done
[ -n "$REPO_URL" ] || fail "Repository-URL fehlt"
[ -n "$BASE_DIR" ] || fail "Basisverzeichnis fehlt"
[ -n "$PARENT_DOMAIN" ] || fail "Parent-Domain fehlt"
if [ -z "$SUBDOMAIN" ]; then
SUBDOMAIN="$(slugify "$SCHOOL_NAME")"
else
SUBDOMAIN="$(slugify "$SUBDOMAIN")"
fi
is_valid_subdomain "$SUBDOMAIN" || fail "Ungueltige Subdomain: $SUBDOMAIN"
INSTANCE_DIR="$BASE_DIR/$SUBDOMAIN"
FULL_DOMAIN="$SUBDOMAIN.$PARENT_DOMAIN"
require_cmd git
require_cmd docker
require_cmd bash
mkdir -p "$BASE_DIR"
if [ -z "$HTTPS_PORT" ]; then
HTTPS_PORT="$(next_free_port 15443)"
fi
if [ -z "$HTTP_PORT" ]; then
HTTP_PORT="$(next_free_port 15080)"
fi
if [ "$ACTION" = "create" ] || [ "$ACTION" = "start" ]; then
setup_or_update_repo "$INSTANCE_DIR" "$REPO_URL"
[ -f "$INSTANCE_DIR/start.sh" ] || fail "start.sh im Zielrepository nicht gefunden: $INSTANCE_DIR"
write_env_file "$INSTANCE_DIR" "$HTTP_PORT" "$HTTPS_PORT" "$APP_IMAGE_TAG"
set_library_enabled "$INSTANCE_DIR" "$LIBRARY_ENABLED"
cd "$INSTANCE_DIR"
normalize_instance_compose
run_instance_start
write_nginx_site "$SUBDOMAIN" "$FULL_DOMAIN" "$HTTPS_PORT"
print_kv "SUBDOMAIN" "$SUBDOMAIN"
print_kv "DOMAIN" "$FULL_DOMAIN"
print_kv "HTTPS_PORT" "$HTTPS_PORT"
print_kv "HTTP_PORT" "$HTTP_PORT"
print_kv "APP_IMAGE_TAG" "$APP_IMAGE_TAG"
print_kv "LIBRARY_ENABLED" "$LIBRARY_ENABLED"
print_kv "INSTANCE_DIR" "$INSTANCE_DIR"
exit 0
fi
fail "Nicht unterstuetzte Action: $ACTION"
+3 -10
View File
@@ -33,15 +33,8 @@ if ! "${DOCKER_CMD[@]}" compose version >/dev/null 2>&1; then
exit 1 exit 1
fi fi
NUITKA_JOBS="${NUITKA_JOBS:-1}" echo "==> Building website image (pure Python runtime)"
NUITKA_LOW_MEMORY="${NUITKA_LOW_MEMORY:-1}" "${DOCKER_CMD[@]}" compose build website
echo "==> Safe Nuitka build settings"
echo " NUITKA_JOBS=${NUITKA_JOBS}"
echo " NUITKA_LOW_MEMORY=${NUITKA_LOW_MEMORY}"
echo "==> Building website image (Nuitka standalone conversion runs in Dockerfile)"
NUITKA_JOBS="$NUITKA_JOBS" NUITKA_LOW_MEMORY="$NUITKA_LOW_MEMORY" "${DOCKER_CMD[@]}" compose build website
echo "==> Starting internal MongoDB + Website containers" echo "==> Starting internal MongoDB + Website containers"
"${DOCKER_CMD[@]}" compose up -d "${DOCKER_CMD[@]}" compose up -d
@@ -51,4 +44,4 @@ echo "==> Container status"
echo "Application is starting on: http://localhost:4999" echo "Application is starting on: http://localhost:4999"
echo "Use: docker compose logs -f website" echo "Use: docker compose logs -f website"
echo "Tip: If build is still too heavy, run with NUITKA_JOBS=1 NUITKA_LOW_MEMORY=1 ./start_docker_build.sh" echo "Provisioning is available in the container via /app/provision_instance.sh"
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
HOSTS_FILE="/etc/hosts"
START_MARK="# >>> invario-dev-hosts >>>"
END_MARK="# <<< invario-dev-hosts <<<"
if ! command -v docker >/dev/null 2>&1; then
echo "docker fehlt; Hosts-Sync übersprungen"
exit 0
fi
if ! docker compose ps mongodb >/dev/null 2>&1; then
echo "mongodb-compose-Service nicht erreichbar; Hosts-Sync übersprungen"
exit 0
fi
DOMAINS_RAW="$(docker compose exec -T mongodb mongosh --quiet --eval 'db.getSiblingDB("Invario_Website").school_instances.find({},{_id:0,domain:1}).toArray().forEach(d=>{ if(d.domain){ print(String(d.domain).trim()) } })' 2>/dev/null || true)"
TMP_BLOCK="$(mktemp)"
trap 'rm -f "$TMP_BLOCK"' EXIT
{
echo "$START_MARK"
echo "127.0.0.1 localhost"
while IFS= read -r line; do
domain="$(echo "$line" | tr -d '\r' | xargs)"
if [[ -n "$domain" ]]; then
echo "127.0.0.1 $domain"
fi
done <<< "$DOMAINS_RAW"
echo "$END_MARK"
} > "$TMP_BLOCK"
if [[ "${EUID}" -eq 0 ]]; then
sed -i "/$START_MARK/,/$END_MARK/d" "$HOSTS_FILE"
cat "$TMP_BLOCK" >> "$HOSTS_FILE"
else
sudo sed -i "/$START_MARK/,/$END_MARK/d" "$HOSTS_FILE"
sudo tee -a "$HOSTS_FILE" < "$TMP_BLOCK" >/dev/null
fi
echo "Hosts-Sync abgeschlossen"
+4
View File
@@ -274,6 +274,10 @@
<p><strong>Team verwalten:</strong> Bilder, Rolle und Arbeit der zwei Team-Cards zentral pflegen.</p> <p><strong>Team verwalten:</strong> Bilder, Rolle und Arbeit der zwei Team-Cards zentral pflegen.</p>
<a href="{{ url_for('admin_team') }}">Team Cards öffnen</a> <a href="{{ url_for('admin_team') }}">Team Cards öffnen</a>
</div> </div>
<div class="quick-admin-link">
<p><strong>Schul-Instanzen:</strong> Neue Subdomain-Instanzen für Schulen aus dem Inventarsystem-Repository starten.</p>
<a href="{{ url_for('admin_instances') }}">Instanzen öffnen</a>
</div>
<h2>Buchungsanfragen verwalten</h2> <h2>Buchungsanfragen verwalten</h2>
<div class="block-days-section"> <div class="block-days-section">
<h3>Kalender-Tage sperren</h3> <h3>Kalender-Tage sperren</h3>
+308
View File
@@ -0,0 +1,308 @@
{% extends "base.html" %}
{% block title %}Admin | Instanzen{% endblock %}
{% block content %}
<section class="instance-header">
<h1>Schul-Instanzen starten</h1>
<p>Neue Instanzen des Inventarsystems für Subdomains pro Schule anlegen, sowie die Bibliothek dauerhaft aktivieren oder deaktivieren.</p>
</section>
<section class="instance-form-wrap">
<form method="post" class="instance-form">
<input type="hidden" name="action" value="create">
<label for="owner_username">Zugewiesener Nutzer</label>
<select id="owner_username" name="owner_username" required {% if not available_users %}disabled{% endif %}>
{% if available_users %}
<option value="">Nutzer wählen</option>
{% for user in available_users %}
<option value="{{ user.username }}" data-school-name="{{ user.display_name or user.username }}">{{ user.username }}{% if user.display_name %} - {{ user.display_name }}{% endif %}</option>
{% endfor %}
{% else %}
<option value="">Keine freien Nutzer verfügbar</option>
{% endif %}
</select>
{% if not available_users %}
<p class="warn-text">Alle Nutzer sind bereits einer Instanz zugewiesen. Lege einen neuen Nutzer an oder lösche eine bestehende Instanz.</p>
{% endif %}
<label for="school_name">Schulname</label>
<input id="school_name" name="school_name" type="text" placeholder="wird aus dem Nutzer übernommen" required>
<label for="app_image_tag">Version</label>
<select id="app_image_tag" name="app_image_tag" required>
{% for version in version_options %}
<option value="{{ version }}">{{ version }}</option>
{% endfor %}
</select>
<label for="subdomain">Subdomain (optional)</label>
<input id="subdomain" name="subdomain" type="text" placeholder="wird aus Schulname abgeleitet">
<label class="check-row">
<input type="checkbox" name="library_enabled" value="1">
Bibliothek aktivieren
</label>
<button type="submit">Instanz erstellen und starten</button>
</form>
<aside class="instance-meta">
<h2>Systemdaten</h2>
<p><strong>Repository:</strong> {{ instance_repo_url }}</p>
<p><strong>Basis-Domain:</strong> {{ parent_domain }}</p>
<p><strong>Instanz-Pfad:</strong> {{ base_dir }}</p>
<p><strong>Provisioning-Skript:</strong> {{ provision_script }}</p>
<p class="note">Hinweis: Für Docker- und Nginx-Operationen benötigt der laufende Prozess die passenden Host-Berechtigungen.</p>
</aside>
</section>
<section class="instance-table-wrap">
<h2>Vorhandene Instanzen</h2>
{% if instances %}
<table>
<thead>
<tr>
<th>Schule</th>
<th>Nutzer</th>
<th>Subdomain</th>
<th>Version</th>
<th>Bibliothek</th>
<th>Aktion</th>
<th>HTTPS-Port</th>
<th>Status</th>
<th>Nginx</th>
<th>Letzte Meldung</th>
<th>Aktualisiert</th>
</tr>
</thead>
<tbody>
{% for item in instances %}
<tr>
<td>{{ item.school_name }}</td>
<td>{{ item.owner_username or '-' }}</td>
<td>
{% if item.domain %}
<a class="instance-domain-link" href="https://{{ item.domain }}" target="_blank" rel="noopener noreferrer">{{ item.domain }}</a>
{% else %}
-
{% endif %}
</td>
<td>{{ item.app_image_tag or 'latest' }}</td>
<td>{{ 'an' if item.library_enabled else 'aus' }}</td>
<td>
<form method="post" class="inline-toggle-form">
<input type="hidden" name="action" value="toggle_library">
<input type="hidden" name="subdomain" value="{{ item.subdomain }}">
<input type="hidden" name="library_enabled" value="{{ 0 if item.library_enabled else 1 }}">
<button type="submit">{{ 'Bibliothek deaktivieren' if item.library_enabled else 'Bibliothek aktivieren' }}</button>
</form>
<form method="post" class="inline-toggle-form" onsubmit="return confirm('Instanz {{ item.subdomain }} wirklich löschen? Dieser Vorgang entfernt Container, Daten und Konfiguration.');">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="subdomain" value="{{ item.subdomain }}">
<button type="submit" class="danger">Instanz löschen</button>
</form>
</td>
<td>{{ item.https_port or '-' }}</td>
<td>
<span class="pill {{ 'ok' if item.status == 'Läuft' else 'error' }}">{{ item.status }}</span>
</td>
<td>{{ item.nginx_status }}</td>
<td>{{ item.last_message }}</td>
<td>{{ item.updated_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>Noch keine Instanzen vorhanden.</p>
{% endif %}
</section>
<style>
.instance-header {
margin-bottom: 1rem;
}
.instance-form-wrap {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 1rem;
margin-bottom: 1.2rem;
}
.instance-form,
.instance-meta,
.instance-table-wrap {
border: 1px solid #d8e1e8;
background: #ffffff;
border-radius: 14px;
padding: 1rem;
}
.instance-form {
display: grid;
gap: 0.5rem;
}
.instance-form input,
.instance-form select {
border: 1px solid #bcd0de;
border-radius: 10px;
padding: 0.55rem 0.65rem;
font: inherit;
}
.check-row {
display: flex;
align-items: center;
gap: 0.45rem;
margin-top: 0.2rem;
font-weight: 600;
}
.instance-form button {
margin-top: 0.4rem;
border: none;
border-radius: 999px;
padding: 0.55rem 0.95rem;
font-weight: 700;
cursor: pointer;
color: #ffffff;
background: linear-gradient(120deg, #0b5b89 0%, #0b4262 100%);
}
.warn-text {
margin: 0.2rem 0 0.1rem;
color: #8b2a27;
font-size: 0.9rem;
font-weight: 600;
}
.instance-domain-link {
color: #0b5b89;
font-weight: 700;
text-decoration: none;
}
.instance-domain-link:hover {
text-decoration: underline;
}
.inline-toggle-form {
margin: 0;
}
.inline-toggle-form + .inline-toggle-form {
margin-top: 0.35rem;
}
.inline-toggle-form button {
border: 1px solid #bcd0de;
background: #ffffff;
color: #173f5a;
border-radius: 999px;
padding: 0.35rem 0.68rem;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.inline-toggle-form button:hover {
background: #f2f8fc;
}
.inline-toggle-form button.danger {
border-color: #e3adad;
color: #8b2a27;
background: #fef2f2;
}
.inline-toggle-form button.danger:hover {
background: #fde4e4;
}
.instance-meta h2,
.instance-table-wrap h2 {
margin: 0 0 0.5rem;
}
.instance-meta p {
margin: 0.28rem 0;
font-size: 0.92rem;
}
.instance-meta .note {
margin-top: 0.65rem;
padding-top: 0.65rem;
border-top: 1px dashed #c7d2db;
}
.instance-table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
border-bottom: 1px solid #e7edf3;
padding: 0.62rem;
vertical-align: top;
font-size: 0.9rem;
}
.pill {
display: inline-flex;
border-radius: 999px;
padding: 0.18rem 0.62rem;
font-weight: 700;
font-size: 0.78rem;
}
.pill.ok {
background: #eaf8f1;
border: 1px solid #8cc9a6;
color: #0f5c3a;
}
.pill.error {
background: #fef2f2;
border: 1px solid #e3adad;
color: #8b2a27;
}
@media (max-width: 900px) {
.instance-form-wrap {
grid-template-columns: 1fr;
}
}
</style>
<script>
(function() {
const ownerSelect = document.getElementById('owner_username');
const schoolInput = document.getElementById('school_name');
if (!ownerSelect || !schoolInput) {
return;
}
const updateSchoolName = () => {
const selectedOption = ownerSelect.options[ownerSelect.selectedIndex];
const schoolName = selectedOption ? (selectedOption.dataset.schoolName || '') : '';
if (schoolName) {
schoolInput.value = schoolName;
}
};
ownerSelect.addEventListener('change', updateSchoolName);
updateSchoolName();
})();
</script>
{% endblock %}
+131
View File
@@ -0,0 +1,131 @@
{% extends "base.html" %}
{% block title %}Admin | System-Tools{% endblock %}
{% block content %}
<section class="head">
<h1>System-Tools</h1>
<p>Nützliche Admin-Funktionen für Betrieb, Backup, Update, Restart und Log-Download.</p>
</section>
<section class="core-tools">
<h2>Core-Services</h2>
<div class="actions">
<form method="post">
<input type="hidden" name="action" value="restart_core">
<button type="submit">Website + MongoDB neu starten</button>
</form>
<a class="action-link" href="{{ url_for('admin_download_core_logs') }}">Core-Logs herunterladen</a>
</div>
</section>
<section class="instance-tools">
<h2>Instanz-Operationen</h2>
{% if instances %}
<table>
<thead>
<tr>
<th>Schule</th>
<th>Subdomain</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for item in instances %}
<tr>
<td>{{ item.school_name }}</td>
<td>{{ item.subdomain }}</td>
<td>{{ item.status }} / {{ item.nginx_status }}</td>
<td>
<div class="actions row-actions">
<form method="post">
<input type="hidden" name="action" value="backup_instance">
<input type="hidden" name="subdomain" value="{{ item.subdomain }}">
<button type="submit">Backup</button>
</form>
<form method="post">
<input type="hidden" name="action" value="update_instance">
<input type="hidden" name="subdomain" value="{{ item.subdomain }}">
<button type="submit">Update</button>
</form>
<form method="post">
<input type="hidden" name="action" value="restart_instance">
<input type="hidden" name="subdomain" value="{{ item.subdomain }}">
<button type="submit">Restart</button>
</form>
<a class="action-link" href="{{ url_for('admin_download_instance_logs', subdomain=item.subdomain) }}">Logs</a>
</div>
<form method="post" class="inline-admin-form">
<input type="hidden" name="action" value="create_instance_admin">
<input type="hidden" name="subdomain" value="{{ item.subdomain }}">
<input type="text" name="admin_username" placeholder="Admin-Username" required>
<input type="password" name="admin_password" placeholder="Passwort (>=8)" required>
<input type="text" name="admin_first_name" placeholder="Vorname" value="Admin">
<input type="text" name="admin_last_name" placeholder="Nachname" value="User">
<button type="submit">Admin erstellen</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>Keine Instanzen vorhanden.</p>
{% endif %}
</section>
<style>
.head { margin-bottom: 1rem; }
.core-tools,
.instance-tools {
border: 1px solid #d8e1e8;
background: #ffffff;
border-radius: 14px;
padding: 1rem;
margin-bottom: 1rem;
}
.actions { display: flex; flex-wrap: wrap; gap: 0.55rem; align-items: center; }
.row-actions { gap: 0.4rem; }
.actions form { margin: 0; }
.inline-admin-form {
margin-top: 0.55rem;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.38rem;
}
.inline-admin-form input {
border: 1px solid #bcd0de;
border-radius: 8px;
padding: 0.38rem 0.5rem;
font: inherit;
min-width: 0;
}
button,
.action-link {
border: 1px solid #bcd0de;
background: #ffffff;
padding: 0.42rem 0.72rem;
border-radius: 999px;
font-weight: 700;
cursor: pointer;
text-decoration: none;
color: #173f5a;
display: inline-flex;
align-items: center;
}
button:hover,
.action-link:hover { background: #f2f8fc; }
table { width: 100%; border-collapse: collapse; }
th, td {
text-align: left;
border-bottom: 1px solid #e7edf3;
padding: 0.62rem;
vertical-align: top;
}
@media (max-width: 860px) {
.actions { display: grid; }
.inline-admin-form { grid-template-columns: 1fr; }
}
</style>
{% endblock %}
+17
View File
@@ -15,6 +15,7 @@
<th>Benutzername</th> <th>Benutzername</th>
<th>Name</th> <th>Name</th>
<th>Rolle</th> <th>Rolle</th>
<th>Instanz-Links</th>
<th>Aktionen</th> <th>Aktionen</th>
</tr> </tr>
</thead> </thead>
@@ -24,6 +25,18 @@
<td>{{ user.username }}</td> <td>{{ user.username }}</td>
<td>{{ user.display_name }}</td> <td>{{ user.display_name }}</td>
<td>{{ 'Admin' if user.is_admin else 'Nutzer' }}</td> <td>{{ 'Admin' if user.is_admin else 'Nutzer' }}</td>
<td>
{% set user_instances = instances_by_owner.get(user.username, []) %}
{% if user_instances %}
<div class="instance-links">
{% for inst in user_instances %}
<a href="https://{{ inst.domain }}" target="_blank" rel="noopener noreferrer">{{ inst.domain }}</a>
{% endfor %}
</div>
{% else %}
<span class="no-instance">Keine zugewiesene Instanz</span>
{% endif %}
</td>
<td> <td>
<form method="post" class="actions"> <form method="post" class="actions">
<input type="hidden" name="username" value="{{ user.username }}"> <input type="hidden" name="username" value="{{ user.username }}">
@@ -51,6 +64,10 @@
table { width: 100%; border-collapse: collapse; } table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.7rem; border-bottom: 1px solid #e8eef2; } th, td { text-align: left; padding: 0.7rem; border-bottom: 1px solid #e8eef2; }
.actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; } .actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.instance-links { display: grid; gap: 0.35rem; }
.instance-links a { color: #0b5b89; font-weight: 600; text-decoration: none; }
.instance-links a:hover { text-decoration: underline; }
.no-instance { color: #5e7283; }
select { border: 1px solid #bcd0de; background: #fff; padding: 0.4rem 0.55rem; border-radius: 10px; font: inherit; } select { border: 1px solid #bcd0de; background: #fff; padding: 0.4rem 0.55rem; border-radius: 10px; font: inherit; }
button { border: 1px solid #bcd0de; background: #fff; padding: 0.4rem 0.65rem; border-radius: 999px; font-weight: 700; cursor: pointer; } button { border: 1px solid #bcd0de; background: #fff; padding: 0.4rem 0.65rem; border-radius: 999px; font-weight: 700; cursor: pointer; }
</style> </style>
+3
View File
@@ -385,6 +385,7 @@
<div class="dropdown-menu"> <div class="dropdown-menu">
<a href="{{ url_for('my_licenses') }}">Meine Lizenzen</a> <a href="{{ url_for('my_licenses') }}">Meine Lizenzen</a>
<a href="{{ url_for('my_invoices') }}">Meine Rechnungen</a> <a href="{{ url_for('my_invoices') }}">Meine Rechnungen</a>
<a href="{{ url_for('my_instance_management') }}">Meine Instanz</a>
<a href="{{ url_for('user_chat') }}">Chat mit Admin</a> <a href="{{ url_for('user_chat') }}">Chat mit Admin</a>
<a href="{{ url_for('user_tickets') }}">Support Tickets</a> <a href="{{ url_for('user_tickets') }}">Support Tickets</a>
</div> </div>
@@ -401,8 +402,10 @@
<a href="{{ url_for('admin_invoices') }}">Rechnungsverwaltung</a> <a href="{{ url_for('admin_invoices') }}">Rechnungsverwaltung</a>
<a href="{{ url_for('admin_chats') }}">Admin-Chat</a> <a href="{{ url_for('admin_chats') }}">Admin-Chat</a>
<a href="{{ url_for('admin_tickets') }}">Support-Center</a> <a href="{{ url_for('admin_tickets') }}">Support-Center</a>
<a href="{{ url_for('admin_system_tools') }}">System-Tools</a>
<a href="{{ url_for('admin_blog') }}">Blog verwalten</a> <a href="{{ url_for('admin_blog') }}">Blog verwalten</a>
<a href="{{ url_for('admin_team') }}">Team Cards</a> <a href="{{ url_for('admin_team') }}">Team Cards</a>
<a href="{{ url_for('admin_instances') }}">Schul-Instanzen</a>
</div> </div>
</details> </details>
{% endif %} {% endif %}
+41
View File
@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Meine Instanz{% endblock %}
{% block content %}
<section class="instance-head">
<h1>Meine Instanzverwaltung</h1>
<p>Hier sehen Sie ausschließlich Ihre zugewiesene Instanz und können diese direkt nutzen.</p>
</section>
<section class="instance-card">
<h2>Instanzdaten</h2>
{% if instance %}
<p><strong>Schule:</strong> {{ instance.school_name }}</p>
<p><strong>Domain:</strong> {{ instance.domain }}</p>
<p><strong>Subdomain:</strong> {{ instance.subdomain }}</p>
<p><strong>HTTPS-Port:</strong> {{ instance.https_port or '-' }}</p>
<p><strong>Status:</strong> {{ instance.status }}</p>
<p><strong>Nginx:</strong> {{ instance.nginx_status }}</p>
<p><strong>Letzte Meldung:</strong> {{ instance.last_message }}</p>
<p><strong>Aktualisiert:</strong> {{ instance.updated_at }}</p>
<p><strong>Zugang:</strong> <a href="https://{{ instance.domain }}" target="_blank" rel="noopener noreferrer">https://{{ instance.domain }}</a></p>
{% else %}
<p>Für Ihr Konto ist aktuell keine Instanz zugewiesen.</p>
<p>Bitte wenden Sie sich an den Administrator, um eine Instanz zugewiesen zu bekommen.</p>
{% endif %}
</section>
<style>
.instance-head { margin-bottom: 1rem; }
.instance-card {
max-width: 780px;
border: 1px solid #d8e1e8;
border-radius: 14px;
background: #ffffff;
padding: 1rem;
}
.instance-card h2 { margin: 0 0 0.6rem; }
.instance-card p { margin: 0.28rem 0; }
</style>
{% endblock %}
+475
View File
@@ -0,0 +1,475 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="create"
SCHOOL_NAME=""
SUBDOMAIN=""
REPO_URL=""
BASE_DIR=""
PARENT_DOMAIN=""
HTTPS_PORT=""
HTTP_PORT=""
TLS_MODE="development"
APP_IMAGE_TAG="latest"
LIBRARY_ENABLED="0"
WILDCARD_CERT_FILE="/etc/nginx/certs/wildcard.meine-domain.crt"
WILDCARD_KEY_FILE="/etc/nginx/certs/wildcard.meine-domain.key"
NGINX_SITES_AVAILABLE="/etc/nginx/sites-available"
NGINX_SITES_ENABLED="/etc/nginx/sites-enabled"
print_kv() {
local key="$1"
local value="$2"
printf '%s=%s\n' "$key" "$value"
}
fail() {
local message="$1"
print_kv "MESSAGE" "$message"
print_kv "ERROR" "$message"
exit 1
}
slugify() {
local input="$1"
input="${input,,}"
input="$(echo "$input" | sed -E 's/ä/ae/g; s/ö/oe/g; s/ü/ue/g; s/ß/ss/g')"
input="$(echo "$input" | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-+//; s/-+$//')"
printf '%s' "${input:0:63}"
}
is_valid_subdomain() {
local value="$1"
[[ "$value" =~ ^[a-z0-9]([a-z0-9-]{1,61}[a-z0-9])$ ]]
}
require_cmd() {
local cmd="$1"
command -v "$cmd" >/dev/null 2>&1 || fail "Fehlender Befehl: $cmd"
}
is_port_in_use() {
local port="$1"
if docker ps --format '{{.Ports}}' | grep -Eq "(^|[,[:space:]])((0\.0\.0\.0|\[::\]):)?${port}->"; then
return 0
fi
if command -v ss >/dev/null 2>&1; then
ss -ltn "( sport = :$port )" 2>/dev/null | awk 'NR>1 {print $4}' | grep -q . && return 0
fi
return 1
}
next_free_port() {
local start_port="$1"
local current="$start_port"
while is_port_in_use "$current"; do
current=$((current + 1))
done
printf '%s' "$current"
}
write_env_file() {
local target_dir="$1"
local http_port="$2"
local https_port="$3"
local app_image_tag="$4"
cat > "$target_dir/.docker-build.env" <<EOF
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=$http_port
INVENTAR_HTTPS_PORT=$https_port
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$app_image_tag
EOF
}
set_library_enabled() {
local instance_dir="$1"
local enabled="$2"
local cfg="$instance_dir/config.json"
[ -f "$cfg" ] || return 0
python3 - <<'PY' "$cfg" "$enabled"
import json
import sys
cfg_path = sys.argv[1]
enabled = sys.argv[2] == "1"
with open(cfg_path, "r", encoding="utf-8") as fh:
data = json.load(fh)
modules = data.get("modules")
if not isinstance(modules, dict):
modules = {}
data["modules"] = modules
library = modules.get("library")
if not isinstance(library, dict):
library = {}
modules["library"] = library
library["enabled"] = enabled
with open(cfg_path, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2, ensure_ascii=False)
fh.write("\n")
PY
}
normalize_instance_compose() {
local compose_file="docker-compose.yml"
[ -f "$compose_file" ] || return 0
python3 - <<'PY'
from pathlib import Path
path = Path("docker-compose.yml")
content = path.read_text(encoding="utf-8")
lines = content.splitlines()
out = []
in_nginx = False
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
indent = len(line) - len(line.lstrip(" "))
if stripped.startswith("nginx:") and indent == 2:
in_nginx = True
out.append(line)
i += 1
continue
if in_nginx and indent == 2 and stripped.endswith(":") and not stripped.startswith("nginx:"):
in_nginx = False
if stripped.startswith("container_name:") and indent == 4:
i += 1
continue
if in_nginx and stripped.startswith("ports:") and indent == 4:
out.append(line)
out.append(" - \"${INVENTAR_HTTP_PORT:-80}:80\"")
out.append(" - \"${INVENTAR_HTTPS_PORT:-443}:443\"")
i += 1
while i < len(lines):
nxt = lines[i]
nxt_stripped = nxt.strip()
nxt_indent = len(nxt) - len(nxt.lstrip(" "))
if nxt_stripped.startswith("-") and nxt_indent >= 6:
i += 1
continue
break
continue
out.append(line)
i += 1
normalized = "\n".join(out)
if content.endswith("\n"):
normalized += "\n"
if normalized != content:
path.write_text(normalized, encoding="utf-8")
PY
}
run_instance_start() {
local start_output
local retry_output
local update_output
stack_is_running() {
local running_services
running_services="$(docker compose --env-file .docker-build.env ps --status running --services 2>/dev/null || true)"
printf '%s\n' "$running_services" | grep -Fxq app || return 1
printf '%s\n' "$running_services" | grep -Fxq nginx || return 1
printf '%s\n' "$running_services" | grep -Fxq mongodb || return 1
return 0
}
if start_output="$(INVENTAR_SETUP_CRON=0 INVENTAR_HTTP_PORT="$HTTP_PORT" INVENTAR_HTTPS_PORT="$HTTPS_PORT" bash ./start.sh --no-cron 2>&1)"; then
return 0
fi
if stack_is_running; then
print_kv "MESSAGE" "Instanz gestartet (Healthcheck im Startskript war nicht erreichbar, Dienste laufen)."
return 0
fi
if printf '%s' "$start_output" | grep -qi "local app image not found"; then
if [ ! -x ./update.sh ]; then
fail "start.sh fehlgeschlagen und update.sh fehlt. Letzte Meldung: $(printf '%s' "$start_output" | tail -n1)"
fi
if ! update_output="$(bash ./update.sh 2>&1)"; then
if stack_is_running; then
print_kv "MESSAGE" "Instanz gestartet (update.sh meldete Healthcheck-Fehler, Dienste laufen)."
return 0
fi
fail "update.sh fehlgeschlagen: $(printf '%s' "$update_output" | tail -n1)"
fi
if retry_output="$(INVENTAR_SETUP_CRON=0 INVENTAR_HTTP_PORT="$HTTP_PORT" INVENTAR_HTTPS_PORT="$HTTPS_PORT" bash ./start.sh --no-cron 2>&1)"; then
print_kv "MESSAGE" "Instanz gestartet (Image automatisch per update.sh geladen)."
return 0
fi
fail "Start nach update.sh fehlgeschlagen: $(printf '%s' "$retry_output" | tail -n1)"
fi
fail "start.sh fehlgeschlagen: $(printf '%s' "$start_output" | tail -n1)"
}
setup_or_update_repo() {
local target_dir="$1"
local repo_url="$2"
if [ -d "$target_dir/.git" ]; then
git -C "$target_dir" fetch --all --prune
git -C "$target_dir" pull --ff-only origin main || true
return 0
fi
if [ -d "$target_dir" ] && [ "$(find "$target_dir" -mindepth 1 -maxdepth 1 | wc -l)" -gt 0 ]; then
fail "Zielverzeichnis ist nicht leer: $target_dir"
fi
mkdir -p "$target_dir"
git clone "$repo_url" "$target_dir"
}
write_nginx_site() {
local subdomain="$1"
local full_domain="$2"
local https_port="$3"
local cert_file=""
local key_file=""
local cert_dir="/etc/nginx/certs"
local site_name="inventarsystem-${subdomain}.conf"
local avail_file="$NGINX_SITES_AVAILABLE/$site_name"
local enabled_file="$NGINX_SITES_ENABLED/$site_name"
if [ ! -d "$NGINX_SITES_AVAILABLE" ] || [ ! -d "$NGINX_SITES_ENABLED" ]; then
print_kv "NGINX_STATUS" "skipped"
print_kv "MESSAGE" "Instanz gestartet. Nginx-Verzeichnisse nicht gefunden, Reverse-Proxy manuell anlegen."
return 0
fi
if [ ! -w "$NGINX_SITES_AVAILABLE" ] || [ ! -w "$NGINX_SITES_ENABLED" ]; then
print_kv "NGINX_STATUS" "manual_required"
print_kv "MESSAGE" "Instanz gestartet. Keine Schreibrechte auf Nginx-Konfiguration, bitte als root/sudo ausführen."
return 0
fi
if [ "$TLS_MODE" = "production" ]; then
cert_file="$WILDCARD_CERT_FILE"
key_file="$WILDCARD_KEY_FILE"
if [ ! -f "$cert_file" ] || [ ! -f "$key_file" ]; then
print_kv "NGINX_STATUS" "error"
print_kv "MESSAGE" "Produktivmodus aktiv, aber Wildcard-Zertifikat fehlt: $cert_file / $key_file"
return 0
fi
else
cert_file="$cert_dir/$full_domain.crt"
key_file="$cert_dir/$full_domain.key"
if [ ! -f "$cert_file" ] || [ ! -f "$key_file" ]; then
if ! command -v openssl >/dev/null 2>&1; then
print_kv "NGINX_STATUS" "manual_required"
print_kv "MESSAGE" "Instanz gestartet. openssl fehlt für Development-Zertifikat, Zertifikat bitte manuell anlegen."
return 0
fi
if [ ! -d "$cert_dir" ] || [ ! -w "$cert_dir" ]; then
print_kv "NGINX_STATUS" "manual_required"
print_kv "MESSAGE" "Instanz gestartet. Keine Schreibrechte auf $cert_dir für Development-Zertifikat."
return 0
fi
if ! openssl req -x509 -nodes -newkey rsa:2048 -sha256 \
-days 825 \
-keyout "$key_file" \
-out "$cert_file" \
-subj "/CN=$full_domain" \
-addext "subjectAltName=DNS:$full_domain" >/dev/null 2>&1; then
openssl req -x509 -nodes -newkey rsa:2048 -sha256 \
-days 825 \
-keyout "$key_file" \
-out "$cert_file" \
-subj "/CN=$full_domain" >/dev/null 2>&1 || {
print_kv "NGINX_STATUS" "manual_required"
print_kv "MESSAGE" "Instanz gestartet. Development-Zertifikat konnte nicht erstellt werden."
return 0
}
fi
chmod 600 "$key_file" 2>/dev/null || true
chmod 644 "$cert_file" 2>/dev/null || true
fi
fi
cat > "$avail_file" <<EOF
server {
listen 80;
server_name $full_domain;
return 301 https://\$host\$request_uri;
}
server {
listen 443 ssl;
server_name $full_domain;
ssl_certificate $cert_file;
ssl_certificate_key $key_file;
location / {
proxy_pass https://127.0.0.1:$https_port;
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_ssl_verify off;
proxy_read_timeout 120s;
}
}
EOF
ln -sfn "$avail_file" "$enabled_file"
if ! command -v nginx >/dev/null 2>&1; then
print_kv "NGINX_STATUS" "manual_required"
print_kv "MESSAGE" "Instanz gestartet. Nginx-Site wurde geschrieben, Reload bitte auf dem Host ausführen."
return 0
fi
if nginx -t >/dev/null 2>&1; then
nginx -s reload >/dev/null 2>&1 || true
print_kv "NGINX_STATUS" "ok"
else
print_kv "NGINX_STATUS" "error"
print_kv "MESSAGE" "Instanz gestartet, aber nginx -t ist fehlgeschlagen."
return 0
fi
}
while [ "$#" -gt 0 ]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--school-name)
SCHOOL_NAME="${2:-}"
shift 2
;;
--subdomain)
SUBDOMAIN="${2:-}"
shift 2
;;
--repo)
REPO_URL="${2:-}"
shift 2
;;
--base-dir)
BASE_DIR="${2:-}"
shift 2
;;
--domain)
PARENT_DOMAIN="${2:-}"
shift 2
;;
--https-port)
HTTPS_PORT="${2:-}"
shift 2
;;
--http-port)
HTTP_PORT="${2:-}"
shift 2
;;
--tls-mode)
TLS_MODE="${2:-}"
shift 2
;;
--wildcard-cert-file)
WILDCARD_CERT_FILE="${2:-}"
shift 2
;;
--wildcard-key-file)
WILDCARD_KEY_FILE="${2:-}"
shift 2
;;
--app-image-tag)
APP_IMAGE_TAG="${2:-latest}"
shift 2
;;
--library-enabled)
LIBRARY_ENABLED="${2:-0}"
shift 2
;;
*)
fail "Unbekannter Parameter: $1"
;;
esac
done
[ -n "$REPO_URL" ] || fail "Repository-URL fehlt"
[ -n "$BASE_DIR" ] || fail "Basisverzeichnis fehlt"
[ -n "$PARENT_DOMAIN" ] || fail "Parent-Domain fehlt"
if [ -z "$SUBDOMAIN" ]; then
SUBDOMAIN="$(slugify "$SCHOOL_NAME")"
else
SUBDOMAIN="$(slugify "$SUBDOMAIN")"
fi
is_valid_subdomain "$SUBDOMAIN" || fail "Ungueltige Subdomain: $SUBDOMAIN"
INSTANCE_DIR="$BASE_DIR/$SUBDOMAIN"
FULL_DOMAIN="$SUBDOMAIN.$PARENT_DOMAIN"
require_cmd git
require_cmd docker
require_cmd bash
mkdir -p "$BASE_DIR"
if [ -z "$HTTPS_PORT" ]; then
HTTPS_PORT="$(next_free_port 15443)"
fi
if [ -z "$HTTP_PORT" ]; then
HTTP_PORT="$(next_free_port 15080)"
fi
if [ "$ACTION" = "create" ] || [ "$ACTION" = "start" ]; then
setup_or_update_repo "$INSTANCE_DIR" "$REPO_URL"
[ -f "$INSTANCE_DIR/start.sh" ] || fail "start.sh im Zielrepository nicht gefunden: $INSTANCE_DIR"
write_env_file "$INSTANCE_DIR" "$HTTP_PORT" "$HTTPS_PORT" "$APP_IMAGE_TAG"
set_library_enabled "$INSTANCE_DIR" "$LIBRARY_ENABLED"
cd "$INSTANCE_DIR"
normalize_instance_compose
run_instance_start
write_nginx_site "$SUBDOMAIN" "$FULL_DOMAIN" "$HTTPS_PORT"
print_kv "SUBDOMAIN" "$SUBDOMAIN"
print_kv "DOMAIN" "$FULL_DOMAIN"
print_kv "HTTPS_PORT" "$HTTPS_PORT"
print_kv "HTTP_PORT" "$HTTP_PORT"
print_kv "APP_IMAGE_TAG" "$APP_IMAGE_TAG"
print_kv "LIBRARY_ENABLED" "$LIBRARY_ENABLED"
print_kv "INSTANCE_DIR" "$INSTANCE_DIR"
exit 0
fi
fail "Nicht unterstuetzte Action: $ACTION"