commit 1b251869c455d4fda374de3f7d2e346175200b15 Author: Senko-san Date: Mon Jun 8 12:49:45 2026 +0300 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e06665b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Generated by the wizard — never commit these. +.env.deploy +.env.deploy.bak.* +docker-compose.yml +Caddyfile +data/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3b1b4a5 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7393dc4 --- /dev/null +++ b/README.md @@ -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 +``` + +## 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 +``` diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..1fee0c1 --- /dev/null +++ b/deploy.sh @@ -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 "$@" diff --git a/i18n/en.sh b/i18n/en.sh new file mode 100644 index 0000000..61c2825 --- /dev/null +++ b/i18n/en.sh @@ -0,0 +1,135 @@ +# English strings. Each entry sets MSG[]. 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." diff --git a/i18n/ru.sh b/i18n/ru.sh new file mode 100644 index 0000000..1655941 --- /dev/null +++ b/i18n/ru.sh @@ -0,0 +1,135 @@ +# Русские строки. Каждая запись задаёт MSG[]. Загружается 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]="Очистка завершена." diff --git a/lib/checks.sh b/lib/checks.sh new file mode 100644 index 0000000..de86376 --- /dev/null +++ b/lib/checks.sh @@ -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)" +} diff --git a/lib/compose_gen.sh b/lib/compose_gen.sh new file mode 100644 index 0000000..9ed6dc8 --- /dev/null +++ b/lib/compose_gen.sh @@ -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" +} diff --git a/lib/env_gen.sh b/lib/env_gen.sh new file mode 100644 index 0000000..8e6888c --- /dev/null +++ b/lib/env_gen.sh @@ -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" +} diff --git a/lib/i18n.sh b/lib/i18n.sh new file mode 100644 index 0000000..5db7af9 --- /dev/null +++ b/lib/i18n.sh @@ -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/.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 +} diff --git a/lib/lifecycle.sh b/lib/lifecycle.sh new file mode 100644 index 0000000..9d4f318 --- /dev/null +++ b/lib/lifecycle.sh @@ -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 +} diff --git a/lib/secrets.sh b/lib/secrets.sh new file mode 100644 index 0000000..c58935e --- /dev/null +++ b/lib/secrets.sh @@ -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}")" +} diff --git a/lib/ui.sh b/lib/ui.sh new file mode 100644 index 0000000..ff760aa --- /dev/null +++ b/lib/ui.sh @@ -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; }; } diff --git a/templates/compose/_base.yml b/templates/compose/_base.yml new file mode 100644 index 0000000..c0b4828 --- /dev/null +++ b/templates/compose/_base.yml @@ -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: diff --git a/templates/compose/backend.yml b/templates/compose/backend.yml new file mode 100644 index 0000000..73456ff --- /dev/null +++ b/templates/compose/backend.yml @@ -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@ diff --git a/templates/compose/caddy.yml b/templates/compose/caddy.yml new file mode 100644 index 0000000..5e9bd0d --- /dev/null +++ b/templates/compose/caddy.yml @@ -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 diff --git a/templates/compose/minio.yml b/templates/compose/minio.yml new file mode 100644 index 0000000..458c2ae --- /dev/null +++ b/templates/compose/minio.yml @@ -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' + " diff --git a/templates/compose/postgres.yml b/templates/compose/postgres.yml new file mode 100644 index 0000000..e5d859a --- /dev/null +++ b/templates/compose/postgres.yml @@ -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 diff --git a/templates/compose/redis.yml b/templates/compose/redis.yml new file mode 100644 index 0000000..9130ae0 --- /dev/null +++ b/templates/compose/redis.yml @@ -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 diff --git a/templates/compose/webui.yml b/templates/compose/webui.yml new file mode 100644 index 0000000..381f7cc --- /dev/null +++ b/templates/compose/webui.yml @@ -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 diff --git a/templates/env/env.template b/templates/env/env.template new file mode 100644 index 0000000..e5df296 --- /dev/null +++ b/templates/env/env.template @@ -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@