7da5c4a15e
Adds a wizard step (after the ML step) that prompts for an AcoustID API key. Left blank, enrichment runs on embedded tags only; with a key the backend also identifies untagged files by audio fingerprint (§1D). The key is appended to .env.deploy and reaches both api and worker via env_file. en/ru strings included. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
308 lines
11 KiB
Bash
Executable File
308 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_ACOUSTID_KEY=""
|
|
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"
|
|
}
|
|
|
|
step_enrichment() {
|
|
ui_title "$(t step_enrichment)"
|
|
ui_dim "$(t enrichment_note)"
|
|
ui_input "$(t acoustid_prompt)" "" ""; CFG_ACOUSTID_KEY="$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
|
|
step_enrichment
|
|
|
|
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 "$@"
|