#!/bin/env bash set -e # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Function to print colored output print_status() { echo -e "${GREEN}✅ $1${NC}" } print_warning() { echo -e "${YELLOW}⚠️ $1${NC}" } print_error() { echo -e "${RED}❌ $1${NC}" } print_info() { echo -e "${BLUE}ℹ️ $1${NC}" } # Return "--network " if it exists, otherwise nothing compose_network_flag() { local name name="$(docker network ls --format '{{.Name}}' | awk -v d="$(basename "$PWD")" '$0==d"_default"{print; exit}')" || true if [[ -n "$name" ]]; then echo --network "$name" fi } # Guard: cancel if an external S3 is already configured (no bundled MinIO service) external_s3_guard() { local acc sec acc=$(read_compose_value "S3_ACCESS_KEY") sec=$(read_compose_value "S3_SECRET_KEY") # If S3 creds are present and no bundled MinIO service, assume external S3/AWS is in use if [[ -n "$acc" && -n "$sec" ]]; then if ! has_service minio; then print_warning "Detected existing S3 credentials in docker-compose.yml and no bundled MinIO service." print_error "This migration is intended only for setups using local uploads." print_info "No files were migrated. If you already use external S3 (incl. AWS), you don't need this migration." exit 0 fi fi } # Function to cleanup sensitive output from terminal and history (best effort) cleanup_sensitive_output() { echo "" print_warning "⚠️ SECURITY NOTICE: Sensitive credentials were displayed above." print_info "Please copy and securely save these credentials before proceeding." echo "" if [ -t 0 ] && [ -t 1 ]; then echo -n "After you have safely saved the credentials, press Enter to clear the terminal and command history: " read -r # Clear the terminal screen and scrollback buffer (best effort across terminals) clear 2>/dev/null || true printf '\033[3J' 2>/dev/null || true # Clear bash history for current session (no-op in non-interactive shells) history -c 2>/dev/null || true history -w 2>/dev/null || true print_status "✅ Terminal and command history cleared for security." print_info "The credentials are no longer visible in this terminal session." else print_info "Non-interactive session detected. Skipping terminal/history cleanup." fi print_warning "Remember: The credentials are still in your docker-compose.yml file - keep it secure!" echo "" } # Mode flags (set interactively) FORCE_RECONFIGURE=false MIGRATE_ONLY=false REGENERATE_CREDS=false # Quietly detect uploads path inside the running formbricks container (returns only the path or empty) get_container_uploads_path_quiet() { # Prefer explicit container target from compose, else UPLOADS_DIR (absolute), else legacy local target target=$(get_uploads_container_target) local env_path env_path=$(read_compose_value "UPLOADS_DIR") local path if [[ -n "$target" ]]; then path="$target" elif [[ -n "$env_path" && "$env_path" =~ ^/ ]]; then path="$env_path" else path="/home/nextjs/apps/web/uploads" fi local max_attempts=12 local attempt=1 while [[ $attempt -le $max_attempts ]]; do if docker compose ps -q formbricks >/dev/null 2>&1; then if docker compose exec -T formbricks test -d "$path" 2>/dev/null; then echo "container:$path" return 0 fi fi sleep 2 ((attempt++)) done echo "" } # Collect multiple candidate upload sources (newline-separated), de-duplicated collect_upload_sources() { declare -A seen local out=() local env_path env_path=$(read_compose_value "UPLOADS_DIR") local mount_target mount_target=$(get_uploads_container_target) # 1) container mount target (from compose) if [[ -n "$mount_target" ]]; then local key="container:$mount_target" if [[ -z "${seen[$key]}" ]]; then out+=("$key"); seen[$key]=1; fi fi # 2) container UPLOADS_DIR (absolute) if [[ -n "$env_path" && "$env_path" =~ ^/ ]]; then local key="container:$env_path" if [[ -z "${seen[$key]}" ]]; then out+=("$key"); seen[$key]=1; fi fi # 3) legacy container path local legacy="container:/home/nextjs/apps/web/uploads" if [[ -z "${seen[$legacy]}" ]]; then out+=("$legacy"); seen[$legacy]=1; fi # 4) host UPLOADS_DIR if exists if [[ -n "$env_path" && ! "$env_path" =~ ^/ ]]; then if [[ -d "$env_path" ]]; then if [[ -z "${seen[$env_path]}" ]]; then out+=("$env_path"); seen[$env_path]=1; fi elif [[ -d "./$env_path" ]]; then local hp="./$env_path" if [[ -z "${seen[$hp]}" ]]; then out+=("$hp"); seen[$hp]=1; fi fi fi # 5) fallback host paths if [[ -d "./apps/web/uploads" && -z "${seen[./apps/web/uploads]}" ]]; then out+=("./apps/web/uploads"); seen[./apps/web/uploads]=1; fi if [[ -d "./uploads" && -z "${seen[./uploads]}" ]]; then out+=("./uploads"); seen[./uploads]=1; fi for s in "${out[@]}"; do echo "$s"; done } # Collect upload sources from rendered compose (post-start only) collect_upload_sources_post_start() { declare -A seen local out=() local container_present=0 local env_path env_path=$(read_compose_value "UPLOADS_DIR") # Parse rendered compose volumes for formbricks service (write awk program to a tmp file to avoid any escape/ANSI issues) local __awk_tmp__ __awk_tmp__=$(mktemp) cat > "$__awk_tmp__" <<'AWK_EOF' /^ formbricks:/ { in_svc=1; next } /^ [A-Za-z0-9_-]+:/ && !/^ formbricks:/ { in_svc=0 } in_svc && /^ volumes:/ { in_vol=1; next } in_svc && /^ [A-Za-z0-9_-]+:/ { if(in_vol) in_vol=0 } in_vol { if ($0 ~ /^[[:space:]]*-[[:space:]]*type:/){ tp=$0; sub(/.*type:[[:space:]]*/, "", tp); src=""; tgt="" } else if ($0 ~ /source:/){ line=$0; sub(/^[^:]*:[[:space:]]*/, "", line); src=line } else if ($0 ~ /target:/){ line=$0; sub(/^[^:]*:[[:space:]]*/, "", line); tgt=line; if (tp!="" && tgt!="") printf "%s|%s|%s\n", tp, src, tgt } } AWK_EOF local entries entries=$(docker compose config 2>/dev/null | awk -f "$__awk_tmp__") rm -f "$__awk_tmp__" # From entries, add bind mounts to uploads (host path) and the uploads named volume target (container path) while IFS= read -r e; do [[ -z "$e" ]] && continue local tp src tgt rest tp="${e%%|*}"; rest="${e#*|}"; src="${rest%%|*}"; tgt="${rest##*|}" if [[ "$tp" == "bind" && "$tgt" == *"/uploads"* ]]; then # docker compose config resolves ./ to absolute paths if [[ -z "${seen[$src]}" ]]; then out+=("$src"); seen[$src]=1; fi fi if [[ "$tp" == "volume" && "$src" == "uploads" ]]; then local key="container:$tgt" if [[ -z "${seen[$key]}" ]]; then out+=("$key"); seen[$key]=1; fi container_present=1 fi done <<< "$entries" # Also include absolute UPLOADS_DIR as container path if [[ -n "$env_path" && "$env_path" =~ ^/ ]]; then local key="container:$env_path" if [[ -z "${seen[$key]}" ]]; then out+=("$key"); seen[$key]=1; fi container_present=1 fi # Legacy fallback only if no container source was detected if [[ $container_present -eq 0 ]]; then local legacy="container:/home/nextjs/apps/web/uploads" if [[ -z "${seen[$legacy]}" ]]; then out+=("$legacy"); seen[$legacy]=1; fi fi for s in "${out[@]}"; do echo "$s"; done } # Preview counts for each source preview_upload_sources() { local sources="$1" echo "" echo "📋 Migration sources preview:" while IFS= read -r src; do [[ -z "$src" ]] && continue if [[ "$src" == container:* ]]; then local p="${src#container:}" local cnt cnt=$(docker compose exec -T formbricks sh -lc 'find '"$p"' -type f 2>/dev/null | wc -l' || echo 0) echo " - $src → $cnt files" else local cnt cnt=$(find "$src" -type f 2>/dev/null | wc -l || echo 0) echo " - $src (host) → $cnt files" fi done <<< "$sources" } # Migrate a generic source path (container:* or host) migrate_from_source() { local src="$1" if [[ "$src" == container:* ]]; then local p="${src#container:}" if docker compose exec -T formbricks test -d "$p" 2>/dev/null; then migrate_container_files_to_minio "$p" else print_warning "Container path not found, skipping: $p" fi else if [[ -d "$src" ]]; then migrate_files_to_minio "$src" else print_warning "Host path not found, skipping: $src" fi fi } # Helper: read a simple KEY: value (quoted or unquoted) from docker-compose.yml read_compose_value() { local key="$1" # Prefer quoted value local val val=$(sed -n "s/^[[:space:]]*$key:[[:space:]]*\"\(.*\)\"[[:space:]]*$/\1/p" docker-compose.yml | head -n 1) if [[ -z "$val" ]]; then val=$(sed -n "s/^[[:space:]]*$key:[[:space:]]*\([^#][^[:space:]]*\)[[:space:]]*$/\1/p" docker-compose.yml | head -n 1) fi echo "$val" } # Read container mount target for the named volume `uploads` under the formbricks service (e.g., /home/nextjs/apps/web/uploads) get_uploads_container_target() { docker compose config 2>/dev/null | awk ' /^ formbricks:/ { in_svc=1; next } /^ [A-Za-z0-9_-]+:/ && !/^ formbricks:/ { in_svc=0 } in_svc && /^ volumes:/ { in_vol=1; next } in_svc && /^ [A-Za-z0-9_-]+:/ { if(in_vol) in_vol=0 } in_vol { if ($0 ~ /source:[[:space:]]*uploads[[:space:]]*$/) { seen_src=1 } else if (seen_src && $0 ~ /target:[[:space:]]*/) { sub(/^[^:]*:[[:space:]]*/, "", $0); print; exit } # reset seen_src when a new entry starts if ($0 ~ /^[[:space:]]*-[[:space:]]*type:/ && seen_src && !found) { seen_src=0 } } ' } # Idempotency helpers has_service() { grep -q "^ $1:[[:space:]]*$" docker-compose.yml } add_or_replace_env_var() { local key="$1"; local value="$2"; local section="${3:-STORAGE}" if grep -q "^[[:space:]]*$key:" docker-compose.yml; then # Replace existing uncommented key if sed --version >/dev/null 2>&1; then sed -i "s|^\([[:space:]]*$key:\).*|\1 \"$value\"|" docker-compose.yml; else sed -i '' "s|^\([[:space:]]*$key:\).*|\1 \"$value\"|" docker-compose.yml; fi elif grep -q "^[[:space:]]*#[[:space:]]*$key:" docker-compose.yml; then # Uncomment placeholder and set if sed --version >/dev/null 2>&1; then sed -i "s|^[[:space:]]*#[[:space:]]*$key:.*| $key: \"$value\"|" docker-compose.yml; else sed -i '' "s|^[[:space:]]*#[[:space:]]*$key:.*| $key: \"$value\"|" docker-compose.yml; fi else # Add to specified section with fallback local section_found=false if [[ "$section" == "REQUIRED" ]] && grep -q -E "^[[:space:]]*#+[[:space:]]*REQUIRED[[:space:]]*#+[[:space:]]*$" docker-compose.yml; then # Add to REQUIRED section awk -v insert_key="$key" -v insert_val="$value" ' /^[[:space:]]*#+[[:space:]]*REQUIRED[[:space:]]*#+[[:space:]]*$/ {print; in_required=1; next} in_required && /^[[:space:]]*#+.*OPTIONAL/ && !printed { print ""; print " # " insert_key " (required for Formbricks 4.0)"; print " " insert_key ": \"" insert_val "\""; printed=1 } { print } END { if(in_required && !printed) { print ""; print " # " insert_key " (required for Formbricks 4.0)"; print " " insert_key ": \"" insert_val "\"" } } ' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml section_found=true elif [[ "$section" == "STORAGE" ]] && grep -q -E "^[[:space:]]*#+[[:space:]]*OPTIONAL[[:space:]]*\\([[:space:]]*STORAGE[[:space:]]*\\)[[:space:]]*#+[[:space:]]*$" docker-compose.yml; then # Add to STORAGE section (original behavior) awk -v insert_key="$key" -v insert_val="$value" ' BEGIN{printed=0} /^[[:space:]]*#+[[:space:]]*OPTIONAL[[:space:]]*\([[:space:]]*STORAGE[[:space:]]*\)[[:space:]]*#+[[:space:]]*$/ {print; in_storage=1; next} in_storage && /^[[:space:]]*#+.*OPTIONAL.*OAUTH/ && !printed { print " " insert_key ": \"" insert_val "\""; printed=1; print; in_storage=0; next } { print } END { if(in_storage && !printed) print " " insert_key ": \"" insert_val "\"" } ' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml section_found=true fi # Fallback: add at the end of environment block if section not found if [[ "$section_found" == false ]]; then awk -v insert_key="$key" -v insert_val="$value" ' /^ environment:/ {print; in_env=1; next} in_env && /^[^[:space:]]/ { if (!printed) { print " " insert_key ": \"" insert_val "\""; printed=1 } in_env=0 } { print } END { if(in_env && !printed) print " " insert_key ": \"" insert_val "\"" } ' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml fi fi } # Function to check if we're in the correct directory check_formbricks_directory() { # Case 1: docker-compose.yml in current directory if [[ -f "docker-compose.yml" ]]; then if grep -q "formbricks" docker-compose.yml; then return 0 else print_error "This doesn't appear to be a Formbricks docker-compose.yml file!" exit 1 fi fi # Case 2: one-click setup parent directory containing ./formbricks/docker-compose.yml if [[ -f "formbricks/docker-compose.yml" ]]; then cd formbricks print_status "Detected one-click setup layout. Switched to ./formbricks directory." if ! grep -q "formbricks" docker-compose.yml; then print_error "This doesn't appear to be a Formbricks docker-compose.yml file!" exit 1 fi return 0 fi # Neither current directory nor ./formbricks contains a compose file print_error "docker-compose.yml not found in current directory or in ./formbricks/" print_info "Run this script from the parent directory created by the one-click setup (containing ./formbricks/), or from the directory containing docker-compose.yml." exit 1 } # Function to backup existing docker-compose.yml backup_docker_compose() { local backup_file="docker-compose.yml.backup.$(date +%Y%m%d_%H%M%S)" cp docker-compose.yml "$backup_file" print_status "Backed up docker-compose.yml to $backup_file" } # Function to check if MinIO is already configured check_minio_configured() { if grep -q "minio:" docker-compose.yml; then # Respect explicit CLI flags first if [[ "$FORCE_RECONFIGURE" == true ]]; then print_info "MinIO already configured; proceeding with reconfiguration (flag)." return 0 fi if [[ "$MIGRATE_ONLY" == true ]]; then print_info "MinIO already configured; proceeding with files migration only (flag)." return 0 fi print_warning "MinIO appears to already be configured in docker-compose.yml" echo -n "Reconfigure MinIO and rerun the full migration? [y/N]: " read ans ans=$(echo "$ans" | tr '[:upper:]' '[:lower:]') if [[ "$ans" == "y" ]]; then FORCE_RECONFIGURE=true REGENERATE_CREDS=true return 0 fi echo -n "Run files migration only (skip MinIO reconfiguration)? [Y/n]: " read only_mig only_mig=$(echo "$only_mig" | tr '[:upper:]' '[:lower:]') if [[ -z "$only_mig" || "$only_mig" == "y" ]]; then MIGRATE_ONLY=true return 0 fi print_info "Operation cancelled by user." exit 0 fi } # Remove existing minio and minio-init service blocks from docker-compose.yml remove_minio_services() { awk ' BEGIN{in_svc=0; skip=0} /^services:[[:space:]]*$/ {print; next} /^ minio:/ {skip=1; next} /^ minio-init:/ {skip=1; next} /^ [A-Za-z0-9_-]+:/ { if(skip){ skip=0 } } { if(!skip) print } ' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml print_status "Removed existing MinIO services from docker-compose.yml" } # Function to generate MinIO credentials generate_minio_credentials() { # Reuse existing credentials if present (idempotent), otherwise generate new local existing_s3_access existing_s3_secret existing_bucket existing_root_user existing_root_password existing_service_user existing_service_password existing_s3_access=$(read_compose_value "S3_ACCESS_KEY") existing_s3_secret=$(read_compose_value "S3_SECRET_KEY") existing_bucket=$(read_compose_value "S3_BUCKET_NAME") existing_root_user=$(read_compose_value "MINIO_ROOT_USER") existing_root_password=$(read_compose_value "MINIO_ROOT_PASSWORD") existing_service_user=$(read_compose_value "MINIO_SERVICE_USER") existing_service_password=$(read_compose_value "MINIO_SERVICE_PASSWORD") # Service account (used by Formbricks) — prefer existing S3_* first, then existing MINIO_SERVICE_*, else generate if [[ "$REGENERATE_CREDS" == true ]]; then minio_service_user="formbricks-service-$(openssl rand -hex 4)" minio_service_password=$(openssl rand -base64 20) elif [[ -n "$existing_s3_access" && -n "$existing_s3_secret" ]]; then minio_service_user="$existing_s3_access" minio_service_password="$existing_s3_secret" elif [[ -n "$existing_service_user" && -n "$existing_service_password" ]]; then minio_service_user="$existing_service_user" minio_service_password="$existing_service_password" else minio_service_user="formbricks-service-$(openssl rand -hex 4)" minio_service_password=$(openssl rand -base64 20) fi # Bucket — prefer existing S3 bucket name if [[ -n "$existing_bucket" ]]; then minio_bucket_name="$existing_bucket" else minio_bucket_name="formbricks-uploads" fi # Root credentials for MinIO server — prefer existing if present if [[ "$REGENERATE_CREDS" == true ]]; then minio_root_user="formbricks-$(openssl rand -hex 4)" minio_root_password=$(openssl rand -base64 20) elif [[ -n "$existing_root_user" && -n "$existing_root_password" ]]; then minio_root_user="$existing_root_user" minio_root_password="$existing_root_password" else minio_root_user="formbricks-$(openssl rand -hex 4)" minio_root_password=$(openssl rand -base64 20) fi # Shared policy across rotations for simplicity and no dangling policies minio_policy_name="formbricks-policy" if [[ "$REGENERATE_CREDS" == true ]]; then prev_service_user="${existing_s3_access:-$existing_service_user}" else prev_service_user="" fi if [[ "$MIGRATE_ONLY" != true || "$REGENERATE_CREDS" == true ]]; then print_status "Generated MinIO credentials" print_info "Root User: $minio_root_user" print_info "Service User: $minio_service_user" print_info "Bucket: $minio_bucket_name" fi } # Function to detect HTTPS setup detect_https_setup() { if grep -q "websecure" docker-compose.yml || grep -q "certresolver" docker-compose.yml; then echo "y" else echo "n" fi } # Function to get main domain from docker-compose.yml get_main_domain() { local domain="" if grep -q "WEBAPP_URL:" docker-compose.yml; then # Extract URL value (handle both quoted and unquoted) domain=$(grep "WEBAPP_URL:" docker-compose.yml | sed 's/.*WEBAPP_URL: *"\([^"]*\)".*/\1/' 2>/dev/null) # Fallback for unquoted values if [[ -z "$domain" ]]; then domain=$(grep "WEBAPP_URL:" docker-compose.yml | sed 's/.*WEBAPP_URL: *\([^[:space:]]*\).*/\1/') fi # Remove protocol (BSD sed compatible) domain=$(echo "$domain" | sed -e 's|https://||' -e 's|http://||') echo "$domain" else echo "" fi } # Function to add S3 environment variables add_s3_environment_variables() { local files_domain="$1" local https_setup="$2" # Determine S3 endpoint URL based on HTTPS setup local s3_endpoint_url="" if [[ "$https_setup" == "y" ]]; then s3_endpoint_url="https://$files_domain" else s3_endpoint_url="http://$files_domain" fi # Idempotently set/update S3 environment variables add_or_replace_env_var "S3_ACCESS_KEY" "$minio_service_user" add_or_replace_env_var "S3_SECRET_KEY" "$minio_service_password" add_or_replace_env_var "S3_REGION" "us-east-1" add_or_replace_env_var "S3_BUCKET_NAME" "$minio_bucket_name" add_or_replace_env_var "S3_ENDPOINT_URL" "$s3_endpoint_url" add_or_replace_env_var "S3_FORCE_PATH_STYLE" "1" print_status "S3 environment variables ensured in docker-compose.yml" } # Function to add MinIO service to docker-compose.yml add_minio_service() { local files_domain="$1" local main_domain="$2" local https_setup="$3" # Skip injecting if services already exist if has_service minio && has_service minio-init; then print_info "MinIO services already present. Skipping service injection." return 0 fi # Create MinIO service configuration local minio_service_config="" if [[ "$https_setup" == "y" ]]; then traefik_entrypoints="websecure"; cors_origin="https://$main_domain"; tls_block=$' - "traefik.http.routers.minio-s3.tls=true"\n - "traefik.http.routers.minio-s3.tls.certresolver=default"' else traefik_entrypoints="web"; cors_origin="http://$main_domain"; tls_block="" fi minio_service_config=" minio: restart: always image: minio/minio@sha256:13582eff79c6605a2d315bdd0e70164142ea7e98fc8411e9e10d089502a6d883 command: server /data environment: MINIO_ROOT_USER: \"$minio_root_user\" MINIO_ROOT_PASSWORD: \"$minio_root_password\" volumes: - minio-data:/data labels: - \"traefik.enable=true\" - \"traefik.http.routers.minio-s3.rule=Host(\`$files_domain\`)\" - \"traefik.http.routers.minio-s3.entrypoints=$traefik_entrypoints\" ${tls_block} - \"traefik.http.routers.minio-s3.service=minio-s3\" - \"traefik.http.services.minio-s3.loadbalancer.server.port=9000\" - \"traefik.http.routers.minio-s3.middlewares=minio-cors,minio-ratelimit\" - \"traefik.http.middlewares.minio-cors.headers.accesscontrolallowmethods=GET,PUT,POST,DELETE,HEAD,OPTIONS\" - \"traefik.http.middlewares.minio-cors.headers.accesscontrolallowheaders=*\" - \"traefik.http.middlewares.minio-cors.headers.accesscontrolalloworiginlist=$cors_origin\" - \"traefik.http.middlewares.minio-cors.headers.accesscontrolmaxage=100\" - \"traefik.http.middlewares.minio-cors.headers.addvaryheader=true\" - \"traefik.http.middlewares.minio-ratelimit.ratelimit.average=100\" - \"traefik.http.middlewares.minio-ratelimit.ratelimit.burst=200\" minio-init: image: minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 depends_on: - minio environment: MINIO_ROOT_USER: \"$minio_root_user\" MINIO_ROOT_PASSWORD: \"$minio_root_password\" MINIO_SERVICE_USER: \"$minio_service_user\" MINIO_SERVICE_PASSWORD: \"$minio_service_password\" MINIO_BUCKET_NAME: \"$minio_bucket_name\" entrypoint: - /bin/sh - -c - | echo '⏳ Waiting for MinIO to be ready...'; attempts=0 max_attempts=30 until mc alias set minio http://minio:9000 "$minio_root_user" "$minio_root_password" >/dev/null 2>&1 \ && mc ls minio >/dev/null 2>&1; do attempts=$((attempts + 1)) if [ $attempts -ge $max_attempts ]; then printf '❌ Failed to connect to MinIO after %s attempts\n' "$max_attempts" exit 1 fi printf '...still waiting attempt %s/%s\n' "$attempts" "$max_attempts" sleep 2 done echo '🔗 MinIO reachable; alias configured.'; echo '🪣 Creating bucket (idempotent)...'; mc mb minio/$minio_bucket_name --ignore-existing; echo '📄 Creating JSON policy file...'; cat > /tmp/formbricks-policy.json << 'POLICY_EOF' { \"Version\": \"2012-10-17\", \"Statement\": [ { \"Effect\": \"Allow\", \"Action\": [\"s3:DeleteObject\", \"s3:GetObject\", \"s3:PutObject\"], \"Resource\": [\"arn:aws:s3:::$minio_bucket_name/*\"] }, { \"Effect\": \"Allow\", \"Action\": [\"s3:ListBucket\"], \"Resource\": [\"arn:aws:s3:::$minio_bucket_name\"] } ] } POLICY_EOF echo '🔒 Creating policy (idempotent)...'; if ! mc admin policy info minio $minio_policy_name >/dev/null 2>&1; then mc admin policy create minio $minio_policy_name /tmp/formbricks-policy.json || true; echo 'Policy created successfully.'; else echo 'Policy already exists, skipping creation.'; fi echo '👤 Creating service user (idempotent)...'; if ! mc admin user info minio \"$minio_service_user\" >/dev/null 2>&1; then mc admin user add minio \"$minio_service_user\" \"$minio_service_password\"; echo 'User created successfully.'; else echo 'User already exists, skipping creation.'; fi echo '🔗 Attaching policy to user (idempotent)...'; mc admin policy attach minio $minio_policy_name --user \"$minio_service_user\" || echo 'Policy already attached or attachment failed (non-fatal).'; # If creds were rotated, delete the previous service user (shared policy retained) if [ \"$REGENERATE_CREDS\" = true ] && [ -n \"$prev_service_user\" ] && [ \"$prev_service_user\" != \"$minio_service_user\" ]; then echo '🧹 Cleaning up previous service user...'; mc admin user remove minio \"$prev_service_user\" || echo 'Previous user removal failed or not found (non-fatal).'; fi echo '✅ MinIO setup complete!'; exit 0;" # Write MinIO service to temporary file echo "$minio_service_config" > minio_service.tmp # Add MinIO service before the volumes section awk ' { print if ($0 ~ /^services:$/ && !inserted) { while ((getline line < "minio_service.tmp") > 0) print line close("minio_service.tmp") inserted = 1 } } ' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml # Clean up temporary file rm -f minio_service.tmp print_status "Added MinIO service to docker-compose.yml" } # Generic function to add service dependency add_service_dependency() { local service="$1" # Target service (e.g., "formbricks", "traefik") local dependency="$2" # Dependency to add (e.g., "redis", "minio-init", "minio") local optional="${3:-false}" # Optional parameter - if true, don't exit if service not found # Check if service exists if ! grep -q "^ $service:" docker-compose.yml; then if [[ "$optional" == "true" ]]; then print_info "$service service not found - skipping dependency addition." return 0 else print_error "$service service not found in docker-compose.yml!" print_info "Please ensure the $service service is properly configured before running this migration." exit 1 fi fi # Check if dependency is already present in the service depends_on section if awk -v srv="$service" -v dep="$dependency" 'BEGIN{found=0} /^ / && $0 ~ "^ " srv ":" {in_svc=1} in_svc && /^ [a-zA-Z]/ && $0 !~ "^ " srv ":" {in_svc=0} in_svc && $0 ~ "^[[:space:]]*-[[:space:]]*" dep "[[:space:]]*$" {found=1} END{exit(!found)}' docker-compose.yml; then # Dependency already present, skip addition print_info "$dependency dependency already present in $service service." else # Write awk script to temporary file to avoid shell escaping issues local awk_script_tmp awk_script_tmp=$(mktemp) cat > "$awk_script_tmp" << 'AWK_EOF' # Store all lines to be able to look ahead { lines[NR] = $0 } END { # First pass: check if target service has depends_on for (i = 1; i <= NR; i++) { if (lines[i] ~ "^ " srv ":") { in_target = 1 start_line = i } else if (in_target && lines[i] ~ /^ [a-zA-Z]/ && lines[i] !~ "^ " srv ":") { in_target = 0 end_line = i - 1 break } if (in_target && lines[i] ~ /^[[:space:]]*depends_on:[[:space:]]*$/) { has_depends_on = 1 } } if (in_target && !end_line) end_line = NR # Second pass: output with modifications in_svc = 0; in_depends = 0; added = 0 for (i = 1; i <= NR; i++) { line = lines[i] if (line ~ "^ " srv ":") { in_svc = 1 print line continue } if (in_svc && line ~ /^ [a-zA-Z]/ && line !~ "^ " srv ":") { # Exiting service if (!has_depends_on && !added) { print " depends_on:" print " - " new_dep added = 1 } in_svc = 0; in_depends = 0 print line continue } if (in_svc && line ~ /^[[:space:]]*depends_on:[[:space:]]*$/) { in_depends = 1 print line continue } if (in_svc && in_depends && line ~ /^[[:space:]]*-[[:space:]]*/) { print line continue } if (in_svc && in_depends && line !~ /^[[:space:]]*-[[:space:]]*/) { # End of depends_on list if (!added) { print " - " new_dep added = 1 } in_depends = 0 print line continue } if (in_svc && line ~ /^[[:space:]]+[a-zA-Z_][^:]*:[[:space:]]*/) { # First property in service without depends_on if (!has_depends_on && !added) { print " depends_on:" print " - " new_dep added = 1 has_depends_on = 1 } print line continue } print line } # Handle case where service ends at EOF if (in_depends && !added) { print " - " new_dep } else if (in_svc && !has_depends_on && !added) { print " depends_on:" print " - " new_dep } } AWK_EOF # Use the awk script with variables awk -v srv="$service" -v new_dep="$dependency" -f "$awk_script_tmp" docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml rm -f "$awk_script_tmp" print_status "Added $dependency dependency to $service service" fi } # Function to add Redis environment variable add_redis_environment_variables() { # Use the enhanced helper function with REQUIRED section add_or_replace_env_var "REDIS_URL" "redis://redis:6379" "REQUIRED" print_status "Redis environment variables ensured in docker-compose.yml" } # Function to add Redis service to docker-compose.yml add_redis_service() { # Skip injecting if service already exists if has_service redis; then print_info "Redis service already present. Skipping service injection." return 0 fi # Create Redis service configuration local redis_service_config=" redis: restart: always image: valkey/valkey@sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d command: valkey-server --appendonly yes volumes: - redis:/data " # Write Redis service to temporary file echo "$redis_service_config" > redis_service.tmp # Add Redis service before the volumes section awk ' { print if ($0 ~ /^services:$/ && !inserted) { while ((getline line < "redis_service.tmp") > 0) print line close("redis_service.tmp") inserted = 1 } } ' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml # Clean up temporary file rm -f redis_service.tmp print_status "Added Redis service to docker-compose.yml" } # Function to add redis volume add_redis_volume() { # Ensure redis volume exists once if grep -q '^volumes:' docker-compose.yml; then # volumes block exists; check for redis inside it if awk '/^volumes:/{invol=1; next} invol && NF==0{invol=0} invol{ if($1=="redis:") found=1 } END{ exit(!found) }' docker-compose.yml; then print_info "Redis volume already present." else awk ' /^volumes:/ { print; invol=1; next } invol && /^[^[:space:]]/ { if(!added){ print " redis:"; print " driver: local"; added=1 } ; invol=0 } { print } END { if (invol && !added) { print " redis:"; print " driver: local" } } ' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml print_status "Redis volume ensured" fi else # no volumes block; append one with redis only (non-destructive to services) { echo "" echo "volumes:" echo " redis:" echo " driver: local" } >> docker-compose.yml print_status "Added volumes section with Redis" fi } # Function to add minio-data volume add_minio_volume() { # Ensure minio-data volume exists once if grep -q '^volumes:' docker-compose.yml; then # volumes block exists; check for minio-data inside it if awk '/^volumes:/{invol=1; next} invol && NF==0{invol=0} invol{ if($1=="minio-data:") found=1 } END{ exit(!found) }' docker-compose.yml; then print_info "minio-data volume already present." else awk ' /^volumes:/ { print; invol=1; next } invol && /^[^[:space:]]/ { if(!added){ print " minio-data:"; print " driver: local"; added=1 } ; invol=0 } { print } END { if (invol && !added) { print " minio-data:"; print " driver: local" } } ' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml print_status "minio-data volume ensured" fi else # no volumes block; append one with minio-data only (non-destructive to services) { echo "" echo "volumes:" echo " minio-data:" echo " driver: local" } >> docker-compose.yml print_status "Added volumes section with minio-data" fi } # Function to check if MinIO is ready by probing the minio container wait_for_minio_ready() { print_info "Waiting for MinIO to be ready..." local max_attempts=30 attempt=1 while [[ $attempt -le $max_attempts ]]; do # Probe using mc from a one-off mc container to avoid relying on service state if docker run --rm $(compose_network_flag) --entrypoint /bin/sh \ minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 -lc \ "mc alias set minio http://minio:9000 '$minio_root_user' '$minio_root_password' >/dev/null 2>&1 && mc admin info minio >/dev/null 2>&1"; then print_status "MinIO is ready!" return 0 fi if [[ $attempt -eq $max_attempts ]]; then print_error "MinIO did not become ready within expected time. Please check the logs." return 1 fi print_info "Attempt $attempt/$max_attempts - waiting for MinIO..." sleep 5 ((attempt++)) done } # Ensure the target bucket exists (idempotent) ensure_bucket_exists() { print_info "Ensuring bucket '$minio_bucket_name' exists..." docker run --rm $(compose_network_flag) \ -e MINIO_ROOT_USER="$minio_root_user" \ -e MINIO_ROOT_PASSWORD="$minio_root_password" \ -e MINIO_BUCKET_NAME="$minio_bucket_name" \ --entrypoint /bin/sh \ minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 -lc ' mc alias set minio http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null 2>&1; mc mb minio/"$MINIO_BUCKET_NAME" --ignore-existing ' } # Ensure service user and shared policy exist and are attached (idempotent) ensure_service_user_and_policy() { print_info "Ensuring service user and policy exist..." docker run --rm $(compose_network_flag) \ -e MINIO_ROOT_USER="$minio_root_user" \ -e MINIO_ROOT_PASSWORD="$minio_root_password" \ -e MINIO_SERVICE_USER="$minio_service_user" \ -e MINIO_SERVICE_PASSWORD="$minio_service_password" \ -e MINIO_BUCKET_NAME="$minio_bucket_name" \ --entrypoint /bin/sh minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 -lc ' mc alias set minio http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null 2>&1; # Create shared policy if missing if ! mc admin policy info minio formbricks-policy >/dev/null 2>&1; then cat > /tmp/formbricks-policy.json << EOF { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"], "Resource": ["arn:aws:s3:::$MINIO_BUCKET_NAME/*"] }, { "Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": ["arn:aws:s3:::$MINIO_BUCKET_NAME"] } ] } EOF mc admin policy create minio formbricks-policy /tmp/formbricks-policy.json >/dev/null 2>&1 || true fi; # Create service user if missing if ! mc admin user info minio "$MINIO_SERVICE_USER" >/dev/null 2>&1; then mc admin user add minio "$MINIO_SERVICE_USER" "$MINIO_SERVICE_PASSWORD" >/dev/null 2>&1 || true fi; # Attach policy (idempotent) mc admin policy attach minio formbricks-policy --user "$MINIO_SERVICE_USER" >/dev/null 2>&1 || true ' } # Function to migrate files from container storage to MinIO migrate_container_files_to_minio() { local container_path="$1" print_info "Starting file migration from container path $container_path to MinIO..." # Wait for MinIO to be ready, then ensure user/policy (idempotent) wait_for_minio_ready || return 1 ensure_service_user_and_policy ensure_bucket_exists || return 1 # Count files to migrate local file_count=$(docker compose exec -T formbricks find "$container_path" -type f 2>/dev/null | wc -l) if [[ $file_count -eq 0 ]]; then print_warning "No files found in container path $container_path to migrate." return 0 fi print_info "Found $file_count files to migrate from container" # Use a one-off mc container that mounts the formbricks container filesystem and mirrors the path print_info "Starting container-to-MinIO migration..." local FORMBRICKS_CID FORMBRICKS_CID=$(docker compose ps -q formbricks) docker run --rm $(compose_network_flag) \ --volumes-from "$FORMBRICKS_CID" \ -e MINIO_ROOT_USER="$minio_root_user" \ -e MINIO_ROOT_PASSWORD="$minio_root_password" \ -e MINIO_BUCKET_NAME="$minio_bucket_name" \ --entrypoint /bin/sh minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 -lc ' echo "📁 Starting file migration from container to MinIO..."; mc alias set minio http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"; mc mirror --overwrite --preserve '"$container_path"' "minio/$MINIO_BUCKET_NAME" ' if [[ $? -eq 0 ]]; then print_status "Container file migration completed successfully!" # Non-blocking suggestion for clearing container path print_info "Tip: To clear container uploads later, run: docker compose exec -T formbricks sh -lc 'rm -rf "$container_path"/*'" else print_error "Container file migration encountered errors. Please check the output above." return 1 fi } # Function to migrate files from local storage to MinIO migrate_files_to_minio() { local uploads_dir="$1" if [[ -z "$uploads_dir" ]]; then print_warning "No existing uploads directory found or directory is empty. Skipping file migration." return 0 fi # Check if this is a container-based uploads directory if [[ "$uploads_dir" == container:* ]]; then local container_path="${uploads_dir#container:}" print_info "Detected container-based uploads at: $container_path" migrate_container_files_to_minio "$container_path" return $? elif [[ ! -d "$uploads_dir" ]]; then print_warning "Host uploads directory not found: $uploads_dir. Skipping file migration." return 0 fi print_info "Starting file migration from $uploads_dir to MinIO..." # Wait for MinIO to be ready, then ensure user/policy (idempotent) wait_for_minio_ready || return 1 ensure_service_user_and_policy ensure_bucket_exists || return 1 # Count files to migrate local file_count=$(find "$uploads_dir" -type f 2>/dev/null | wc -l) if [[ $file_count -eq 0 ]]; then print_warning "No files found in $uploads_dir to migrate." return 0 fi print_info "Found $file_count files to migrate" # Create a temporary container to handle the migration print_info "Creating temporary migration container..." # Resolve host source path (support relative and absolute) local host_src="$uploads_dir" if [[ "$host_src" != /* ]]; then host_src="$PWD/$host_src" fi docker run --rm $(compose_network_flag) \ -v "$host_src:/source:ro" \ -e MINIO_ROOT_USER="$minio_root_user" \ -e MINIO_ROOT_PASSWORD="$minio_root_password" \ -e MINIO_BUCKET_NAME="$minio_bucket_name" \ --entrypoint /bin/sh \ minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 -lc ' echo "🔗 Setting up MinIO alias for migration..."; mc alias set minio http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"; echo "📁 Mirroring host directory to bucket (recursive)..."; mc mirror --overwrite --preserve /source "minio/$MINIO_BUCKET_NAME"; echo "📊 Mirror complete."; ' if [[ $? -eq 0 ]]; then print_status "File migration completed successfully!" # Non-blocking suggestion for host backup if [[ -d "$uploads_dir" ]]; then print_info "Tip: To archive local source, run: mv '$uploads_dir' '${uploads_dir}.backup'" fi else print_error "File migration encountered errors. Please check the output above." return 1 fi } # Function to restart Docker Compose restart_docker_compose() { echo -n "Restart now to apply changes and continue the migration? (recommended) [Y/n]: " local restart_confirm read -r restart_confirm restart_confirm=$(echo "$restart_confirm" | tr '[:upper:]' '[:lower:]') if [[ -z "$restart_confirm" || "$restart_confirm" == "y" ]]; then print_info "We need to briefly restart Formbricks to apply the changes." print_info "Stopping services..." docker compose down print_info "Starting services with MinIO..." docker compose up -d print_status "Docker Compose restarted successfully!" return 0 else print_warning "Skipping restart for now." print_info "When you're ready, run: docker compose down && docker compose up -d" return 1 fi } # Function to wait for a specific service to be up wait_for_service_up() { local service_name="$1" local max_attempts=30 local attempt=1 while [[ $attempt -le $max_attempts ]]; do if docker compose ps -q "$service_name" >/dev/null 2>&1; then print_status "$service_name is up!" return 0 fi if [[ $attempt -eq $max_attempts ]]; then print_error "$service_name did not become up within expected time. Please check the logs." return 1 fi print_info "Attempt $attempt/$max_attempts - waiting for $service_name..." sleep 5 ((attempt++)) done } # Main migration function migrate_to_v4() { echo "🧱 Formbricks v4.0 Migration" echo "============================" echo "" print_info "We'll prepare your Formbricks instance for v4.0 by:" print_info "- Adding Redis (for caching)" print_info "- Adding MinIO (for file storage)" print_info "- Moving your existing files into MinIO" print_info "You'll be asked to restart briefly so changes take effect." echo "" # Check if we're in the right directory check_formbricks_directory # Backup docker-compose.yml before making any changes backup_docker_compose # Add Redis configuration first (prerequisite for Formbricks 4.0) print_status "Setting up Redis..." add_redis_environment_variables add_redis_service add_redis_volume add_service_dependency "formbricks" "redis" echo "" # Abort early if external S3 already configured and no bundled MinIO external_s3_guard # Check if MinIO is already configured unless migrating only if [[ "$MIGRATE_ONLY" != true ]]; then if [[ "$FORCE_RECONFIGURE" == true ]]; then print_info "Reconfiguring MinIO as requested (--reconfigure)." else check_minio_configured fi fi # Detect current setup (pre-start) local main_domain=$(get_main_domain) local https_setup=$(detect_https_setup) if [[ -z "$main_domain" ]]; then print_error "Could not detect main domain from docker-compose.yml" print_info "Please make sure WEBAPP_URL is configured in your docker-compose.yml" exit 1 fi print_info "Detected configuration:" print_info "Main domain: $main_domain" print_info "HTTPS setup: $https_setup" echo "" local files_domain if [[ "$MIGRATE_ONLY" == true ]]; then # Derive files domain from existing S3_ENDPOINT_URL files_domain=$(read_compose_value "S3_ENDPOINT_URL" | sed -E 's#^https?://##') if [[ -z "$files_domain" ]]; then print_error "S3_ENDPOINT_URL not found. Please configure it in your docker-compose.yml" exit 1 fi print_info "Using existing files domain: $files_domain" # Ensure we have current MinIO credentials in variables for readiness and migration generate_minio_credentials else # Confirm subdomain requirement and get subdomain print_warning "IMPORTANT: MinIO requires a subdomain to function properly." print_info "You need to have DNS configured for a files subdomain (e.g., files.$main_domain)" print_info "Make sure the subdomain points to the same server IP as your main domain." echo "" echo -n "Do you have a subdomain configured for MinIO? [y/N]: " read subdomain_confirmed subdomain_confirmed=$(echo "$subdomain_confirmed" | tr '[:upper:]' '[:lower:]') if [[ "$subdomain_confirmed" != "y" ]]; then print_error "Please configure a subdomain for MinIO before running this migration." print_info "Example: Create a DNS A record for files.$main_domain pointing to your server IP" exit 1 fi local default_files_domain="files.$main_domain" echo -n "Enter the files subdomain to use for MinIO (e.g., $default_files_domain): " read files_domain if [[ -z "$files_domain" ]]; then files_domain="$default_files_domain" fi print_info "Using files domain: $files_domain" echo "" fi if [[ "$MIGRATE_ONLY" != true ]]; then # Generate or rotate MinIO credentials generate_minio_credentials echo "" # If reconfiguring/rotating creds, remove existing service blocks so reinjection is clean if [[ "$REGENERATE_CREDS" == true || "$FORCE_RECONFIGURE" == true ]]; then remove_minio_services || true fi # Add S3 environment variables (updated if credentials rotated) add_s3_environment_variables "$files_domain" "$https_setup" # Add MinIO service add_minio_service "$files_domain" "$main_domain" "$https_setup" # Add MinIO dependency to formbricks add_service_dependency "formbricks" "minio-init" # Update Traefik configuration (optional - skip if traefik not present) add_service_dependency "traefik" "minio" "true" # Add MinIO volume add_minio_volume print_status "Docker Compose configuration updated successfully!" echo "" else print_info "Skipping MinIO reconfiguration." fi # Restart Docker Compose when reconfiguring; otherwise ensure services are up local restart_success=false local proceed_migration=false if [[ "$MIGRATE_ONLY" != true ]]; then if restart_docker_compose; then restart_success=true proceed_migration=true else # User declined restart; confirm they understand migration cannot proceed without it print_warning "Without restarting now, the migration cannot proceed." echo -n "Restart Docker Compose now to proceed with migration? [Y/n]: " read -r confirm_restart confirm_restart=$(echo "$confirm_restart" | tr '[:upper:]' '[:lower:]') if [[ -z "$confirm_restart" || "$confirm_restart" == "y" ]]; then if restart_docker_compose; then restart_success=true proceed_migration=true else print_warning "Migration cancelled because restart was declined." print_info "You can run 'docker compose down && docker compose up -d' later, then rerun this script to migrate files." return 0 fi else print_warning "Migration cancelled at your request. No files were migrated." print_info "You can restart later and rerun this script to migrate files." return 0 fi fi else # Try to ensure services are up without full restart docker compose up -d >/dev/null 2>&1 || true proceed_migration=true fi if [[ "$proceed_migration" == true ]]; then restart_success=true echo "" # Ensure formbricks container is up so uploads path is visible; hard-fail otherwise if ! wait_for_service_up formbricks; then print_error "Formbricks service is not up. Aborting migration." return 1 fi # Collect multiple sources post-start (rendered compose) local sources sources=$(collect_upload_sources_post_start) preview_upload_sources "$sources" # If nothing detected, offer manual entry if [[ -z "$sources" ]]; then read -p "No sources detected. Enter a path (container:/path or ./path) or press Enter to skip: " manual_src manual_src=$(echo "$manual_src" | xargs) if [[ -n "$manual_src" ]]; then sources="$manual_src"; fi fi if [[ -n "$sources" ]]; then echo "" read -p "Proceed to migrate from the sources above? [Y/n]: " do_mig do_mig=$(echo "$do_mig" | tr '[:upper:]' '[:lower:]') if [[ -z "$do_mig" || "$do_mig" == "y" ]]; then # Priority: sources as collected from rendered config mapfile -t __SRC_ARR__ < <(printf '%s\n' "$sources") local MIGRATION_FAILED=0 local MIGRATION_PLANNED_TOTAL=0 for src in "${__SRC_ARR__[@]}"; do [[ -z "$src" ]] && continue # compute planned count local planned=0 rc=0 if [[ "$src" == container:* ]]; then local p="${src#container:}" planned=$(docker compose exec -T formbricks sh -lc 'find '"$p"' -type f 2>/dev/null | wc -l' || echo 0) else planned=$(find "$src" -type f 2>/dev/null | wc -l || echo 0) fi MIGRATION_PLANNED_TOTAL=$((MIGRATION_PLANNED_TOTAL + planned)) migrate_from_source "$src" || rc=$? if [[ $rc -ne 0 ]]; then MIGRATION_FAILED=1; fi done echo "" if [[ $MIGRATION_FAILED -eq 0 ]]; then if [[ $MIGRATION_PLANNED_TOTAL -gt 0 ]]; then print_status "Migration successful." else print_status "No files detected to migrate. Cleaning compose as requested." fi cleanup_uploads_from_compose # Auto-remove minio-init so it won't run again on future 'docker compose up' awk ' BEGIN{in_svc=0; skip=0} /^ minio-init:/ {skip=1; next} skip && /^ [A-Za-z0-9_-]+:/ {skip=0} { if(!skip) print } ' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml # Remove depends_on reference from formbricks (portable via awk) awk ' BEGIN{in_fb=0} /^ formbricks:/ {in_fb=1} in_fb && /^[[:space:]]*-[[:space:]]*minio-init[[:space:]]*$/ {next} /^ [A-Za-z0-9_-]+:/ && !/^ formbricks:/ {in_fb=0} {print} ' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml # Remove any stopped container for minio-init and server orphan containers docker compose rm -f -s minio-init >/dev/null 2>&1 || true docker compose up -d --remove-orphans >/dev/null 2>&1 || true print_status "Removed minio-init service, dependency, and cleaned up any leftover container." echo "" read -p "Restart Docker Compose now to apply cleanup changes? [Y/n]: " apply_restart apply_restart=$(echo "$apply_restart" | tr '[:upper:]' '[:lower:]') if [[ -z "$apply_restart" || "$apply_restart" == "y" ]]; then print_info "Applying cleanup changes..." docker compose up -d --remove-orphans print_status "Cleanup applied. Services are up." else print_info "You can apply changes later with: docker compose up -d" fi else print_error "Migration failed or no files were found to migrate." echo "Sources detected:"; printf '%s\n' "$sources" print_info "Please copy files manually from the sources above to MinIO, then rerun the script." print_info "Volumes and UPLOADS_DIR were NOT removed from docker-compose.yml." fi else print_warning "Skipped migration at user request." fi else print_warning "No uploads directory detected to migrate." fi fi echo "" echo "🎉 Formbricks v4.0 Migration Complete!" echo "=======================================" echo "" print_status "Infrastructure Configuration:" print_info "Redis://redis:6379" print_info "Files Domain: $files_domain" print_info "S3 Access Key: $minio_service_user" print_info "S3 Bucket: $minio_bucket_name" echo "" if [[ "$restart_success" == true ]]; then print_status "Your Formbricks instance is now ready for v4.0!" print_info "- Redis is configured for caching" print_info "- MinIO is configured for file storage" print_info "To check that everything is running, use: docker compose ps" print_info "To view logs (for troubleshooting), use: docker compose logs" else print_warning "Before migration can happen, please remember to restart your services:" print_info "docker compose down && docker compose up -d" fi if [[ "$MIGRATE_ONLY" != true ]]; then echo "" print_info "🔒 Important: Save these MinIO credentials securely:" echo "Root User: $minio_root_user" echo "Root Password: $minio_root_password" echo "Service User: $minio_service_user" echo "Service Password: $minio_service_password" cleanup_sensitive_output fi } # Guarded early definition to avoid 'command not found' in any flow if ! declare -F cleanup_uploads_from_compose >/dev/null 2>&1; then cleanup_uploads_from_compose() { print_info "Cleaning docker-compose.yml uploads configuration..." # 1) Comment out UPLOADS_DIR if present if sed --version >/dev/null 2>&1; then sed -i 's/^\([[:space:]]*\)UPLOADS_DIR:[[:space:]].*/\1# UPLOADS_DIR:/' docker-compose.yml || true; else sed -i '' 's/^\([[:space:]]*\)UPLOADS_DIR:[[:space:]].*/\1# UPLOADS_DIR:/' docker-compose.yml || true; fi # 2) Remove uploads mapping from formbricks service volumes awk ' BEGIN{in_svc=0; in_vol=0} /^ formbricks:/ {in_svc=1} /^ [A-Za-z0-9_-]+:/ && !/^ formbricks:/ {in_svc=0} {line=$0} in_svc && /^ volumes:/ {in_vol=1; print; next} in_svc && /^ [A-Za-z0-9_-]+:/ {if(in_vol) in_vol=0} in_vol { if (line ~ /^[[:space:]]*-[[:space:]]*uploads:/) next if (line ~ /^[[:space:]]*-[[:space:]].*\/uploads\/?[[:space:]]*$/) next print; next } {print} ' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml # 3) Remove root-level uploads volume definition awk ' BEGIN{in_root=0; skip=0} /^volumes:[[:space:]]*$/ {print; in_root=1; next} in_root && /^[^[:space:]]/ {in_root=0} in_root { if ($0 ~ /^ uploads:[[:space:]]*$/) {skip=1; next} if (skip) { if ($0 ~ /^ [A-Za-z0-9_-]+:[[:space:]]*$/) {skip=0; print; next} next } print; next } {print} ' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml print_status "Removed uploads volume mapping and UPLOADS_DIR." } fi # Check if script is being run directly if [[ -n "${BASH_SOURCE:-}" && "${BASH_SOURCE[0]}" == "${0}" ]]; then migrate_to_v4 fi