#!/usr/bin/env bash set -Eeuo pipefail # ============================================================================= # Invoice Ninja - Remote Deployment Bootstrap # # Phase 2: # - Deployment-Profil festlegen # - Sicherheits-Preflight durchführen # - Synology-ACL für Zielverzeichnis und Kundenkonfiguration einrichten # - Kundenkonfiguration beim ersten Lauf selbst erzeugen # - Kundenkonfiguration validieren # # Noch NICHT enthalten: # - Download eines Releases # - SHA256-Prüfung # - Erzeugen der technischen .env # - Docker Compose # - Starten oder Stoppen von Containern # - Löschen bestehender Docker-Ressourcen # # Aufruf: # # Produktion: # bash bootstrap-invoiceninja.sh production # # Test: # bash bootstrap-invoiceninja.sh test # # ============================================================================= # ----------------------------------------------------------------------------- # Grundkonfiguration # ----------------------------------------------------------------------------- SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" DEPLOYMENT_MODE="${1:-}" PRODUCTION_TARGET="/volume1/docker/invoiceninja" TEST_TARGET="/volume1/docker/invoiceninja_test" PRODUCTION_PROJECT="invoiceninja" TEST_PROJECT="invoiceninja_test" PRODUCTION_PORT="5485" TEST_PORT="5486" CONFIG_FILENAME="kundenkonfiguration.env" SYNOACLTOOL="/usr/syno/bin/synoacltool" EXIT_CONFIG=2 EXIT_SAFETY=3 # ----------------------------------------------------------------------------- # Logging # ----------------------------------------------------------------------------- log() { printf '[INFO] %s\n' "$1" } warn() { printf '[WARN] %s\n' "$1" >&2 } error() { printf '[ERROR] %s\n' "$1" >&2 } die() { error "$1" exit 1 } # ----------------------------------------------------------------------------- # Aufruf prüfen # ----------------------------------------------------------------------------- if (( $# > 1 )); then error "Zu viele Argumente angegeben." printf '[ERROR] Verwendung: %s production|test\n' \ "$(basename "$0")" >&2 exit "$EXIT_CONFIG" fi # ----------------------------------------------------------------------------- # Deployment-Profil # # Der Installationsmodus wird absichtlich NICHT aus der Kundenkonfiguration # gelesen. # # Dadurch können Zielverzeichnis, Compose-Projekt, Port, Bind-Adresse und # HTTPS-Verhalten nicht vom Kunden verändert werden. # ----------------------------------------------------------------------------- case "$DEPLOYMENT_MODE" in production) ACCESS_MODE="reverse_proxy" TARGET_DIR="$PRODUCTION_TARGET" PROJECT_NAME="$PRODUCTION_PROJECT" WEB_BIND_ADDRESS="127.0.0.1" WEB_PORT="$PRODUCTION_PORT" REQUIRE_HTTPS="true" ;; test) ACCESS_MODE="direct" TARGET_DIR="$TEST_TARGET" PROJECT_NAME="$TEST_PROJECT" WEB_BIND_ADDRESS="0.0.0.0" WEB_PORT="$TEST_PORT" REQUIRE_HTTPS="false" ;; "") error "Kein Installationsmodus angegeben." printf '[ERROR] Verwendung: %s production|test\n' \ "$(basename "$0")" >&2 exit "$EXIT_CONFIG" ;; *) error "Ungültiger Installationsmodus: $DEPLOYMENT_MODE" error "Erlaubt sind ausschließlich: production oder test" exit "$EXIT_CONFIG" ;; esac CONFIG_FILE="$TARGET_DIR/$CONFIG_FILENAME" TARGET_PARENT="$(dirname -- "$TARGET_DIR")" # ----------------------------------------------------------------------------- # Kundenkonfiguration erzeugen # # Die Vorlage ist absichtlich direkt im Bootstrap enthalten. # Dadurch benötigt der Kunde beim ersten Start nur dieses eine Skript. # ----------------------------------------------------------------------------- write_customer_config_template() { cat > "$CONFIG_FILE" <<'EOF' # ============================================================================= # Invoice Ninja - Installationskonfiguration # ============================================================================= # # Diese Datei enthält ausschließlich Angaben, die für die Installation # kundenspezifisch festgelegt werden müssen. # # Werte mit müssen angepasst werden. # # Technische Einstellungen und Passwörter für Docker, MariaDB, Redis und # Invoice Ninja werden automatisch festgelegt bzw. erzeugt. # # Diese Datei wird nach erfolgreicher Installation wieder entfernt. # # ============================================================================= # ----------------------------------------------------------------------------- # Öffentliche Adresse # # Produktiv: # APP_URL=https://rechnung.kunde.de # # Interner Test: # APP_URL=http://192.168.1.100:5486 # ----------------------------------------------------------------------------- APP_URL= # ----------------------------------------------------------------------------- # Initialer Invoice-Ninja-Administrator # ----------------------------------------------------------------------------- IN_USER_EMAIL= IN_PASSWORD= # ----------------------------------------------------------------------------- # E-Mail-Versand # # log # Invoice Ninja versendet zunächst keine echten E-Mails. # # smtp # Invoice Ninja verwendet einen SMTP-Server. # # Bei MAIL_MAILER=log werden die SMTP-Felder darunter ignoriert. # # Bei MAIL_MAILER=smtp müssen alle erforderlichen SMTP-Felder ausgefüllt # werden. # ----------------------------------------------------------------------------- MAIL_MAILER=log # ----------------------------------------------------------------------------- # SMTP # # Nur erforderlich, wenn MAIL_MAILER=smtp verwendet wird. # ----------------------------------------------------------------------------- MAIL_HOST= MAIL_PORT=587 MAIL_USERNAME= MAIL_PASSWORD= MAIL_ENCRYPTION=STARTTLS MAIL_FROM_ADDRESS= MAIL_FROM_NAME="Invoice Ninja" EOF } # ----------------------------------------------------------------------------- # Kundenkonfiguration lesen # # Die Datei wird absichtlich NICHT mit "source" geladen. # Dadurch werden Inhalte nicht als Shell-Code ausgeführt. # ----------------------------------------------------------------------------- get_config_value() { local key="$1" awk -F= -v key="$key" ' $1 == key { value = substr($0, index($0, "=") + 1) gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) gsub(/^"|"$/, "", value) print value exit } ' "$CONFIG_FILE" } value_is_missing() { local value="$1" [[ -z "$value" || "$value" == "" ]] } # ----------------------------------------------------------------------------- # Synology-ACL prüfen # ----------------------------------------------------------------------------- has_synology_acl() { local path="$1" local acl_output acl_output="$( "$SYNOACLTOOL" -get "$path" 2>&1 || true )" [[ "$acl_output" == *"ACL version:"* ]] } # ----------------------------------------------------------------------------- # Voraussetzungen # ----------------------------------------------------------------------------- if [[ "${EUID}" -ne 0 ]]; then die "Das Skript muss als root ausgeführt werden." fi command -v awk >/dev/null 2>&1 \ || die "awk wurde nicht gefunden." command -v docker >/dev/null 2>&1 \ || die "Docker wurde nicht gefunden." command -v find >/dev/null 2>&1 \ || die "find wurde nicht gefunden." [[ -x "$SYNOACLTOOL" ]] \ || die "Das Synology-ACL-Werkzeug wurde nicht gefunden: $SYNOACLTOOL" docker info >/dev/null 2>&1 \ || die "Docker ist nicht erreichbar." docker compose version >/dev/null 2>&1 \ || die "Docker Compose v2 wurde nicht gefunden." [[ -d "$TARGET_PARENT" ]] \ || die "Das übergeordnete Zielverzeichnis wurde nicht gefunden: $TARGET_PARENT" has_synology_acl "$TARGET_PARENT" \ || die "Das übergeordnete Zielverzeichnis besitzt keine verwendbare Synology-ACL: $TARGET_PARENT" # ----------------------------------------------------------------------------- # Interne Sicherheitsregeln # ----------------------------------------------------------------------------- if [[ "$DEPLOYMENT_MODE" == "production" ]]; then [[ "$ACCESS_MODE" == "reverse_proxy" ]] \ || die "Interner Sicherheitsfehler bei der Produktiv-Zugriffsart." [[ "$TARGET_DIR" == "$PRODUCTION_TARGET" ]] \ || die "Interner Sicherheitsfehler beim Produktiv-Zielverzeichnis." [[ "$PROJECT_NAME" == "$PRODUCTION_PROJECT" ]] \ || die "Interner Sicherheitsfehler beim Produktiv-Projektnamen." [[ "$WEB_BIND_ADDRESS" == "127.0.0.1" ]] \ || die "Interner Sicherheitsfehler bei der Produktiv-Bind-Adresse." [[ "$WEB_PORT" == "$PRODUCTION_PORT" ]] \ || die "Interner Sicherheitsfehler beim Produktiv-Port." [[ "$REQUIRE_HTTPS" == "true" ]] \ || die "Interner Sicherheitsfehler bei der Produktiv-HTTPS-Konfiguration." fi if [[ "$DEPLOYMENT_MODE" == "test" ]]; then [[ "$ACCESS_MODE" == "direct" ]] \ || die "Interner Sicherheitsfehler bei der Test-Zugriffsart." [[ "$TARGET_DIR" == "$TEST_TARGET" ]] \ || die "Interner Sicherheitsfehler beim Test-Zielverzeichnis." [[ "$PROJECT_NAME" == "$TEST_PROJECT" ]] \ || die "Interner Sicherheitsfehler beim Test-Projektnamen." [[ "$WEB_BIND_ADDRESS" == "0.0.0.0" ]] \ || die "Interner Sicherheitsfehler bei der Test-Bind-Adresse." [[ "$WEB_PORT" == "$TEST_PORT" ]] \ || die "Interner Sicherheitsfehler beim Test-Port." [[ "$REQUIRE_HTTPS" == "false" ]] \ || die "Interner Sicherheitsfehler bei der Test-HTTPS-Konfiguration." [[ "$TARGET_DIR" != "$PRODUCTION_TARGET" ]] \ || die "Eine Testinstallation darf niemals das Produktivverzeichnis verwenden." [[ "$PROJECT_NAME" != "$PRODUCTION_PROJECT" ]] \ || die "Eine Testinstallation darf niemals den Produktiv-Projektnamen verwenden." [[ "$WEB_PORT" != "$PRODUCTION_PORT" ]] \ || die "Eine Testinstallation darf niemals den Produktiv-Port verwenden." fi # ----------------------------------------------------------------------------- # Sicherheitsprüfung des Zielsystems # ----------------------------------------------------------------------------- SAFETY_ERRORS=() add_safety_error() { SAFETY_ERRORS+=("$1") } # ----------------------------------------------------------------------------- # Zielpfad prüfen # ----------------------------------------------------------------------------- if [[ -L "$TARGET_DIR" ]]; then add_safety_error \ "Das Zielverzeichnis ist ein symbolischer Link: $TARGET_DIR" fi if [[ -e "$TARGET_DIR" && ! -d "$TARGET_DIR" ]]; then add_safety_error \ "Der Zielpfad existiert bereits, ist aber kein Verzeichnis: $TARGET_DIR" fi if [[ -L "$CONFIG_FILE" ]]; then add_safety_error \ "Die Kundenkonfiguration ist ein symbolischer Link: $CONFIG_FILE" fi # ----------------------------------------------------------------------------- # Bestehende Laufzeitdaten erkennen # # Eine vorhandene technische Installation wird niemals automatisch # überschrieben oder aufgeräumt. # ----------------------------------------------------------------------------- if [[ -d "$TARGET_DIR" ]]; then [[ -e "$TARGET_DIR/.env" ]] && \ add_safety_error \ "Im Zielverzeichnis existiert bereits eine .env." [[ -e "$TARGET_DIR/docker-compose.yml" ]] && \ add_safety_error \ "Im Zielverzeichnis existiert bereits eine docker-compose.yml." [[ -e "$TARGET_DIR/db" ]] && \ add_safety_error \ "Im Zielverzeichnis existiert bereits db/." [[ -e "$TARGET_DIR/storage" ]] && \ add_safety_error \ "Im Zielverzeichnis existiert bereits storage/." UNEXPECTED_TARGET_CONTENT="$( find "$TARGET_DIR" \ -mindepth 1 \ -maxdepth 1 \ ! -name "$CONFIG_FILENAME" \ -print 2>/dev/null )" if [[ -n "$UNEXPECTED_TARGET_CONTENT" ]]; then add_safety_error \ "Das Zielverzeichnis enthält weitere oder unbekannte Dateien." fi fi # ----------------------------------------------------------------------------- # Bestehendes Docker-Compose-Projekt prüfen # ----------------------------------------------------------------------------- EXISTING_PROJECT_CONTAINERS="$( docker ps -a \ --filter "label=com.docker.compose.project=${PROJECT_NAME}" \ --format '{{.Names}}' )" if [[ -n "$EXISTING_PROJECT_CONTAINERS" ]]; then add_safety_error \ "Das Docker-Compose-Projekt '$PROJECT_NAME' besitzt bereits Container." fi # ----------------------------------------------------------------------------- # Docker-Port prüfen # ----------------------------------------------------------------------------- PORT_CONTAINERS="$( docker ps -a \ --filter "publish=${WEB_PORT}" \ --format '{{.Names}}' )" if [[ -n "$PORT_CONTAINERS" ]]; then add_safety_error \ "Der vorgesehene Docker-Port ${WEB_PORT} wird bereits von einem Container verwendet." fi # ----------------------------------------------------------------------------- # Zusätzlich auf lokal belegten TCP-Port prüfen # ----------------------------------------------------------------------------- PORT_LISTENER="" if command -v ss >/dev/null 2>&1; then PORT_LISTENER="$( ss -ltn 2>/dev/null | awk -v port=":${WEB_PORT}" ' $4 ~ port "$" { listener = $4 } END { if (listener != "") { print listener } } ' )" elif command -v netstat >/dev/null 2>&1; then PORT_LISTENER="$( netstat -ltn 2>/dev/null | awk -v port=":${WEB_PORT}" ' $4 ~ port "$" { listener = $4 } END { if (listener != "") { print listener } } ' )" fi if [[ -n "$PORT_LISTENER" && -z "$PORT_CONTAINERS" ]]; then add_safety_error \ "Der TCP-Port ${WEB_PORT} wird bereits auf dem NAS verwendet." fi # ----------------------------------------------------------------------------- # Ergebnis der Sicherheitsprüfung # ----------------------------------------------------------------------------- if (( ${#SAFETY_ERRORS[@]} > 0 )); then printf '\n' >&2 warn "Die Installation wurde aus Sicherheitsgründen blockiert:" for safety_error in "${SAFETY_ERRORS[@]}"; do printf ' - %s\n' "$safety_error" >&2 done printf '\n' >&2 warn "Bestehende Systeme wurden nicht verändert." warn "Es wurden keine Docker-Ressourcen gelöscht oder gestoppt." exit "$EXIT_SAFETY" fi # ----------------------------------------------------------------------------- # Erster Lauf # # Wenn noch keine Kundenkonfiguration existiert: # # 1. Zielverzeichnis anlegen # 2. Synology-ACL vom Docker-Verzeichnis übernehmen # 3. Kundenkonfiguration direkt aus dem Bootstrap erzeugen # 4. Synology-ACL vom Zielverzeichnis übernehmen # 5. Benutzer zum Bearbeiten auffordern # 6. kontrolliert mit Exit-Code 2 beenden # # Es werden hierbei noch KEINE Docker-Ressourcen erstellt. # ----------------------------------------------------------------------------- if [[ ! -e "$CONFIG_FILE" ]]; then log "Es wurde noch keine Kundenkonfiguration gefunden." log "Die Installationskonfiguration wird vorbereitet." TARGET_CREATED="false" if [[ ! -d "$TARGET_DIR" ]]; then mkdir -- "$TARGET_DIR" TARGET_CREATED="true" if ! "$SYNOACLTOOL" \ -enforce-inherit \ "$TARGET_DIR" >/dev/null 2>&1; then rmdir -- "$TARGET_DIR" 2>/dev/null || true die "Die Synology-ACL konnte für das Zielverzeichnis nicht eingerichtet werden." fi if ! has_synology_acl "$TARGET_DIR"; then rmdir -- "$TARGET_DIR" 2>/dev/null || true die "Das Zielverzeichnis wurde nicht korrekt in den Synology-ACL-Modus übernommen." fi fi # ------------------------------------------------------------------------- # Kundenkonfiguration aus eingebauter Vorlage erzeugen # ------------------------------------------------------------------------- if ! write_customer_config_template; then rm -- "$CONFIG_FILE" 2>/dev/null || true if [[ "$TARGET_CREATED" == "true" ]]; then rmdir -- "$TARGET_DIR" 2>/dev/null || true fi die "Die Kundenkonfiguration konnte nicht erstellt werden." fi # ------------------------------------------------------------------------- # Synology-ACL für die Kundenkonfiguration # ------------------------------------------------------------------------- if ! "$SYNOACLTOOL" \ -enforce-inherit \ "$CONFIG_FILE" >/dev/null 2>&1; then rm -- "$CONFIG_FILE" 2>/dev/null || true if [[ "$TARGET_CREATED" == "true" ]]; then rmdir -- "$TARGET_DIR" 2>/dev/null || true fi die "Die Synology-ACL konnte für die Kundenkonfiguration nicht eingerichtet werden." fi if ! has_synology_acl "$CONFIG_FILE"; then rm -- "$CONFIG_FILE" 2>/dev/null || true if [[ "$TARGET_CREATED" == "true" ]]; then rmdir -- "$TARGET_DIR" 2>/dev/null || true fi die "Die Kundenkonfiguration wurde nicht korrekt in den Synology-ACL-Modus übernommen." fi printf '\n' log "Die Kundenkonfiguration wurde erstellt:" printf '\n' printf ' %s\n' "$CONFIG_FILE" printf '\n' log "Bitte öffnen Sie diese Datei über DSM bzw. die Docker-Freigabe" log "und ersetzen Sie alle erforderlichen Werte mit ." log "Anschließend dieselbe Installationsaufgabe erneut ausführen." printf '\n' log "Hinweis: Die Kundenkonfiguration ist nur für die Installation vorgesehen" log "und wird nach erfolgreicher Installation wieder entfernt." printf '\n' log "Es wurden noch keine Docker-Container oder Docker-Ressourcen erstellt." exit "$EXIT_CONFIG" fi # ----------------------------------------------------------------------------- # Kundenkonfiguration muss eine reguläre Datei sein # ----------------------------------------------------------------------------- if [[ ! -f "$CONFIG_FILE" ]]; then die "Die Kundenkonfiguration ist keine reguläre Datei: $CONFIG_FILE" fi # ----------------------------------------------------------------------------- # Synology-ACL der vorhandenen Kundenkonfiguration sicherstellen # ----------------------------------------------------------------------------- if ! "$SYNOACLTOOL" \ -enforce-inherit \ "$CONFIG_FILE" >/dev/null 2>&1; then die "Die Synology-ACL der Kundenkonfiguration konnte nicht aktualisiert werden." fi if ! has_synology_acl "$CONFIG_FILE"; then die "Die Kundenkonfiguration besitzt keine gültige Synology-ACL." fi # ----------------------------------------------------------------------------- # Kundenkonfiguration einlesen # ----------------------------------------------------------------------------- APP_URL="$(get_config_value APP_URL)" IN_USER_EMAIL="$(get_config_value IN_USER_EMAIL)" IN_PASSWORD="$(get_config_value IN_PASSWORD)" MAIL_MAILER="$(get_config_value MAIL_MAILER)" MAIL_HOST="$(get_config_value MAIL_HOST)" MAIL_PORT="$(get_config_value MAIL_PORT)" MAIL_USERNAME="$(get_config_value MAIL_USERNAME)" MAIL_PASSWORD="$(get_config_value MAIL_PASSWORD)" MAIL_ENCRYPTION="$(get_config_value MAIL_ENCRYPTION)" MAIL_FROM_ADDRESS="$(get_config_value MAIL_FROM_ADDRESS)" MAIL_FROM_NAME="$(get_config_value MAIL_FROM_NAME)" # ----------------------------------------------------------------------------- # Kundenkonfiguration validieren # ----------------------------------------------------------------------------- CONFIG_ERRORS=() add_config_error() { CONFIG_ERRORS+=("$1") } require_value() { local key="$1" local value="$2" if value_is_missing "$value"; then add_config_error "$key ist nicht gesetzt." fi } require_value "APP_URL" "$APP_URL" require_value "IN_USER_EMAIL" "$IN_USER_EMAIL" require_value "IN_PASSWORD" "$IN_PASSWORD" require_value "MAIL_MAILER" "$MAIL_MAILER" # ----------------------------------------------------------------------------- # Mail-Konfiguration prüfen # ----------------------------------------------------------------------------- if ! value_is_missing "$MAIL_MAILER"; then case "$MAIL_MAILER" in log) # SMTP-Werte werden bewusst ignoriert. ;; smtp) require_value "MAIL_HOST" "$MAIL_HOST" require_value "MAIL_PORT" "$MAIL_PORT" require_value "MAIL_USERNAME" "$MAIL_USERNAME" require_value "MAIL_PASSWORD" "$MAIL_PASSWORD" require_value "MAIL_ENCRYPTION" "$MAIL_ENCRYPTION" require_value "MAIL_FROM_ADDRESS" "$MAIL_FROM_ADDRESS" require_value "MAIL_FROM_NAME" "$MAIL_FROM_NAME" if ! value_is_missing "$MAIL_PORT"; then if [[ ! "$MAIL_PORT" =~ ^[0-9]+$ ]]; then add_config_error \ "MAIL_PORT muss eine gültige Portnummer sein." elif (( MAIL_PORT < 1 || MAIL_PORT > 65535 )); then add_config_error \ "MAIL_PORT muss zwischen 1 und 65535 liegen." fi fi ;; *) add_config_error \ "MAIL_MAILER muss 'log' oder 'smtp' sein." ;; esac fi # ----------------------------------------------------------------------------- # APP_URL auf Deployment-Profil prüfen # ----------------------------------------------------------------------------- case "$DEPLOYMENT_MODE" in production) if [[ "$APP_URL" != https://* ]]; then add_config_error \ "Eine produktive Installation benötigt eine HTTPS-APP_URL." fi ;; test) if [[ "$APP_URL" != http://* ]]; then add_config_error \ "Eine Testinstallation benötigt eine HTTP-APP_URL." fi if [[ "$APP_URL" != *":${WEB_PORT}"* ]]; then add_config_error \ "Bei einer Testinstallation muss APP_URL den Port ${WEB_PORT} enthalten." fi ;; esac # ----------------------------------------------------------------------------- # Bei fehlerhafter Konfiguration abbrechen # ----------------------------------------------------------------------------- if (( ${#CONFIG_ERRORS[@]} > 0 )); then printf '\n' >&2 warn "Die Kundenkonfiguration ist noch nicht vollständig oder ungültig:" for config_error in "${CONFIG_ERRORS[@]}"; do printf ' - %s\n' "$config_error" >&2 done printf '\n' >&2 warn "Bitte bearbeiten Sie:" printf ' %s\n' "$CONFIG_FILE" >&2 printf '\n' >&2 warn "Es wurden keine Docker-Ressourcen erstellt, gelöscht oder gestoppt." exit "$EXIT_CONFIG" fi # ----------------------------------------------------------------------------- # Preflight-Zusammenfassung # # Keine Passwörter oder Zugangsdaten ausgeben. # ----------------------------------------------------------------------------- printf '\n' log "Preflight-Prüfung erfolgreich." printf '\n' printf ' Installationsmodus : %s\n' "$DEPLOYMENT_MODE" printf ' Zugriffsart : %s\n' "$ACCESS_MODE" printf ' Zielverzeichnis : %s\n' "$TARGET_DIR" printf ' Compose-Projekt : %s\n' "$PROJECT_NAME" printf ' Web-Bind-Adresse : %s\n' "$WEB_BIND_ADDRESS" printf ' Web-Port : %s\n' "$WEB_PORT" printf ' HTTPS erforderlich : %s\n' "$REQUIRE_HTTPS" printf ' APP_URL : %s\n' "$APP_URL" printf ' Mail-Modus : %s\n' "$MAIL_MAILER" printf '\n' # ----------------------------------------------------------------------------- # Bei Testinstallation vorhandenes PROD-Projekt nur anzeigen # ----------------------------------------------------------------------------- if [[ "$DEPLOYMENT_MODE" == "test" ]]; then PROD_CONTAINERS="$( docker ps -a \ --filter "label=com.docker.compose.project=${PRODUCTION_PROJECT}" \ --format '{{.Names}}' )" if [[ -n "$PROD_CONTAINERS" ]]; then log "Bestehendes Produktivprojekt '$PRODUCTION_PROJECT' wurde erkannt." log "Das Produktivprojekt wird vom Test nicht angesprochen." fi fi printf '\n' log "Alle Preflight-Prüfungen wurden erfolgreich abgeschlossen." log "Die Kundenkonfiguration ist vollständig." log "Es wurden noch keine Docker-Container oder Docker-Ressourcen erstellt."