This commit is contained in:
Senko-san
2026-06-08 12:49:45 +03:00
commit 1b251869c4
21 changed files with 1404 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# Generated by the wizard — never commit these.
.env.deploy
.env.deploy.bak.*
docker-compose.yml
Caddyfile
data/
+35
View File
@@ -0,0 +1,35 @@
# mcma-bootstrap — installer & lifecycle for self-hosted MCMA.
#
# All real logic lives in the shell scripts; this Makefile is a thin entrypoint.
# `make` or `make help` lists targets.
SHELL := /bin/bash
HERE := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
.DEFAULT_GOAL := help
.PHONY: help deploy update up down logs status clean
help: ## Show this help
@grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}'
deploy: ## Run the interactive installer (fresh install or update menu)
@bash "$(HERE)deploy.sh"
update: ## Pull fresh images of the current tag and restart (no wizard)
@bash "$(HERE)deploy.sh" --update
up: ## Start the stack from the existing config
@bash "$(HERE)deploy.sh" --up
down: ## Stop the stack (containers only; data is kept)
@bash "$(HERE)deploy.sh" --down
logs: ## Tail logs from all services
@bash "$(HERE)deploy.sh" --logs
status: ## Show container status + health
@bash "$(HERE)deploy.sh" --status
clean: ## Stop and remove generated config (prompts before deleting data volumes)
@bash "$(HERE)deploy.sh" --clean
+102
View File
@@ -0,0 +1,102 @@
# mcma-bootstrap
Interactive installer for **MCMA** (My Cool Music App) — a self-hosted music
service. One command asks a few questions, writes a `.env.deploy` and a
`docker-compose.yml`, pulls the images, runs database migrations, creates the
first admin, and verifies the stack is healthy.
Zero dependencies beyond **bash 4+, Docker, and Docker Compose v2**. No jq, no
Python, no yq. Secrets are generated for you and never printed.
## Quick start
```bash
make deploy
```
That's it. The wizard walks you through:
1. Language (English / Русский)
2. Which services to run (backend is required; web UI optional)
3. Image tag (`latest` or a pinned version)
4. Database — built-in Postgres or an external one
5. Redis — built-in or external
6. Media storage — local directory, built-in MinIO (S3), or external S3
7. Network — reverse proxy with automatic HTTPS, or a plain HTTP port
8. The first administrator account
9. An optional ML service URL
Then it generates the config, pulls images, migrates, seeds the admin, starts
everything, and waits for the API health check before declaring success.
## Commands
```bash
make deploy # run the installer (fresh install, or update/reconfigure menu)
make update # pull fresh images of the current tag, migrate, restart
make up # start the stack from the existing config
make down # stop the stack (data volumes are kept)
make logs # tail logs from all services
make status # container status + API health
make clean # stop and remove generated config (prompts before deleting data)
```
## What gets generated
| File | Purpose | Committed? |
| --------------------- | ---------------------------------------- | ---------- |
| `.env.deploy` | All settings + generated secrets (0600) | **No** |
| `docker-compose.yml` | The stack, assembled from your choices | **No** |
| `Caddyfile` | Reverse-proxy routing (if proxy enabled) | **No** |
All three are in `.gitignore`. Re-running `make deploy` over an existing install
offers to update images or reconfigure (backing up the old config first); it
never silently overwrites.
## Architecture notes
- **One backend image, two roles.** `git.ollyhearn.ru/olly/mcma-backend` runs
the API (`uvicorn`, port 8000) and the background worker
(`arq app.workers.arq_worker.WorkerSettings`) — same image, different command.
- **The web UI needs a reverse proxy.** `git.ollyhearn.ru/olly/mcma-webui` is a
prebuilt static SPA that calls `/api/v1` on its own origin and does not proxy
the API itself. So whenever the web UI is deployed, the installer puts **Caddy**
in front as the single entrypoint, routing `/api/*`, `/health*` and `/rest/*`
to the backend and everything else to the UI. Caddy runs plain HTTP on a port,
or gets automatic HTTPS if you provide a domain. A backend-only deploy skips
the proxy and publishes the API port directly.
- **Startup is ordered and fails loud.** Backing services come up and become
healthy → migrations run → the first admin is created → app services start →
the API `/health` endpoint is polled. The backend is never started over a
database that failed to migrate.
## Private registry
The images live in a private registry. If `make deploy` reports `unauthorized`
while pulling, log in first:
```bash
docker login git.ollyhearn.ru
```
## Creating an admin later
If you skipped admin creation during setup:
```bash
docker compose --project-name mcma --env-file .env.deploy -f docker-compose.yml \
run --rm --no-deps api mcma create-admin <username>
```
## Layout
```
mcma-bootstrap/
├── Makefile # thin entrypoint → deploy.sh
├── deploy.sh # wizard + lifecycle orchestrator
├── lib/ # bash modules (ui, i18n, checks, secrets, generators, lifecycle)
├── templates/
│ ├── compose/ # per-service compose fragments, glued by choice
│ └── env/env.template # base .env with @TOKEN@ placeholders
└── i18n/ # ru / en string tables
```
Executable
+299
View File
@@ -0,0 +1,299 @@
#!/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 "$@"
+135
View File
@@ -0,0 +1,135 @@
# English strings. Each entry sets MSG[<key>]. Loaded by lib/i18n.sh.
# Keep keys in sync with i18n/ru.sh.
MSG[yes]="yes"
MSG[no]="no"
MSG[default_label]="default"
MSG[invalid_input]="Invalid input, please try again."
MSG[aborted]="Aborted."
MSG[enter_to_keep]="(Enter to keep default)"
# -- preflight -------------------------------------------------------------
MSG[err_bash_version]="Bash 4+ is required (found %s). Install a newer bash."
MSG[err_need_docker]="Docker is not installed or not on PATH. See https://docs.docker.com/get-docker/"
MSG[err_docker_daemon]="The Docker daemon is not reachable. Is Docker running?"
MSG[err_need_compose]="Docker Compose v2 is required ('docker compose'). Update Docker Desktop or install the compose plugin."
MSG[err_need_openssl]="openssl is required to generate secrets."
MSG[preflight_ok]="Preflight checks passed."
MSG[port_in_use]="Port %s looks busy. Choose another or stop whatever is using it."
# -- locale / welcome ------------------------------------------------------
MSG[choose_locale]="Choose language / Выберите язык"
MSG[welcome_title]="MCMA installer"
MSG[welcome_body]="This wizard will ask a few questions, generate a .env.deploy and docker-compose.yml, pull the images, run database migrations, create the first admin and verify everything is healthy. Secrets are generated for you — nothing secret is printed. Press Ctrl+C any time to abort cleanly."
# -- existing install ------------------------------------------------------
MSG[existing_found]="An existing installation was found (.env.deploy). What would you like to do?"
MSG[menu_update_images]="Update images (pull + restart, keep config)"
MSG[menu_reconfigure]="Reconfigure (back up current config, run the wizard again)"
MSG[menu_cancel]="Cancel"
MSG[backup_made]="Backed up current config to %s"
# -- services --------------------------------------------------------------
MSG[step_services]="Which services do you want to run?"
MSG[svc_backend]="mcma-backend — core app + sync node + worker (required)"
MSG[svc_webui]="mcma-webui — web interface"
MSG[backend_required]="The backend is required and cannot be disabled."
# -- image tag -------------------------------------------------------------
MSG[step_tag]="Which image tag should be deployed?"
MSG[tag_latest]="latest — always the newest build (may occasionally break)"
MSG[tag_custom]="Enter a specific tag (e.g. a release version)"
MSG[tag_prompt]="Image tag"
# -- database --------------------------------------------------------------
MSG[step_db]="Database"
MSG[db_embedded]="Use the built-in Postgres (recommended)"
MSG[db_external]="Connect to an external Postgres"
MSG[db_host]="Postgres host"
MSG[db_port]="Postgres port"
MSG[db_name]="Database name"
MSG[db_user]="Database user"
MSG[db_pass]="Database password"
# -- redis -----------------------------------------------------------------
MSG[step_redis]="Redis"
MSG[redis_embedded]="Use the built-in Redis (recommended)"
MSG[redis_external]="Connect to an external Redis"
MSG[redis_url_prompt]="Redis URL (redis://host:port/db)"
# -- storage ---------------------------------------------------------------
MSG[step_storage]="Where should media files be stored?"
MSG[storage_local]="Local directory on the host (recommended)"
MSG[storage_s3]="S3-compatible object storage"
MSG[storage_path_prompt]="Host directory for media"
MSG[s3_choice]="S3 storage"
MSG[s3_embedded]="Use built-in MinIO"
MSG[s3_external]="Connect to an external S3 endpoint"
MSG[s3_endpoint]="S3 endpoint URL (https://...)"
MSG[s3_bucket]="S3 bucket name"
MSG[s3_region]="S3 region (blank if not applicable)"
MSG[s3_key]="S3 access key"
MSG[s3_secret]="S3 secret key"
# -- network ---------------------------------------------------------------
MSG[step_network]="Network & access"
MSG[proxy_note]="The web UI and API must share one origin. The bundled Caddy proxy does that for you as a single entrypoint."
MSG[net_caddy_http]="Bundled reverse proxy (Caddy), plain HTTP on a port — recommended"
MSG[net_caddy_https]="Bundled reverse proxy (Caddy), automatic HTTPS for a domain"
MSG[net_direct]="No bundled proxy — publish ports / I run my own proxy"
MSG[direct_cors_warn]="Without the bundled proxy the web UI and API must be same-origin: the backend sends no CORS headers, so a cross-origin API URL will be blocked by the browser. Use '/api/v1' only if your own proxy serves both on one origin."
MSG[domain_prompt]="Domain name (e.g. music.example.com)"
MSG[acme_email_prompt]="Email for Let's Encrypt (optional, for expiry notices)"
MSG[http_port_prompt]="HTTP port to publish"
MSG[api_port_prompt]="Backend API port to publish"
MSG[webui_port_prompt]="Web UI port to publish"
MSG[public_api_prompt]="Browser-facing API base URL"
# -- admin -----------------------------------------------------------------
MSG[step_admin]="First administrator"
MSG[admin_q]="Create the first admin user now?"
MSG[admin_user]="Admin username"
MSG[admin_pass]="Admin password (min 8 chars)"
MSG[admin_pass_confirm]="Confirm admin password"
MSG[pass_mismatch]="Passwords do not match."
MSG[pass_too_short]="Password must be at least 8 characters."
MSG[admin_skip_note]="You can create an admin later with: make logs (see README)."
# -- ml --------------------------------------------------------------------
MSG[step_ml]="Optional ML service"
MSG[ml_prompt]="ML service URL (leave blank — backend degrades gracefully)"
# -- summary / run ---------------------------------------------------------
MSG[summary_title]="Summary (secrets hidden)"
MSG[summary_services]="Services"
MSG[summary_tag]="Image tag"
MSG[summary_db]="Database"
MSG[summary_redis]="Redis"
MSG[summary_storage]="Storage"
MSG[summary_access]="Access"
MSG[confirm_start]="Generate config and start now?"
MSG[embedded]="built-in"
MSG[external]="external"
MSG[pull_images]="Pulling images (%s)..."
MSG[pull_hint]="If pulls fail with 'unauthorized', run 'docker login git.ollyhearn.ru' first (private registry)."
MSG[starting_deps]="Starting backing services..."
MSG[waiting_health]="Waiting for %s to become healthy..."
MSG[running_migrations]="Running database migrations..."
MSG[creating_admin]="Creating the first admin..."
MSG[starting_app]="Starting application services..."
MSG[checking_health]="Verifying the API is healthy..."
# -- done / errors ---------------------------------------------------------
MSG[done_title]="MCMA is up."
MSG[done_url]="Open"
MSG[done_config]="Config (keep private)"
MSG[done_admin_login]="Admin username"
MSG[done_commands]="Manage with: make status | make logs | make update | make down"
MSG[err_migrations_failed]="Migrations failed. The backend was NOT started over a broken database. Check 'make logs'."
MSG[err_health_timeout]="The API did not become healthy in time. Check 'make logs' and 'make status'."
MSG[hint_logs]="Run 'make logs' to see what went wrong."
MSG[no_config]="No installation found. Run 'make deploy' first."
MSG[clean_confirm]="Remove generated config files?"
MSG[clean_volumes_confirm]="Also DELETE all data volumes (database, media)? This is irreversible."
MSG[clean_done]="Cleaned up."
+135
View File
@@ -0,0 +1,135 @@
# Русские строки. Каждая запись задаёт MSG[<key>]. Загружается lib/i18n.sh.
# Ключи должны совпадать с i18n/en.sh.
MSG[yes]="да"
MSG[no]="нет"
MSG[default_label]="по умолчанию"
MSG[invalid_input]="Неверный ввод, попробуйте ещё раз."
MSG[aborted]="Прервано."
MSG[enter_to_keep]="(Enter — оставить по умолчанию)"
# -- preflight -------------------------------------------------------------
MSG[err_bash_version]="Требуется Bash 4+ (найден %s). Установите более новый bash."
MSG[err_need_docker]="Docker не установлен или не в PATH. См. https://docs.docker.com/get-docker/"
MSG[err_docker_daemon]="Демон Docker недоступен. Docker запущен?"
MSG[err_need_compose]="Требуется Docker Compose v2 ('docker compose'). Обновите Docker Desktop или установите плагин compose."
MSG[err_need_openssl]="Для генерации секретов требуется openssl."
MSG[preflight_ok]="Предварительные проверки пройдены."
MSG[port_in_use]="Порт %s, похоже, занят. Выберите другой или освободите его."
# -- locale / welcome ------------------------------------------------------
MSG[choose_locale]="Choose language / Выберите язык"
MSG[welcome_title]="Установщик MCMA"
MSG[welcome_body]="Мастер задаст несколько вопросов, создаст .env.deploy и docker-compose.yml, скачает образы, выполнит миграции БД, создаст первого администратора и проверит работоспособность. Секреты генерируются автоматически — ничего секретного не печатается. Ctrl+C в любой момент — чистый выход."
# -- existing install ------------------------------------------------------
MSG[existing_found]="Найдена существующая установка (.env.deploy). Что сделать?"
MSG[menu_update_images]="Обновить образы (pull + перезапуск, конфиг не трогаем)"
MSG[menu_reconfigure]="Перенастроить (бэкап конфига и запуск мастера заново)"
MSG[menu_cancel]="Отмена"
MSG[backup_made]="Текущий конфиг сохранён в %s"
# -- services --------------------------------------------------------------
MSG[step_services]="Какие сервисы запустить?"
MSG[svc_backend]="mcma-backend — ядро + синк-нода + worker (обязателен)"
MSG[svc_webui]="mcma-webui — веб-интерфейс"
MSG[backend_required]="Backend обязателен и не может быть отключён."
# -- image tag -------------------------------------------------------------
MSG[step_tag]="Какой тег образов развернуть?"
MSG[tag_latest]="latest — всегда свежее (может иногда ломаться)"
MSG[tag_custom]="Ввести конкретный тег (например, версию релиза)"
MSG[tag_prompt]="Тег образа"
# -- database --------------------------------------------------------------
MSG[step_db]="База данных"
MSG[db_embedded]="Использовать встроенный Postgres (рекомендуется)"
MSG[db_external]="Подключиться к внешнему Postgres"
MSG[db_host]="Хост Postgres"
MSG[db_port]="Порт Postgres"
MSG[db_name]="Имя базы"
MSG[db_user]="Пользователь базы"
MSG[db_pass]="Пароль базы"
# -- redis -----------------------------------------------------------------
MSG[step_redis]="Redis"
MSG[redis_embedded]="Использовать встроенный Redis (рекомендуется)"
MSG[redis_external]="Подключиться к внешнему Redis"
MSG[redis_url_prompt]="URL Redis (redis://host:port/db)"
# -- storage ---------------------------------------------------------------
MSG[step_storage]="Где хранить медиафайлы?"
MSG[storage_local]="Локальный каталог на хосте (рекомендуется)"
MSG[storage_s3]="S3-совместимое хранилище"
MSG[storage_path_prompt]="Каталог на хосте для медиа"
MSG[s3_choice]="Хранилище S3"
MSG[s3_embedded]="Использовать встроенный MinIO"
MSG[s3_external]="Подключиться к внешнему S3"
MSG[s3_endpoint]="URL эндпоинта S3 (https://...)"
MSG[s3_bucket]="Имя бакета S3"
MSG[s3_region]="Регион S3 (пусто, если не нужно)"
MSG[s3_key]="Access key S3"
MSG[s3_secret]="Secret key S3"
# -- network ---------------------------------------------------------------
MSG[step_network]="Сеть и доступ"
MSG[proxy_note]="Веб-интерфейс и API должны быть на одном origin. Встроенный прокси Caddy обеспечивает это как единая точка входа."
MSG[net_caddy_http]="Встроенный прокси (Caddy), обычный HTTP на порту — рекомендуется"
MSG[net_caddy_https]="Встроенный прокси (Caddy), авто-HTTPS для домена"
MSG[net_direct]="Без встроенного прокси — публикую порты / свой прокси"
MSG[direct_cors_warn]="Без встроенного прокси веб-интерфейс и API должны быть на одном origin: backend не отправляет CORS-заголовки, поэтому кросс-origin URL заблокирует браузер. Используйте '/api/v1', только если ваш прокси отдаёт оба на одном origin."
MSG[domain_prompt]="Доменное имя (например, music.example.com)"
MSG[acme_email_prompt]="Email для Let's Encrypt (необязательно, для уведомлений)"
MSG[http_port_prompt]="HTTP-порт для публикации"
MSG[api_port_prompt]="Порт API backend для публикации"
MSG[webui_port_prompt]="Порт веб-интерфейса для публикации"
MSG[public_api_prompt]="Базовый URL API для браузера"
# -- admin -----------------------------------------------------------------
MSG[step_admin]="Первый администратор"
MSG[admin_q]="Создать первого администратора сейчас?"
MSG[admin_user]="Логин администратора"
MSG[admin_pass]="Пароль администратора (мин. 8 символов)"
MSG[admin_pass_confirm]="Подтвердите пароль"
MSG[pass_mismatch]="Пароли не совпадают."
MSG[pass_too_short]="Пароль должен быть не короче 8 символов."
MSG[admin_skip_note]="Администратора можно создать позже (см. README)."
# -- ml --------------------------------------------------------------------
MSG[step_ml]="Опциональный ML-сервис"
MSG[ml_prompt]="URL ML-сервиса (оставьте пустым — backend деградирует штатно)"
# -- summary / run ---------------------------------------------------------
MSG[summary_title]="Сводка (секреты скрыты)"
MSG[summary_services]="Сервисы"
MSG[summary_tag]="Тег образа"
MSG[summary_db]="База данных"
MSG[summary_redis]="Redis"
MSG[summary_storage]="Хранилище"
MSG[summary_access]="Доступ"
MSG[confirm_start]="Сгенерировать конфиг и запустить сейчас?"
MSG[embedded]="встроенный"
MSG[external]="внешний"
MSG[pull_images]="Скачивание образов (%s)..."
MSG[pull_hint]="Если pull падает с 'unauthorized', выполните 'docker login git.ollyhearn.ru' (приватный регистри)."
MSG[starting_deps]="Запуск зависимостей..."
MSG[waiting_health]="Ожидание готовности %s..."
MSG[running_migrations]="Выполнение миграций БД..."
MSG[creating_admin]="Создание первого администратора..."
MSG[starting_app]="Запуск сервисов приложения..."
MSG[checking_health]="Проверка работоспособности API..."
# -- done / errors ---------------------------------------------------------
MSG[done_title]="MCMA запущен."
MSG[done_url]="Откройте"
MSG[done_config]="Конфиг (храните в секрете)"
MSG[done_admin_login]="Логин администратора"
MSG[done_commands]="Управление: make status | make logs | make update | make down"
MSG[err_migrations_failed]="Миграции не выполнились. Backend НЕ запущен поверх битой БД. Смотрите 'make logs'."
MSG[err_health_timeout]="API не стал healthy вовремя. Смотрите 'make logs' и 'make status'."
MSG[hint_logs]="Выполните 'make logs', чтобы увидеть причину."
MSG[no_config]="Установка не найдена. Сначала выполните 'make deploy'."
MSG[clean_confirm]="Удалить сгенерированные файлы конфигурации?"
MSG[clean_volumes_confirm]="Также УДАЛИТЬ все тома данных (база, медиа)? Это необратимо."
MSG[clean_done]="Очистка завершена."
+45
View File
@@ -0,0 +1,45 @@
# Preflight checks. Fail loud and early with actionable messages.
check_bash() {
if ((BASH_VERSINFO[0] < 4)); then
ui_err "$(t err_bash_version "$BASH_VERSION")"
exit 1
fi
}
check_docker() {
if ! command -v docker >/dev/null 2>&1; then
ui_err "$(t err_need_docker)"; exit 1
fi
if ! docker info >/dev/null 2>&1; then
ui_err "$(t err_docker_daemon)"; exit 1
fi
if ! docker compose version >/dev/null 2>&1; then
ui_err "$(t err_need_compose)"; exit 1
fi
}
check_openssl() {
if ! command -v openssl >/dev/null 2>&1; then
ui_err "$(t err_need_openssl)"; exit 1
fi
}
# is_port_free PORT — best effort; returns 0 if nothing seems to listen on it.
is_port_free() {
local port="$1"
if command -v lsof >/dev/null 2>&1; then
! lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1
elif command -v nc >/dev/null 2>&1; then
! nc -z localhost "$port" >/dev/null 2>&1
else
return 0 # can't check — assume free
fi
}
preflight() {
check_bash
check_docker
check_openssl
ui_ok "$(t preflight_ok)"
}
+103
View File
@@ -0,0 +1,103 @@
# docker-compose.yml + Caddyfile generation by gluing service fragments.
#
# Fragments live in templates/compose/. A few carry @TOKENS@ that depend on the
# user's choices (published ports, depends_on for embedded backing services);
# those are substituted here. External backing services contribute no fragment.
_frag() { cat "${BOOTSTRAP_DIR}/templates/compose/$1"; }
generate_compose() {
local nl=$'\n'
local api_ports="" api_depends="" worker_depends="" webui_ports="" caddy_ports=""
local dep=""
# depends_on: only wait on backing services we actually run, and only when
# they are healthy (never start the backend over an unready dependency).
[[ "$CFG_DB_MODE" == "embedded" ]] && dep+=" db:${nl} condition: service_healthy${nl}"
[[ "$CFG_REDIS_MODE" == "embedded" ]] && dep+=" redis:${nl} condition: service_healthy${nl}"
if [[ -n "$dep" ]]; then
api_depends=" depends_on:${nl}${dep%"$nl"}"
worker_depends="$api_depends"
fi
# API port: published whenever there is no bundled proxy (backend-only, or
# webui with the operator's own proxy). Behind Caddy the API stays in-network.
if [[ "$CFG_PROXY" == "no" ]]; then
api_ports=" ports:${nl} - \"\${API_PORT}:8000\""
fi
# Webui port: published only when the webui runs without the bundled proxy.
if [[ "$CFG_WEBUI" == "yes" && "$CFG_PROXY" == "no" ]]; then
webui_ports=" ports:${nl} - \"\${WEBUI_PORT}:80\""
fi
# Caddy publish ports: 80/443 with a domain (auto-HTTPS), else plain HTTP.
if [[ "$CFG_HTTPS" == "yes" ]]; then
caddy_ports=" - \"80:80\"${nl} - \"443:443\""
else
caddy_ports=" - \"\${HTTP_PORT}:80\""
fi
# -- assemble ---------------------------------------------------------
local backend webui caddy
backend="$(_frag backend.yml)"
backend="${backend//@API_PORTS@/$api_ports}"
backend="${backend//@API_DEPENDS@/$api_depends}"
backend="${backend//@WORKER_DEPENDS@/$worker_depends}"
{
_frag _base.yml
printf '%s\n' "$backend"
if [[ "$CFG_WEBUI" == "yes" ]]; then
webui="$(_frag webui.yml)"
webui="${webui//@WEBUI_PORTS@/$webui_ports}"
printf '%s\n' "$webui"
fi
[[ "$CFG_DB_MODE" == "embedded" ]] && _frag postgres.yml
[[ "$CFG_REDIS_MODE" == "embedded" ]] && _frag redis.yml
[[ "$CFG_STORAGE" == "s3" && "$CFG_S3_MODE" == "embedded" ]] && _frag minio.yml
if [[ "$CFG_PROXY" == "yes" ]]; then
caddy="$(_frag caddy.yml)"
caddy="${caddy//@CADDY_PORTS@/$caddy_ports}"
printf '%s\n' "$caddy"
fi
# -- named volumes (only those actually referenced) ---------------
echo ""
echo "volumes:"
echo " transcode_cache:"
[[ "$CFG_DB_MODE" == "embedded" ]] && echo " pgdata:"
[[ "$CFG_REDIS_MODE" == "embedded" ]] && echo " redisdata:"
[[ "$CFG_STORAGE" == "s3" && "$CFG_S3_MODE" == "embedded" ]] && echo " miniodata:"
if [[ "$CFG_PROXY" == "yes" ]]; then
echo " caddy_data:"
echo " caddy_config:"
fi
} >"$COMPOSE_FILE"
[[ "$CFG_PROXY" == "yes" ]] && generate_caddyfile
}
# generate_caddyfile — concrete Caddyfile (no env indirection). Routes API
# paths to the backend and everything else to the static webui.
generate_caddyfile() {
local site
if [[ "$CFG_HTTPS" == "yes" ]]; then site="$CFG_DOMAIN"; else site=":80"; fi
{
if [[ "$CFG_HTTPS" == "yes" && -n "${CFG_ACME_EMAIL:-}" ]]; then
echo "{"
echo " email ${CFG_ACME_EMAIL}"
echo "}"
echo ""
fi
echo "${site} {"
echo " @api path /api/* /health* /rest/*"
echo " reverse_proxy @api api:8000"
echo " reverse_proxy webui:80"
echo "}"
} >"$CADDYFILE"
}
+79
View File
@@ -0,0 +1,79 @@
# .env.deploy generation. Renders templates/env/env.template (token
# substitution) and appends conditional blocks for the chosen options.
# The file is written with mode 600 and never echoed to stdout.
# render_template FILE — substitutes @KEY@ tokens from the RENDER assoc array.
# Prints the result to stdout.
render_template() {
local file="$1" content key
content="$(cat "$file")"
for key in "${!RENDER[@]}"; do
content="${content//@${key}@/${RENDER[$key]}}"
done
printf '%s\n' "$content"
}
# build_database_url — sets CFG_DATABASE_URL from the collected DB config.
build_database_url() {
if [[ "$CFG_DB_MODE" == "embedded" ]]; then
CFG_DATABASE_URL="postgresql+asyncpg://${CFG_DB_USER}:${CFG_DB_PASS}@db:5432/${CFG_DB_NAME}"
else
CFG_DATABASE_URL="postgresql+asyncpg://${CFG_DB_USER}:${CFG_DB_PASS}@${CFG_DB_HOST}:${CFG_DB_PORT}/${CFG_DB_NAME}"
fi
}
# generate_env — writes $ENV_FILE from template + conditional sections.
generate_env() {
build_database_url
declare -A RENDER=(
[MCMA_IMAGE_TAG]="$CFG_TAG"
[DATABASE_URL]="$CFG_DATABASE_URL"
[REDIS_URL]="$CFG_REDIS_URL"
[JWT_SECRET]="$CFG_JWT_SECRET"
[MEDIA_HOST_PATH]="$CFG_MEDIA_HOST_PATH"
[STORAGE_BACKEND]="$CFG_STORAGE"
[PUBLIC_API_BASE_URL]="$CFG_PUBLIC_API_BASE_URL"
[HTTP_PORT]="$CFG_HTTP_PORT"
[API_PORT]="$CFG_API_PORT"
[WEBUI_PORT]="$CFG_WEBUI_PORT"
)
umask 077
render_template "${BOOTSTRAP_DIR}/templates/env/env.template" >"$ENV_FILE"
# -- embedded Postgres credentials (needed by the db service) ---------
if [[ "$CFG_DB_MODE" == "embedded" ]]; then
{
echo ""
echo "# -- built-in Postgres --------------------------------------------------"
echo "POSTGRES_USER=${CFG_DB_USER}"
echo "POSTGRES_PASSWORD=${CFG_DB_PASS}"
echo "POSTGRES_DB=${CFG_DB_NAME}"
} >>"$ENV_FILE"
fi
# -- S3 storage -------------------------------------------------------
if [[ "$CFG_STORAGE" == "s3" ]]; then
{
echo ""
echo "# -- S3 storage ---------------------------------------------------------"
echo "S3_ENDPOINT_URL=${CFG_S3_ENDPOINT}"
echo "S3_BUCKET=${CFG_S3_BUCKET}"
echo "S3_REGION=${CFG_S3_REGION}"
echo "S3_ACCESS_KEY=${CFG_S3_KEY}"
echo "S3_SECRET_KEY=${CFG_S3_SECRET}"
} >>"$ENV_FILE"
fi
# -- optional ML service ----------------------------------------------
if [[ -n "${CFG_ML_URL:-}" ]]; then
{
echo ""
echo "# -- optional ML service ------------------------------------------------"
echo "ML_SERVICE_URL=${CFG_ML_URL}"
} >>"$ENV_FILE"
fi
chmod 600 "$ENV_FILE"
}
+30
View File
@@ -0,0 +1,30 @@
# i18n loader. Exposes the MSG associative array and the `t` accessor.
#
# i18n_load en # populate MSG from i18n/en.sh
# t step_db # echo MSG[step_db]
# t pull_images foo # printf MSG[pull_images] with args (for %s placeholders)
#
# Strings live in i18n/<locale>.sh; logic never hardcodes user-facing text.
declare -A MSG
i18n_load() {
local locale="$1"
local file="${BOOTSTRAP_DIR}/i18n/${locale}.sh"
[[ -f "$file" ]] || file="${BOOTSTRAP_DIR}/i18n/en.sh"
# shellcheck source=/dev/null
source "$file"
LOCALE="$locale"
}
# t KEY [printf-args...] — resolve a string, optionally formatting placeholders.
t() {
local key="$1"; shift
local s="${MSG[$key]:-$key}"
if (($# > 0)); then
# shellcheck disable=SC2059
printf "$s" "$@"
else
printf '%s' "$s"
fi
}
+133
View File
@@ -0,0 +1,133 @@
# Lifecycle: pull, ordered start (deps → migrate → app), first admin, health,
# plus update/down/logs/status/clean. Wraps `docker compose` with the project's
# env file and generated compose file.
PROJECT="${MCMA_PROJECT:-mcma}"
dc() {
docker compose \
--project-name "$PROJECT" \
--project-directory "$BOOTSTRAP_DIR" \
--env-file "$ENV_FILE" \
-f "$COMPOSE_FILE" "$@"
}
# backing_services — the embedded dependencies present in the compose file that
# must be healthy/complete before migrations (subset of db redis minio*).
backing_services() {
local all want s out=""
all="$(dc config --services 2>/dev/null)"
for want in db redis minio minio-setup; do
while IFS= read -r s; do
[[ "$s" == "$want" ]] && out+="$want "
done <<<"$all"
done
printf '%s' "${out% }"
}
# app_services — everything that is not a one-shot/backing dependency wait.
ensure_media_dir() {
# MEDIA_HOST_PATH is read from the generated env file.
local p
p="$(grep -E '^MEDIA_HOST_PATH=' "$ENV_FILE" | cut -d= -f2-)"
[[ -n "$p" ]] || return 0
# Resolve relative paths against the project directory (compose does too).
[[ "$p" = /* ]] || p="${BOOTSTRAP_DIR}/${p#./}"
mkdir -p "$p"
}
lifecycle_pull() {
ui_info "$(t pull_images "$(grep -E '^MCMA_IMAGE_TAG=' "$ENV_FILE" | cut -d= -f2-)")"
ui_dim "$(t pull_hint)"
dc pull
}
# wait_api_healthy — poll the running API's liveness endpoint over the compose
# network (no host port / TLS assumptions). Returns non-zero on timeout.
wait_api_healthy() {
ui_info "$(t checking_health)"
dc run --rm --no-deps -T api python -c '
import urllib.request, time, sys
for _ in range(60):
try:
if urllib.request.urlopen("http://api:8000/health", timeout=2).status == 200:
sys.exit(0)
except Exception:
pass
time.sleep(2)
sys.exit(1)
'
}
# lifecycle_start CREATE_ADMIN(yes|no)
# Ordered, fail-loud startup. Never starts the backend over a broken DB.
lifecycle_start() {
local create_admin="${1:-no}"
ensure_media_dir
lifecycle_pull
local deps; deps="$(backing_services)"
if [[ -n "$deps" ]]; then
ui_info "$(t starting_deps)"
# shellcheck disable=SC2086
dc up -d --wait $deps
fi
ui_info "$(t running_migrations)"
if ! dc run --rm --no-deps -T api alembic upgrade head; then
ui_err "$(t err_migrations_failed)"
exit 1
fi
if [[ "$create_admin" == "yes" ]]; then
ui_info "$(t creating_admin)"
# Password passed via --password to avoid interactive prompts; not echoed.
dc run --rm --no-deps -T api mcma create-admin "$CFG_ADMIN_USER" --password "$CFG_ADMIN_PASS"
fi
ui_info "$(t starting_app)"
dc up -d
if ! wait_api_healthy; then
ui_err "$(t err_health_timeout)"
exit 1
fi
}
# -- management commands ---------------------------------------------------
lifecycle_update() {
lifecycle_pull
local deps; deps="$(backing_services)"
if [[ -n "$deps" ]]; then
# shellcheck disable=SC2086
dc up -d --wait $deps
fi
ui_info "$(t running_migrations)"
if ! dc run --rm --no-deps -T api alembic upgrade head; then
ui_err "$(t err_migrations_failed)"; exit 1
fi
dc up -d
wait_api_healthy || { ui_err "$(t err_health_timeout)"; exit 1; }
ui_ok "$(t done_title)"
}
lifecycle_up() { ensure_media_dir; dc up -d; }
lifecycle_down() { dc down; }
lifecycle_logs() { dc logs -f --tail=100; }
lifecycle_status() {
dc ps
echo
if wait_api_healthy >/dev/null 2>&1; then ui_ok "API: ok"; else ui_warn "API: not ready"; fi
}
lifecycle_clean() {
dc down
if ui_yesno "$(t clean_volumes_confirm)" "no"; then
dc down -v
fi
if ui_yesno "$(t clean_confirm)" "yes"; then
rm -f "$COMPOSE_FILE" "$CADDYFILE" "$ENV_FILE"
ui_ok "$(t clean_done)"
fi
}
+22
View File
@@ -0,0 +1,22 @@
# Secret generation. Secrets are generated, never requested from the user,
# and never printed. URL-safe alphanumerics only (avoid shell/URL/env quoting
# pitfalls in .env and connection strings).
#
# Note: we deliberately avoid `... | head -c N` here. Under `set -o pipefail`
# head closes the pipe early, the upstream producer dies with SIGPIPE, and the
# whole script would abort. Instead we read a full chunk and slice in bash.
# gen_secret [LENGTH] — alphanumeric token, default 32 chars. Sets SECRET.
gen_secret() {
local len="${1:-32}" raw=""
while ((${#raw} < len)); do
# openssl finishes before tr reads EOF — no early pipe close.
raw+="$(openssl rand -base64 $((len + 16)) | LC_ALL=C tr -dc 'A-Za-z0-9')"
done
SECRET="${raw:0:len}"
}
# gen_hex BYTES — hex string (e.g. JWT secret). Sets SECRET.
gen_hex() {
SECRET="$(openssl rand -hex "${1:-32}")"
}
+134
View File
@@ -0,0 +1,134 @@
# Pure-bash prompt helpers — no external TUI, no dependencies.
#
# Every prompt shows its default, re-asks on invalid input, and lets Ctrl+C
# abort cleanly (the trap is installed in deploy.sh). Results are returned via
# the REPLY-style globals documented on each function.
# -- colours (disabled if not a TTY or NO_COLOR set) -----------------------
if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then
C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'
C_CYAN=$'\033[36m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_RED=$'\033[31m'
else
C_RESET=''; C_BOLD=''; C_DIM=''; C_CYAN=''; C_GREEN=''; C_YELLOW=''; C_RED=''
fi
ui_title() { printf '\n%s%s%s\n' "$C_BOLD$C_CYAN" "$1" "$C_RESET"; }
ui_info() { printf '%s\n' "$1"; }
ui_dim() { printf '%s%s%s\n' "$C_DIM" "$1" "$C_RESET"; }
ui_ok() { printf '%s✓ %s%s\n' "$C_GREEN" "$1" "$C_RESET"; }
ui_warn() { printf '%s! %s%s\n' "$C_YELLOW" "$1" "$C_RESET"; }
ui_err() { printf '%s✗ %s%s\n' "$C_RED" "$1" "$C_RESET" >&2; }
# ui_input PROMPT DEFAULT [VALIDATOR_FN]
# Sets UI_VALUE. VALIDATOR_FN (optional) receives the candidate value and
# returns 0 to accept; on rejection it should print why (to stderr).
ui_input() {
local prompt="$1" default="${2:-}" validator="${3:-}" ans
while true; do
if [[ -n "$default" ]]; then
read -r -p "$prompt [${C_DIM}${default}${C_RESET}]: " ans || exit 130
ans="${ans:-$default}"
else
read -r -p "$prompt: " ans || exit 130
fi
if [[ -n "$validator" ]]; then
if ! "$validator" "$ans"; then continue; fi
fi
UI_VALUE="$ans"
return 0
done
}
# ui_secret PROMPT — no echo. Sets UI_VALUE.
ui_secret() {
local prompt="$1" ans
read -r -s -p "$prompt: " ans || exit 130
echo
UI_VALUE="$ans"
}
# ui_yesno PROMPT DEFAULT(yes|no) — sets UI_BOOL to "yes"/"no", returns 0/1.
ui_yesno() {
local prompt="$1" default="${2:-yes}" ans hint
[[ "$default" == "yes" ]] && hint="[Y/n]" || hint="[y/N]"
while true; do
read -r -p "$prompt $hint: " ans || exit 130
ans="${ans:-$default}"
case "${ans,,}" in
y|yes|да|д) UI_BOOL="yes"; return 0 ;;
n|no|нет|н) UI_BOOL="no"; return 1 ;;
*) ui_warn "$(t invalid_input)" ;;
esac
done
}
# ui_select PROMPT "label1" "label2" ...
# Numbered single-choice menu. Sets UI_INDEX (1-based) and UI_VALUE (label).
ui_select() {
local prompt="$1"; shift
local -a opts=("$@")
local i ans
ui_info "$prompt"
for i in "${!opts[@]}"; do
printf ' %s%d%s) %s\n' "$C_CYAN" "$((i + 1))" "$C_RESET" "${opts[$i]}"
done
while true; do
read -r -p "> [${C_DIM}1${C_RESET}]: " ans || exit 130
ans="${ans:-1}"
if [[ "$ans" =~ ^[0-9]+$ ]] && ((ans >= 1 && ans <= ${#opts[@]})); then
UI_INDEX="$ans"
UI_VALUE="${opts[$((ans - 1))]}"
return 0
fi
ui_warn "$(t invalid_input)"
done
}
# ui_multiselect PROMPT "key1:label1:on" "key2:label2:off" "key3:label3:locked"
# Toggle items by number; 'locked' items cannot be turned off. Empty line
# confirms. Sets UI_SELECTED (space-separated keys that ended up on).
ui_multiselect() {
local prompt="$1"; shift
local -a keys=() labels=() states=() locks=()
local spec key label state
for spec in "$@"; do
key="${spec%%:*}"; spec="${spec#*:}"
label="${spec%%:*}"; state="${spec#*:}"
keys+=("$key"); labels+=("$label")
if [[ "$state" == "locked" ]]; then states+=("on"); locks+=("yes")
else states+=("$state"); locks+=("no"); fi
done
local i ans
while true; do
ui_info "$prompt"
for i in "${!keys[@]}"; do
local mark="[ ]"; [[ "${states[$i]}" == "on" ]] && mark="[x]"
local lock=""; [[ "${locks[$i]}" == "yes" ]] && lock=" ${C_DIM}(required)${C_RESET}"
printf ' %s%d%s) %s %s%s\n' "$C_CYAN" "$((i + 1))" "$C_RESET" "$mark" "${labels[$i]}" "$lock"
done
ui_dim "$(t enter_to_keep) — number toggles, Enter confirms"
read -r -p "> " ans || exit 130
[[ -z "$ans" ]] && break
if [[ "$ans" =~ ^[0-9]+$ ]] && ((ans >= 1 && ans <= ${#keys[@]})); then
local idx=$((ans - 1))
if [[ "${locks[$idx]}" == "yes" ]]; then
ui_warn "$(t backend_required)"
elif [[ "${states[$idx]}" == "on" ]]; then states[$idx]="off"
else states[$idx]="on"; fi
else
ui_warn "$(t invalid_input)"
fi
done
UI_SELECTED=""
for i in "${!keys[@]}"; do
[[ "${states[$i]}" == "on" ]] && UI_SELECTED+="${keys[$i]} "
done
UI_SELECTED="${UI_SELECTED% }"
}
# -- common validators (for ui_input) --------------------------------------
v_nonempty() { [[ -n "$1" ]] || { ui_warn "$(t invalid_input)"; return 1; }; }
v_port() { [[ "$1" =~ ^[0-9]+$ ]] && (($1 >= 1 && $1 <= 65535)) || { ui_warn "$(t invalid_input)"; return 1; }; }
v_url() { [[ "$1" =~ ^https?:// ]] || { ui_warn "$(t invalid_input)"; return 1; }; }
v_redis() { [[ "$1" =~ ^rediss?:// ]] || { ui_warn "$(t invalid_input)"; return 1; }; }
v_domain() { [[ "$1" =~ ^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]] || { ui_warn "$(t invalid_input)"; return 1; }; }
+5
View File
@@ -0,0 +1,5 @@
# ============================================================================
# Generated by mcma-bootstrap — DO NOT EDIT, DO NOT COMMIT.
# Regenerate with `make deploy`. All values come from .env.deploy.
# ============================================================================
services:
+20
View File
@@ -0,0 +1,20 @@
# -- backend: one image, two roles (API server + arq worker) -------------
api:
image: ${BACKEND_IMAGE}:${MCMA_IMAGE_TAG}
restart: unless-stopped
env_file: .env.deploy
volumes:
- ${MEDIA_HOST_PATH}:${MEDIA_PATH}
- transcode_cache:${TRANSCODE_CACHE_PATH}
@API_PORTS@
@API_DEPENDS@
worker:
image: ${BACKEND_IMAGE}:${MCMA_IMAGE_TAG}
restart: unless-stopped
command: arq app.workers.arq_worker.WorkerSettings
env_file: .env.deploy
volumes:
- ${MEDIA_HOST_PATH}:${MEDIA_PATH}
- transcode_cache:${TRANSCODE_CACHE_PATH}
@WORKER_DEPENDS@
+16
View File
@@ -0,0 +1,16 @@
# -- reverse proxy: single entrypoint, same-origin for webui + API -------
# Required when the webui is deployed: the prebuilt webui image bakes a
# same-origin '/api/v1' base URL and only serves static assets, so a proxy
# must route /api, /health and /rest to the backend.
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
@CADDY_PORTS@
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- api
- webui
+26
View File
@@ -0,0 +1,26 @@
# -- built-in S3 (MinIO) + one-shot bucket creation ----------------------
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY}
volumes:
- miniodata:/data
# Waits for MinIO to accept connections, then creates the bucket. Runs once
# (restart: no) and exits 0 — `compose up --wait` treats that as complete.
minio-setup:
image: minio/mc:latest
restart: "no"
depends_on:
- minio
entrypoint: >
/bin/sh -c "
until mc alias set mcma http://minio:9000 ${S3_ACCESS_KEY} ${S3_SECRET_KEY}; do
echo 'waiting for minio...'; sleep 2;
done &&
mc mb --ignore-existing mcma/${S3_BUCKET} &&
echo 'bucket ready'
"
+15
View File
@@ -0,0 +1,15 @@
# -- built-in Postgres ---------------------------------------------------
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
+12
View File
@@ -0,0 +1,12 @@
# -- built-in Redis (cache + arq broker) ---------------------------------
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --save 60 1 --loglevel warning
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
+11
View File
@@ -0,0 +1,11 @@
# -- webui: prebuilt static SPA (served on :80 inside the container) ------
# The browser-facing API base URL is injected at container start from
# PUBLIC_API_BASE_URL (window.__APP_CONFIG__) — no rebuild needed.
webui:
image: ${WEBUI_IMAGE}:${MCMA_IMAGE_TAG}
restart: unless-stopped
environment:
PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL}
@WEBUI_PORTS@
depends_on:
- api
+41
View File
@@ -0,0 +1,41 @@
# ============================================================================
# Generated by mcma-bootstrap — chmod 600, DO NOT COMMIT.
# Regenerate with `make deploy`. Secrets here are generated, not chosen.
# ============================================================================
# -- images ---------------------------------------------------------------
BACKEND_IMAGE=git.ollyhearn.ru/olly/mcma-backend
WEBUI_IMAGE=git.ollyhearn.ru/olly/mcma-webui
MCMA_IMAGE_TAG=@MCMA_IMAGE_TAG@
# -- runtime --------------------------------------------------------------
ENVIRONMENT=prod
LOG_LEVEL=INFO
LOG_JSON=true
# -- database / redis -----------------------------------------------------
DATABASE_URL=@DATABASE_URL@
REDIS_URL=@REDIS_URL@
# -- auth (generated) -----------------------------------------------------
JWT_SECRET=@JWT_SECRET@
ACCESS_TOKEN_TTL_SECONDS=900
REFRESH_TOKEN_TTL_SECONDS=2592000
# -- media / storage ------------------------------------------------------
MEDIA_PATH=/data/media
TRANSCODE_CACHE_PATH=/data/transcode-cache
MEDIA_HOST_PATH=@MEDIA_HOST_PATH@
MAX_PARALLEL_DOWNLOADS=2
STORAGE_BACKEND=@STORAGE_BACKEND@
# -- frontend / metadata --------------------------------------------------
# Browser-facing API base URL. Injected into the webui at container start
# (window.__APP_CONFIG__). '/api/v1' = same-origin behind the reverse proxy.
PUBLIC_API_BASE_URL=@PUBLIC_API_BASE_URL@
MUSICBRAINZ_USER_AGENT=mcma-backend/0.1.0 ( https://github.com/your/repo )
# -- published ports ------------------------------------------------------
HTTP_PORT=@HTTP_PORT@
API_PORT=@API_PORT@
WEBUI_PORT=@WEBUI_PORT@