Files
mcma-bootstrap/deploy.sh
T
Senko-san 1b251869c4 initial
2026-06-08 12:49:45 +03:00

300 lines
11 KiB
Bash
Executable File

#!/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 "$@"