This commit is contained in:
Senko-san
2026-06-08 12:49:45 +03:00
commit 1b251869c4
21 changed files with 1404 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
# Preflight checks. Fail loud and early with actionable messages.
check_bash() {
if ((BASH_VERSINFO[0] < 4)); then
ui_err "$(t err_bash_version "$BASH_VERSION")"
exit 1
fi
}
check_docker() {
if ! command -v docker >/dev/null 2>&1; then
ui_err "$(t err_need_docker)"; exit 1
fi
if ! docker info >/dev/null 2>&1; then
ui_err "$(t err_docker_daemon)"; exit 1
fi
if ! docker compose version >/dev/null 2>&1; then
ui_err "$(t err_need_compose)"; exit 1
fi
}
check_openssl() {
if ! command -v openssl >/dev/null 2>&1; then
ui_err "$(t err_need_openssl)"; exit 1
fi
}
# is_port_free PORT — best effort; returns 0 if nothing seems to listen on it.
is_port_free() {
local port="$1"
if command -v lsof >/dev/null 2>&1; then
! lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1
elif command -v nc >/dev/null 2>&1; then
! nc -z localhost "$port" >/dev/null 2>&1
else
return 0 # can't check — assume free
fi
}
preflight() {
check_bash
check_docker
check_openssl
ui_ok "$(t preflight_ok)"
}
+103
View File
@@ -0,0 +1,103 @@
# docker-compose.yml + Caddyfile generation by gluing service fragments.
#
# Fragments live in templates/compose/. A few carry @TOKENS@ that depend on the
# user's choices (published ports, depends_on for embedded backing services);
# those are substituted here. External backing services contribute no fragment.
_frag() { cat "${BOOTSTRAP_DIR}/templates/compose/$1"; }
generate_compose() {
local nl=$'\n'
local api_ports="" api_depends="" worker_depends="" webui_ports="" caddy_ports=""
local dep=""
# depends_on: only wait on backing services we actually run, and only when
# they are healthy (never start the backend over an unready dependency).
[[ "$CFG_DB_MODE" == "embedded" ]] && dep+=" db:${nl} condition: service_healthy${nl}"
[[ "$CFG_REDIS_MODE" == "embedded" ]] && dep+=" redis:${nl} condition: service_healthy${nl}"
if [[ -n "$dep" ]]; then
api_depends=" depends_on:${nl}${dep%"$nl"}"
worker_depends="$api_depends"
fi
# API port: published whenever there is no bundled proxy (backend-only, or
# webui with the operator's own proxy). Behind Caddy the API stays in-network.
if [[ "$CFG_PROXY" == "no" ]]; then
api_ports=" ports:${nl} - \"\${API_PORT}:8000\""
fi
# Webui port: published only when the webui runs without the bundled proxy.
if [[ "$CFG_WEBUI" == "yes" && "$CFG_PROXY" == "no" ]]; then
webui_ports=" ports:${nl} - \"\${WEBUI_PORT}:80\""
fi
# Caddy publish ports: 80/443 with a domain (auto-HTTPS), else plain HTTP.
if [[ "$CFG_HTTPS" == "yes" ]]; then
caddy_ports=" - \"80:80\"${nl} - \"443:443\""
else
caddy_ports=" - \"\${HTTP_PORT}:80\""
fi
# -- assemble ---------------------------------------------------------
local backend webui caddy
backend="$(_frag backend.yml)"
backend="${backend//@API_PORTS@/$api_ports}"
backend="${backend//@API_DEPENDS@/$api_depends}"
backend="${backend//@WORKER_DEPENDS@/$worker_depends}"
{
_frag _base.yml
printf '%s\n' "$backend"
if [[ "$CFG_WEBUI" == "yes" ]]; then
webui="$(_frag webui.yml)"
webui="${webui//@WEBUI_PORTS@/$webui_ports}"
printf '%s\n' "$webui"
fi
[[ "$CFG_DB_MODE" == "embedded" ]] && _frag postgres.yml
[[ "$CFG_REDIS_MODE" == "embedded" ]] && _frag redis.yml
[[ "$CFG_STORAGE" == "s3" && "$CFG_S3_MODE" == "embedded" ]] && _frag minio.yml
if [[ "$CFG_PROXY" == "yes" ]]; then
caddy="$(_frag caddy.yml)"
caddy="${caddy//@CADDY_PORTS@/$caddy_ports}"
printf '%s\n' "$caddy"
fi
# -- named volumes (only those actually referenced) ---------------
echo ""
echo "volumes:"
echo " transcode_cache:"
[[ "$CFG_DB_MODE" == "embedded" ]] && echo " pgdata:"
[[ "$CFG_REDIS_MODE" == "embedded" ]] && echo " redisdata:"
[[ "$CFG_STORAGE" == "s3" && "$CFG_S3_MODE" == "embedded" ]] && echo " miniodata:"
if [[ "$CFG_PROXY" == "yes" ]]; then
echo " caddy_data:"
echo " caddy_config:"
fi
} >"$COMPOSE_FILE"
[[ "$CFG_PROXY" == "yes" ]] && generate_caddyfile
}
# generate_caddyfile — concrete Caddyfile (no env indirection). Routes API
# paths to the backend and everything else to the static webui.
generate_caddyfile() {
local site
if [[ "$CFG_HTTPS" == "yes" ]]; then site="$CFG_DOMAIN"; else site=":80"; fi
{
if [[ "$CFG_HTTPS" == "yes" && -n "${CFG_ACME_EMAIL:-}" ]]; then
echo "{"
echo " email ${CFG_ACME_EMAIL}"
echo "}"
echo ""
fi
echo "${site} {"
echo " @api path /api/* /health* /rest/*"
echo " reverse_proxy @api api:8000"
echo " reverse_proxy webui:80"
echo "}"
} >"$CADDYFILE"
}
+79
View File
@@ -0,0 +1,79 @@
# .env.deploy generation. Renders templates/env/env.template (token
# substitution) and appends conditional blocks for the chosen options.
# The file is written with mode 600 and never echoed to stdout.
# render_template FILE — substitutes @KEY@ tokens from the RENDER assoc array.
# Prints the result to stdout.
render_template() {
local file="$1" content key
content="$(cat "$file")"
for key in "${!RENDER[@]}"; do
content="${content//@${key}@/${RENDER[$key]}}"
done
printf '%s\n' "$content"
}
# build_database_url — sets CFG_DATABASE_URL from the collected DB config.
build_database_url() {
if [[ "$CFG_DB_MODE" == "embedded" ]]; then
CFG_DATABASE_URL="postgresql+asyncpg://${CFG_DB_USER}:${CFG_DB_PASS}@db:5432/${CFG_DB_NAME}"
else
CFG_DATABASE_URL="postgresql+asyncpg://${CFG_DB_USER}:${CFG_DB_PASS}@${CFG_DB_HOST}:${CFG_DB_PORT}/${CFG_DB_NAME}"
fi
}
# generate_env — writes $ENV_FILE from template + conditional sections.
generate_env() {
build_database_url
declare -A RENDER=(
[MCMA_IMAGE_TAG]="$CFG_TAG"
[DATABASE_URL]="$CFG_DATABASE_URL"
[REDIS_URL]="$CFG_REDIS_URL"
[JWT_SECRET]="$CFG_JWT_SECRET"
[MEDIA_HOST_PATH]="$CFG_MEDIA_HOST_PATH"
[STORAGE_BACKEND]="$CFG_STORAGE"
[PUBLIC_API_BASE_URL]="$CFG_PUBLIC_API_BASE_URL"
[HTTP_PORT]="$CFG_HTTP_PORT"
[API_PORT]="$CFG_API_PORT"
[WEBUI_PORT]="$CFG_WEBUI_PORT"
)
umask 077
render_template "${BOOTSTRAP_DIR}/templates/env/env.template" >"$ENV_FILE"
# -- embedded Postgres credentials (needed by the db service) ---------
if [[ "$CFG_DB_MODE" == "embedded" ]]; then
{
echo ""
echo "# -- built-in Postgres --------------------------------------------------"
echo "POSTGRES_USER=${CFG_DB_USER}"
echo "POSTGRES_PASSWORD=${CFG_DB_PASS}"
echo "POSTGRES_DB=${CFG_DB_NAME}"
} >>"$ENV_FILE"
fi
# -- S3 storage -------------------------------------------------------
if [[ "$CFG_STORAGE" == "s3" ]]; then
{
echo ""
echo "# -- S3 storage ---------------------------------------------------------"
echo "S3_ENDPOINT_URL=${CFG_S3_ENDPOINT}"
echo "S3_BUCKET=${CFG_S3_BUCKET}"
echo "S3_REGION=${CFG_S3_REGION}"
echo "S3_ACCESS_KEY=${CFG_S3_KEY}"
echo "S3_SECRET_KEY=${CFG_S3_SECRET}"
} >>"$ENV_FILE"
fi
# -- optional ML service ----------------------------------------------
if [[ -n "${CFG_ML_URL:-}" ]]; then
{
echo ""
echo "# -- optional ML service ------------------------------------------------"
echo "ML_SERVICE_URL=${CFG_ML_URL}"
} >>"$ENV_FILE"
fi
chmod 600 "$ENV_FILE"
}
+30
View File
@@ -0,0 +1,30 @@
# i18n loader. Exposes the MSG associative array and the `t` accessor.
#
# i18n_load en # populate MSG from i18n/en.sh
# t step_db # echo MSG[step_db]
# t pull_images foo # printf MSG[pull_images] with args (for %s placeholders)
#
# Strings live in i18n/<locale>.sh; logic never hardcodes user-facing text.
declare -A MSG
i18n_load() {
local locale="$1"
local file="${BOOTSTRAP_DIR}/i18n/${locale}.sh"
[[ -f "$file" ]] || file="${BOOTSTRAP_DIR}/i18n/en.sh"
# shellcheck source=/dev/null
source "$file"
LOCALE="$locale"
}
# t KEY [printf-args...] — resolve a string, optionally formatting placeholders.
t() {
local key="$1"; shift
local s="${MSG[$key]:-$key}"
if (($# > 0)); then
# shellcheck disable=SC2059
printf "$s" "$@"
else
printf '%s' "$s"
fi
}
+133
View File
@@ -0,0 +1,133 @@
# Lifecycle: pull, ordered start (deps → migrate → app), first admin, health,
# plus update/down/logs/status/clean. Wraps `docker compose` with the project's
# env file and generated compose file.
PROJECT="${MCMA_PROJECT:-mcma}"
dc() {
docker compose \
--project-name "$PROJECT" \
--project-directory "$BOOTSTRAP_DIR" \
--env-file "$ENV_FILE" \
-f "$COMPOSE_FILE" "$@"
}
# backing_services — the embedded dependencies present in the compose file that
# must be healthy/complete before migrations (subset of db redis minio*).
backing_services() {
local all want s out=""
all="$(dc config --services 2>/dev/null)"
for want in db redis minio minio-setup; do
while IFS= read -r s; do
[[ "$s" == "$want" ]] && out+="$want "
done <<<"$all"
done
printf '%s' "${out% }"
}
# app_services — everything that is not a one-shot/backing dependency wait.
ensure_media_dir() {
# MEDIA_HOST_PATH is read from the generated env file.
local p
p="$(grep -E '^MEDIA_HOST_PATH=' "$ENV_FILE" | cut -d= -f2-)"
[[ -n "$p" ]] || return 0
# Resolve relative paths against the project directory (compose does too).
[[ "$p" = /* ]] || p="${BOOTSTRAP_DIR}/${p#./}"
mkdir -p "$p"
}
lifecycle_pull() {
ui_info "$(t pull_images "$(grep -E '^MCMA_IMAGE_TAG=' "$ENV_FILE" | cut -d= -f2-)")"
ui_dim "$(t pull_hint)"
dc pull
}
# wait_api_healthy — poll the running API's liveness endpoint over the compose
# network (no host port / TLS assumptions). Returns non-zero on timeout.
wait_api_healthy() {
ui_info "$(t checking_health)"
dc run --rm --no-deps -T api python -c '
import urllib.request, time, sys
for _ in range(60):
try:
if urllib.request.urlopen("http://api:8000/health", timeout=2).status == 200:
sys.exit(0)
except Exception:
pass
time.sleep(2)
sys.exit(1)
'
}
# lifecycle_start CREATE_ADMIN(yes|no)
# Ordered, fail-loud startup. Never starts the backend over a broken DB.
lifecycle_start() {
local create_admin="${1:-no}"
ensure_media_dir
lifecycle_pull
local deps; deps="$(backing_services)"
if [[ -n "$deps" ]]; then
ui_info "$(t starting_deps)"
# shellcheck disable=SC2086
dc up -d --wait $deps
fi
ui_info "$(t running_migrations)"
if ! dc run --rm --no-deps -T api alembic upgrade head; then
ui_err "$(t err_migrations_failed)"
exit 1
fi
if [[ "$create_admin" == "yes" ]]; then
ui_info "$(t creating_admin)"
# Password passed via --password to avoid interactive prompts; not echoed.
dc run --rm --no-deps -T api mcma create-admin "$CFG_ADMIN_USER" --password "$CFG_ADMIN_PASS"
fi
ui_info "$(t starting_app)"
dc up -d
if ! wait_api_healthy; then
ui_err "$(t err_health_timeout)"
exit 1
fi
}
# -- management commands ---------------------------------------------------
lifecycle_update() {
lifecycle_pull
local deps; deps="$(backing_services)"
if [[ -n "$deps" ]]; then
# shellcheck disable=SC2086
dc up -d --wait $deps
fi
ui_info "$(t running_migrations)"
if ! dc run --rm --no-deps -T api alembic upgrade head; then
ui_err "$(t err_migrations_failed)"; exit 1
fi
dc up -d
wait_api_healthy || { ui_err "$(t err_health_timeout)"; exit 1; }
ui_ok "$(t done_title)"
}
lifecycle_up() { ensure_media_dir; dc up -d; }
lifecycle_down() { dc down; }
lifecycle_logs() { dc logs -f --tail=100; }
lifecycle_status() {
dc ps
echo
if wait_api_healthy >/dev/null 2>&1; then ui_ok "API: ok"; else ui_warn "API: not ready"; fi
}
lifecycle_clean() {
dc down
if ui_yesno "$(t clean_volumes_confirm)" "no"; then
dc down -v
fi
if ui_yesno "$(t clean_confirm)" "yes"; then
rm -f "$COMPOSE_FILE" "$CADDYFILE" "$ENV_FILE"
ui_ok "$(t clean_done)"
fi
}
+22
View File
@@ -0,0 +1,22 @@
# Secret generation. Secrets are generated, never requested from the user,
# and never printed. URL-safe alphanumerics only (avoid shell/URL/env quoting
# pitfalls in .env and connection strings).
#
# Note: we deliberately avoid `... | head -c N` here. Under `set -o pipefail`
# head closes the pipe early, the upstream producer dies with SIGPIPE, and the
# whole script would abort. Instead we read a full chunk and slice in bash.
# gen_secret [LENGTH] — alphanumeric token, default 32 chars. Sets SECRET.
gen_secret() {
local len="${1:-32}" raw=""
while ((${#raw} < len)); do
# openssl finishes before tr reads EOF — no early pipe close.
raw+="$(openssl rand -base64 $((len + 16)) | LC_ALL=C tr -dc 'A-Za-z0-9')"
done
SECRET="${raw:0:len}"
}
# gen_hex BYTES — hex string (e.g. JWT secret). Sets SECRET.
gen_hex() {
SECRET="$(openssl rand -hex "${1:-32}")"
}
+134
View File
@@ -0,0 +1,134 @@
# Pure-bash prompt helpers — no external TUI, no dependencies.
#
# Every prompt shows its default, re-asks on invalid input, and lets Ctrl+C
# abort cleanly (the trap is installed in deploy.sh). Results are returned via
# the REPLY-style globals documented on each function.
# -- colours (disabled if not a TTY or NO_COLOR set) -----------------------
if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then
C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'
C_CYAN=$'\033[36m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_RED=$'\033[31m'
else
C_RESET=''; C_BOLD=''; C_DIM=''; C_CYAN=''; C_GREEN=''; C_YELLOW=''; C_RED=''
fi
ui_title() { printf '\n%s%s%s\n' "$C_BOLD$C_CYAN" "$1" "$C_RESET"; }
ui_info() { printf '%s\n' "$1"; }
ui_dim() { printf '%s%s%s\n' "$C_DIM" "$1" "$C_RESET"; }
ui_ok() { printf '%s✓ %s%s\n' "$C_GREEN" "$1" "$C_RESET"; }
ui_warn() { printf '%s! %s%s\n' "$C_YELLOW" "$1" "$C_RESET"; }
ui_err() { printf '%s✗ %s%s\n' "$C_RED" "$1" "$C_RESET" >&2; }
# ui_input PROMPT DEFAULT [VALIDATOR_FN]
# Sets UI_VALUE. VALIDATOR_FN (optional) receives the candidate value and
# returns 0 to accept; on rejection it should print why (to stderr).
ui_input() {
local prompt="$1" default="${2:-}" validator="${3:-}" ans
while true; do
if [[ -n "$default" ]]; then
read -r -p "$prompt [${C_DIM}${default}${C_RESET}]: " ans || exit 130
ans="${ans:-$default}"
else
read -r -p "$prompt: " ans || exit 130
fi
if [[ -n "$validator" ]]; then
if ! "$validator" "$ans"; then continue; fi
fi
UI_VALUE="$ans"
return 0
done
}
# ui_secret PROMPT — no echo. Sets UI_VALUE.
ui_secret() {
local prompt="$1" ans
read -r -s -p "$prompt: " ans || exit 130
echo
UI_VALUE="$ans"
}
# ui_yesno PROMPT DEFAULT(yes|no) — sets UI_BOOL to "yes"/"no", returns 0/1.
ui_yesno() {
local prompt="$1" default="${2:-yes}" ans hint
[[ "$default" == "yes" ]] && hint="[Y/n]" || hint="[y/N]"
while true; do
read -r -p "$prompt $hint: " ans || exit 130
ans="${ans:-$default}"
case "${ans,,}" in
y|yes|да|д) UI_BOOL="yes"; return 0 ;;
n|no|нет|н) UI_BOOL="no"; return 1 ;;
*) ui_warn "$(t invalid_input)" ;;
esac
done
}
# ui_select PROMPT "label1" "label2" ...
# Numbered single-choice menu. Sets UI_INDEX (1-based) and UI_VALUE (label).
ui_select() {
local prompt="$1"; shift
local -a opts=("$@")
local i ans
ui_info "$prompt"
for i in "${!opts[@]}"; do
printf ' %s%d%s) %s\n' "$C_CYAN" "$((i + 1))" "$C_RESET" "${opts[$i]}"
done
while true; do
read -r -p "> [${C_DIM}1${C_RESET}]: " ans || exit 130
ans="${ans:-1}"
if [[ "$ans" =~ ^[0-9]+$ ]] && ((ans >= 1 && ans <= ${#opts[@]})); then
UI_INDEX="$ans"
UI_VALUE="${opts[$((ans - 1))]}"
return 0
fi
ui_warn "$(t invalid_input)"
done
}
# ui_multiselect PROMPT "key1:label1:on" "key2:label2:off" "key3:label3:locked"
# Toggle items by number; 'locked' items cannot be turned off. Empty line
# confirms. Sets UI_SELECTED (space-separated keys that ended up on).
ui_multiselect() {
local prompt="$1"; shift
local -a keys=() labels=() states=() locks=()
local spec key label state
for spec in "$@"; do
key="${spec%%:*}"; spec="${spec#*:}"
label="${spec%%:*}"; state="${spec#*:}"
keys+=("$key"); labels+=("$label")
if [[ "$state" == "locked" ]]; then states+=("on"); locks+=("yes")
else states+=("$state"); locks+=("no"); fi
done
local i ans
while true; do
ui_info "$prompt"
for i in "${!keys[@]}"; do
local mark="[ ]"; [[ "${states[$i]}" == "on" ]] && mark="[x]"
local lock=""; [[ "${locks[$i]}" == "yes" ]] && lock=" ${C_DIM}(required)${C_RESET}"
printf ' %s%d%s) %s %s%s\n' "$C_CYAN" "$((i + 1))" "$C_RESET" "$mark" "${labels[$i]}" "$lock"
done
ui_dim "$(t enter_to_keep) — number toggles, Enter confirms"
read -r -p "> " ans || exit 130
[[ -z "$ans" ]] && break
if [[ "$ans" =~ ^[0-9]+$ ]] && ((ans >= 1 && ans <= ${#keys[@]})); then
local idx=$((ans - 1))
if [[ "${locks[$idx]}" == "yes" ]]; then
ui_warn "$(t backend_required)"
elif [[ "${states[$idx]}" == "on" ]]; then states[$idx]="off"
else states[$idx]="on"; fi
else
ui_warn "$(t invalid_input)"
fi
done
UI_SELECTED=""
for i in "${!keys[@]}"; do
[[ "${states[$i]}" == "on" ]] && UI_SELECTED+="${keys[$i]} "
done
UI_SELECTED="${UI_SELECTED% }"
}
# -- common validators (for ui_input) --------------------------------------
v_nonempty() { [[ -n "$1" ]] || { ui_warn "$(t invalid_input)"; return 1; }; }
v_port() { [[ "$1" =~ ^[0-9]+$ ]] && (($1 >= 1 && $1 <= 65535)) || { ui_warn "$(t invalid_input)"; return 1; }; }
v_url() { [[ "$1" =~ ^https?:// ]] || { ui_warn "$(t invalid_input)"; return 1; }; }
v_redis() { [[ "$1" =~ ^rediss?:// ]] || { ui_warn "$(t invalid_input)"; return 1; }; }
v_domain() { [[ "$1" =~ ^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]] || { ui_warn "$(t invalid_input)"; return 1; }; }