#!/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" RELEASE_BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz" RELEASE_IMAGE_ASSET_PREFIX="inventarsystem-image-" 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" < 4: continue in_build = False if re.match(r"^\s{4}build:\s*$", line): in_build = True continue if re.match(r"^\s{4}image:\s*", line): if image_set: continue out.append(f" image: {target_image}\n") image_set = True continue out.append(line) with open(compose_file, "w", encoding="utf-8") as fh: fh.writelines(out) PY done } install_from_release() { local target_dir="$1" local repo_url="$2" local requested_tag="$3" local tmp_dir="" local repo_slug="" local meta_file="" local bundle_path="" local image_path="" local tag="" local bundle_url="" local image_url="" repo_slug="$(repo_slug_from_url "$repo_url")" || fail "Repository-URL nicht unterstützbar für Release-Install: $repo_url" tmp_dir="$(mktemp -d)" meta_file="$tmp_dir/release.json" if ! fetch_release_metadata "$repo_slug" "$requested_tag" "$meta_file"; then fail "Release-Metadaten konnten nicht geladen werden (Repo: $repo_slug, Tag: $requested_tag)." fi mapfile -t release_info < <(extract_release_info "$meta_file") tag="${release_info[0]:-}" bundle_url="${release_info[1]:-}" image_url="${release_info[2]:-}" [ -n "$tag" ] || fail "Release-Metadaten enthalten keinen gültigen Tag." [ -n "$bundle_url" ] || fail "Release-Asset fehlt: $RELEASE_BUNDLE_ASSET" [ -n "$image_url" ] || fail "Release-Image-Asset fehlt: ${RELEASE_IMAGE_ASSET_PREFIX}${tag}.tar.gz" bundle_path="$tmp_dir/$RELEASE_BUNDLE_ASSET" image_path="$tmp_dir/${RELEASE_IMAGE_ASSET_PREFIX}${tag}.tar.gz" curl -fsSL "$bundle_url" -o "$bundle_path" || fail "Release-Bundle konnte nicht geladen werden." curl -fsSL "$image_url" -o "$image_path" || fail "Release-Image konnte nicht geladen werden." mkdir -p "$target_dir" tar -xzf "$bundle_path" -C "$target_dir" || fail "Release-Bundle konnte nicht entpackt werden." docker load -i "$image_path" >/dev/null 2>&1 || fail "Docker-Image konnte nicht geladen werden." docker tag "ghcr.io/aiirondev/legendary-octo-garbanzo:$tag" "ghcr.io/aiirondev/legendary-octo-garbanzo:latest" >/dev/null 2>&1 || true pin_compose_app_image "$target_dir" "$tag" rm -rf "$tmp_dir" >/dev/null 2>&1 || true print_kv "APP_IMAGE_TAG" "$tag" } ensure_requested_app_image() { local app_image_tag="$1" local app_image="ghcr.io/aiirondev/legendary-octo-garbanzo:$app_image_tag" if docker image inspect "$app_image" >/dev/null 2>&1; then return 0 fi if ! docker pull "$app_image" >/dev/null 2>&1; then return 1 fi return 0 } 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="" for compose_file in "docker-compose.yml" "docker-compose-multitenant.yml"; do [ -f "$compose_file" ] || continue python3 - <<'PY' "$compose_file" from pathlib import Path import sys path = Path(sys.argv[1]) content = path.read_text(encoding="utf-8") lines = content.splitlines() out = [] in_nginx = False in_ports = False ports_indent = "" in_app = False seen_app_image = 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 in_ports = False out.append(line) i += 1 continue if indent == 2 and stripped.endswith(":"): in_app = stripped.startswith("app:") seen_app_image = False 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_app and indent == 4 and stripped.startswith("image:"): if seen_app_image: i += 1 continue seen_app_image = True 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 done } run_instance_start() { local start_output local retry_output local update_output local compose_file local has_redis=0 compose_file="$(preferred_compose_file)" if [ -f "$compose_file" ] && grep -Eq '^\s{2}redis:\s*$' "$compose_file"; then has_redis=1 fi stack_is_running() { local running_services running_services="$(docker compose -f "$compose_file" --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 if [ "$has_redis" = "1" ]; then printf '%s\n' "$running_services" | grep -Fxq redis || return 1 fi return 0 } if [ "$compose_file" = "docker-compose-multitenant.yml" ]; then if start_output="$(docker compose -f "$compose_file" --env-file .docker-build.env up -d --remove-orphans 2>&1)"; then print_kv "MESSAGE" "Instanz gestartet (Inventarsystem Multiinstancing aktiv)." return 0 fi if stack_is_running; then print_kv "MESSAGE" "Instanz gestartet (Multiinstancing läuft, Healthcheck im Startskript war nicht erreichbar)." return 0 fi if printf '%s' "$start_output" | grep -qi "local app image not found"; then if [ ! -x ./update.sh ]; then fail "Multiinstancing-Start 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, Multiinstancing-Dienste laufen)." return 0 fi fail "update.sh fehlgeschlagen: $(printf '%s' "$update_output" | tail -n1)" fi if retry_output="$(docker compose -f "$compose_file" --env-file .docker-build.env up -d --remove-orphans 2>&1)"; then print_kv "MESSAGE" "Instanz gestartet (Multiinstancing-Image automatisch per update.sh geladen)." return 0 fi fail "Multiinstancing-Start nach update.sh fehlgeschlagen: $(printf '%s' "$retry_output" | tail -n1)" fi fail "Multiinstancing-Start fehlgeschlagen: $(printf '%s' "$start_output" | tail -n1)" fi 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" local app_image_tag="$3" if [ -f "$target_dir/start.sh" ]; then # Existing installation: keep update flow inside Inventarsystem tooling. if [ "$app_image_tag" = "latest" ] && [ -x "$target_dir/update.sh" ]; then (cd "$target_dir" && bash ./update.sh >/dev/null 2>&1 || true) fi 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 install_from_release "$target_dir" "$repo_url" "$app_image_tag" } 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" </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 docker require_cmd bash require_cmd curl require_cmd tar require_cmd python3 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" "$APP_IMAGE_TAG" [ -f "$INSTANCE_DIR/start.sh" ] || fail "start.sh im Zielrepository nicht gefunden: $INSTANCE_DIR" if ! ensure_requested_app_image "$APP_IMAGE_TAG"; then fail "App-Image konnte nicht geladen werden: ghcr.io/aiirondev/legendary-octo-garbanzo:$APP_IMAGE_TAG" fi 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"