initial
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Generated by the wizard — never commit these.
|
||||
.env.deploy
|
||||
.env.deploy.bak.*
|
||||
docker-compose.yml
|
||||
Caddyfile
|
||||
data/
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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
@@ -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]="Очистка завершена."
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}")"
|
||||
}
|
||||
@@ -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; }; }
|
||||
@@ -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:
|
||||
@@ -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@
|
||||
@@ -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
|
||||
@@ -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'
|
||||
"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Vendored
+41
@@ -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@
|
||||
Reference in New Issue
Block a user