#!/usr/bin/env bash # mcma-bootstrap — interactive installer & lifecycle entrypoint for MCMA. # # Fresh run: preflight → wizard → generate .env.deploy + docker-compose.yml → # pull → migrate → first admin → start → healthcheck. Re-run with an existing # install shows an update/reconfigure menu. Management subcommands (--update, # --up, --down, --logs, --status, --clean) skip the wizard. set -euo pipefail BOOTSTRAP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="${BOOTSTRAP_DIR}/.env.deploy" COMPOSE_FILE="${BOOTSTRAP_DIR}/docker-compose.yml" CADDYFILE="${BOOTSTRAP_DIR}/Caddyfile" # shellcheck source=lib/i18n.sh source "${BOOTSTRAP_DIR}/lib/i18n.sh" source "${BOOTSTRAP_DIR}/lib/ui.sh" source "${BOOTSTRAP_DIR}/lib/checks.sh" source "${BOOTSTRAP_DIR}/lib/secrets.sh" source "${BOOTSTRAP_DIR}/lib/env_gen.sh" source "${BOOTSTRAP_DIR}/lib/compose_gen.sh" source "${BOOTSTRAP_DIR}/lib/lifecycle.sh" trap 'echo; ui_warn "$(t aborted)"; exit 130' INT # -- config globals (defaults keep `set -u` happy) ------------------------- CFG_TAG="latest" CFG_WEBUI="no"; CFG_SERVICES="backend" CFG_DB_MODE="embedded"; CFG_DB_HOST=""; CFG_DB_PORT="5432" CFG_DB_NAME="mcma"; CFG_DB_USER="mcma"; CFG_DB_PASS="" CFG_REDIS_MODE="embedded"; CFG_REDIS_URL="redis://redis:6379/0" CFG_STORAGE="local"; CFG_MEDIA_HOST_PATH="./data/media" CFG_S3_MODE="embedded"; CFG_S3_ENDPOINT=""; CFG_S3_BUCKET="mcma" CFG_S3_REGION=""; CFG_S3_KEY=""; CFG_S3_SECRET="" CFG_PROXY="no"; CFG_HTTPS="no"; CFG_DOMAIN=""; CFG_ACME_EMAIL="" CFG_HTTP_PORT="8080"; CFG_API_PORT="8080"; CFG_WEBUI_PORT="3000" CFG_PUBLIC_API_BASE_URL="/api/v1" CFG_ADMIN_CREATE="no"; CFG_ADMIN_USER=""; CFG_ADMIN_PASS="" CFG_ML_URL="" CFG_JWT_SECRET="" # ========================================================================== # Wizard steps # ========================================================================== step_locale() { ui_info "$(t choose_locale)" ui_select "" "English" "Русский" [[ "$UI_INDEX" == "2" ]] && i18n_load ru || i18n_load en } step_welcome() { ui_title "$(t welcome_title)" ui_info "$(t welcome_body)" } step_services() { ui_title "$(t step_services)" ui_multiselect "" \ "backend:$(t svc_backend):locked" \ "webui:$(t svc_webui):on" CFG_SERVICES="$UI_SELECTED" [[ " $CFG_SERVICES " == *" webui "* ]] && CFG_WEBUI="yes" || CFG_WEBUI="no" } step_tag() { ui_title "$(t step_tag)" ui_select "" "$(t tag_latest)" "$(t tag_custom)" if [[ "$UI_INDEX" == "2" ]]; then ui_input "$(t tag_prompt)" "latest" v_nonempty; CFG_TAG="$UI_VALUE" else CFG_TAG="latest" fi } step_db() { ui_title "$(t step_db)" ui_select "" "$(t db_embedded)" "$(t db_external)" if [[ "$UI_INDEX" == "1" ]]; then CFG_DB_MODE="embedded"; CFG_DB_USER="mcma"; CFG_DB_NAME="mcma" gen_secret 24; CFG_DB_PASS="$SECRET" else CFG_DB_MODE="external" ui_input "$(t db_host)" "" v_nonempty; CFG_DB_HOST="$UI_VALUE" ui_input "$(t db_port)" "5432" v_port; CFG_DB_PORT="$UI_VALUE" ui_input "$(t db_name)" "mcma" v_nonempty; CFG_DB_NAME="$UI_VALUE" ui_input "$(t db_user)" "mcma" v_nonempty; CFG_DB_USER="$UI_VALUE" ui_secret "$(t db_pass)"; CFG_DB_PASS="$UI_VALUE" fi } step_redis() { ui_title "$(t step_redis)" ui_select "" "$(t redis_embedded)" "$(t redis_external)" if [[ "$UI_INDEX" == "1" ]]; then CFG_REDIS_MODE="embedded"; CFG_REDIS_URL="redis://redis:6379/0" else CFG_REDIS_MODE="external" ui_input "$(t redis_url_prompt)" "redis://localhost:6379/0" v_redis CFG_REDIS_URL="$UI_VALUE" fi } step_storage() { ui_title "$(t step_storage)" ui_select "" "$(t storage_local)" "$(t storage_s3)" if [[ "$UI_INDEX" == "1" ]]; then CFG_STORAGE="local" ui_input "$(t storage_path_prompt)" "./data/media" v_nonempty CFG_MEDIA_HOST_PATH="$UI_VALUE" else CFG_STORAGE="s3" ui_info "$(t s3_choice)" ui_select "" "$(t s3_embedded)" "$(t s3_external)" if [[ "$UI_INDEX" == "1" ]]; then CFG_S3_MODE="embedded" CFG_S3_ENDPOINT="http://minio:9000"; CFG_S3_BUCKET="mcma"; CFG_S3_REGION="us-east-1" gen_secret 20; CFG_S3_KEY="$SECRET" gen_secret 40; CFG_S3_SECRET="$SECRET" else CFG_S3_MODE="external" ui_input "$(t s3_endpoint)" "" v_url; CFG_S3_ENDPOINT="$UI_VALUE" ui_input "$(t s3_bucket)" "mcma" v_nonempty; CFG_S3_BUCKET="$UI_VALUE" ui_input "$(t s3_region)" "" ""; CFG_S3_REGION="$UI_VALUE" ui_input "$(t s3_key)" "" v_nonempty; CFG_S3_KEY="$UI_VALUE" ui_secret "$(t s3_secret)"; CFG_S3_SECRET="$UI_VALUE" fi fi } step_network() { ui_title "$(t step_network)" if [[ "$CFG_WEBUI" == "yes" ]]; then ui_dim "$(t proxy_note)" ui_select "" "$(t net_caddy_http)" "$(t net_caddy_https)" "$(t net_direct)" case "$UI_INDEX" in 1) # Bundled Caddy, plain HTTP — same-origin, simplest. CFG_PROXY="yes"; CFG_HTTPS="no"; CFG_PUBLIC_API_BASE_URL="/api/v1" ui_input "$(t http_port_prompt)" "8080" v_port; CFG_HTTP_PORT="$UI_VALUE" is_port_free "$CFG_HTTP_PORT" || ui_warn "$(t port_in_use "$CFG_HTTP_PORT")" ;; 2) # Bundled Caddy, automatic HTTPS for a domain. CFG_PROXY="yes"; CFG_HTTPS="yes"; CFG_PUBLIC_API_BASE_URL="/api/v1" ui_input "$(t domain_prompt)" "" v_domain; CFG_DOMAIN="$UI_VALUE" ui_input "$(t acme_email_prompt)" "" ""; CFG_ACME_EMAIL="$UI_VALUE" CFG_HTTP_PORT="80" ;; 3) # No bundled proxy — operator publishes ports / runs their own. CFG_PROXY="no"; CFG_HTTPS="no" ui_warn "$(t direct_cors_warn)" ui_input "$(t api_port_prompt)" "8080" v_port; CFG_API_PORT="$UI_VALUE" is_port_free "$CFG_API_PORT" || ui_warn "$(t port_in_use "$CFG_API_PORT")" ui_input "$(t webui_port_prompt)" "3000" v_port; CFG_WEBUI_PORT="$UI_VALUE" is_port_free "$CFG_WEBUI_PORT" || ui_warn "$(t port_in_use "$CFG_WEBUI_PORT")" ui_input "$(t public_api_prompt)" "/api/v1" v_nonempty CFG_PUBLIC_API_BASE_URL="$UI_VALUE" ;; esac else CFG_PROXY="no"; CFG_HTTPS="no" ui_input "$(t api_port_prompt)" "8080" v_port; CFG_API_PORT="$UI_VALUE" is_port_free "$CFG_API_PORT" || ui_warn "$(t port_in_use "$CFG_API_PORT")" fi } step_admin() { ui_title "$(t step_admin)" if ui_yesno "$(t admin_q)" "yes"; then CFG_ADMIN_CREATE="yes" ui_input "$(t admin_user)" "admin" v_nonempty; CFG_ADMIN_USER="$UI_VALUE" while true; do ui_secret "$(t admin_pass)"; local p1="$UI_VALUE" if ((${#p1} < 8)); then ui_warn "$(t pass_too_short)"; continue; fi ui_secret "$(t admin_pass_confirm)"; local p2="$UI_VALUE" if [[ "$p1" != "$p2" ]]; then ui_warn "$(t pass_mismatch)"; continue; fi CFG_ADMIN_PASS="$p1"; break done else CFG_ADMIN_CREATE="no" ui_dim "$(t admin_skip_note)" fi } step_ml() { ui_title "$(t step_ml)" ui_input "$(t ml_prompt)" "" ""; CFG_ML_URL="$UI_VALUE" } access_url() { if [[ "$CFG_WEBUI" == "yes" ]]; then if [[ "$CFG_PROXY" == "yes" ]]; then if [[ "$CFG_HTTPS" == "yes" ]]; then echo "https://${CFG_DOMAIN}" else echo "http://localhost:${CFG_HTTP_PORT}"; fi else echo "http://localhost:${CFG_WEBUI_PORT} (API base: ${CFG_PUBLIC_API_BASE_URL})" fi else echo "http://localhost:${CFG_API_PORT} (API + docs at /docs)" fi } step_summary() { ui_title "$(t summary_title)" local dbl rdl stl [[ "$CFG_DB_MODE" == embedded ]] && dbl="$(t embedded)" || dbl="$(t external) (${CFG_DB_HOST})" [[ "$CFG_REDIS_MODE" == embedded ]] && rdl="$(t embedded)" || rdl="$(t external)" if [[ "$CFG_STORAGE" == local ]]; then stl="local: ${CFG_MEDIA_HOST_PATH}" elif [[ "$CFG_S3_MODE" == embedded ]]; then stl="S3 ($(t embedded) MinIO)" else stl="S3 ($(t external))"; fi printf ' %-12s %s\n' "$(t summary_services):" "$CFG_SERVICES" printf ' %-12s %s\n' "$(t summary_tag):" "$CFG_TAG" printf ' %-12s %s\n' "$(t summary_db):" "$dbl" printf ' %-12s %s\n' "$(t summary_redis):" "$rdl" printf ' %-12s %s\n' "$(t summary_storage):" "$stl" printf ' %-12s %s\n' "$(t summary_access):" "$(access_url)" echo ui_yesno "$(t confirm_start)" "yes" || { ui_warn "$(t aborted)"; exit 0; } } step_done() { ui_title "$(t done_title)" ui_ok "$(t done_url): $(access_url)" ui_info "$(t done_config): ${ENV_FILE}" [[ "$CFG_ADMIN_CREATE" == "yes" ]] && ui_info "$(t done_admin_login): ${CFG_ADMIN_USER}" ui_dim "$(t done_commands)" } run_wizard() { step_locale step_welcome step_services step_tag step_db step_redis step_storage step_network step_admin step_ml gen_hex 32; CFG_JWT_SECRET="$SECRET" generate_env generate_compose step_summary lifecycle_start "$CFG_ADMIN_CREATE" step_done } # Existing-install menu (idempotency, plan §5). existing_menu() { i18n_load "${MCMA_LOCALE:-en}" ui_title "$(t existing_found)" ui_select "" "$(t menu_update_images)" "$(t menu_reconfigure)" "$(t menu_cancel)" case "$UI_INDEX" in 1) lifecycle_update ;; 2) local ts; ts="$(date +%Y%m%d-%H%M%S)" cp "$ENV_FILE" "${ENV_FILE}.bak.${ts}" [[ -f "$COMPOSE_FILE" ]] && cp "$COMPOSE_FILE" "${COMPOSE_FILE}.bak.${ts}" ui_ok "$(t backup_made "${ENV_FILE}.bak.${ts}")" run_wizard ;; *) ui_info "$(t aborted)" ;; esac } require_install() { if [[ ! -f "$ENV_FILE" || ! -f "$COMPOSE_FILE" ]]; then i18n_load "${MCMA_LOCALE:-en}" ui_err "$(t no_config)"; exit 1 fi } # ========================================================================== # Entrypoint / arg dispatch # ========================================================================== main() { local cmd="${1:-deploy}" i18n_load "${MCMA_LOCALE:-en}" # default strings; the wizard re-loads per choice case "$cmd" in --update) require_install; lifecycle_update ;; --up) require_install; lifecycle_up ;; --down) require_install; lifecycle_down ;; --logs) require_install; lifecycle_logs ;; --status) require_install; lifecycle_status ;; --clean) i18n_load "${MCMA_LOCALE:-en}" require_install; lifecycle_clean ;; deploy|"") preflight if [[ -f "$ENV_FILE" ]]; then existing_menu; else run_wizard; fi ;; *) echo "Usage: deploy.sh [--update|--up|--down|--logs|--status|--clean]" >&2 exit 2 ;; esac } main "$@"