#!/usr/bin/env bash set -Eeuo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" PROD_COMPOSE="${ROOT_DIR}/compose.prod.yaml" DEV_COMPOSE="${ROOT_DIR}/compose.yaml" ENV_FILE="${ROOT_DIR}/.env" LEGACY_WAS_RUNNING=0 PROD_COMPOSE_ARGS=(-f "$PROD_COMPOSE") SSH_TUNNEL=0 log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" } fail() { printf '[error] %s\n' "$*" >&2 exit 1 } env_value() { local key="$1" line value line="$(grep -E "^${key}=" "$ENV_FILE" | tail -n 1 || true)" value="${line#*=}" case "$value" in \"*\") value="${value:1:${#value}-2}" ;; \'*\') value="${value:1:${#value}-2}" ;; esac printf '%s\n' "$value" } configure_compose() { local auth_host characters_host arg for arg in "$@"; do case "$arg" in --ssh-tunnel) SSH_TUNNEL=1 ;; *) fail "Неизвестный параметр: $arg (доступен --ssh-tunnel)" ;; esac done auth_host="$(env_value VMANGOS_AUTH_DB_HOST)" characters_host="$(env_value VMANGOS_CHARACTERS_DB_HOST)" if [[ "$auth_host" == core-db-tunnel || "$characters_host" == core-db-tunnel ]]; then SSH_TUNNEL=1 fi if (( SSH_TUNNEL )); then [[ -f "${ROOT_DIR}/compose.prod.ssh-tunnel.yaml" ]] || fail "Не найден compose.prod.ssh-tunnel.yaml" if [[ -n "$(env_value VMANGOS_AUTH_DB_URL)" || -n "$(env_value VMANGOS_CHARACTERS_DB_URL)" ]]; then fail "Для SSH-туннеля уберите VMANGOS_AUTH_DB_URL и VMANGOS_CHARACTERS_DB_URL; используйте отдельные настройки БД" fi PROD_COMPOSE_ARGS+=(-f "${ROOT_DIR}/compose.prod.ssh-tunnel.yaml") log "БД ядра: SSH-туннель core-db-tunnel:3306" fi prod config --quiet } prod() { docker compose "${PROD_COMPOSE_ARGS[@]}" "$@" } legacy() { docker compose -f "$DEV_COMPOSE" "$@" } wait_for_health() { local service="$1" local container_id local status container_id="$(prod ps -q "$service")" [[ -n "$container_id" ]] || fail "Контейнер ${service} не создан" for _ in $(seq 1 45); do status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")" if [[ "$status" == healthy ]]; then return 0 fi if [[ "$status" == exited || "$status" == dead ]]; then docker logs --tail 100 "$container_id" >&2 || true fail "Контейнер ${service} завершился со статусом ${status}" fi sleep 2 done docker logs --tail 100 "$container_id" >&2 || true fail "Контейнер ${service} не перешёл в healthy" } rollback_legacy() { log "Production web не прошёл проверку, выполняю откат" prod stop web app >/dev/null 2>&1 || true if (( LEGACY_WAS_RUNNING )); then legacy start laravel.test >/dev/null 2>&1 || true fi } verify_frontend_assets() { local app_port="$1" local app_manifest web_manifest assets asset_path content_type app_manifest="$(prod exec -T app sha256sum /var/www/html/public/build/manifest.json)" || return 1 web_manifest="$(prod exec -T web sha256sum /var/www/html/public/build/manifest.json)" || return 1 if [[ "$app_manifest" != "$web_manifest" ]]; then log "Сборки frontend в app и web не совпадают" return 1 fi assets="$(prod exec -T app php -r ' $manifest = json_decode(file_get_contents("public/build/manifest.json"), true, 512, JSON_THROW_ON_ERROR); $entry = $manifest["resources/js/app.js"] ?? []; if (! isset($entry["file"])) { fwrite(STDERR, "Missing JavaScript entry in Vite manifest\n"); exit(1); } foreach (array_merge([$entry["file"]], $entry["css"] ?? []) as $asset) { echo "/build/".$asset."\n"; } ')" || return 1 while IFS= read -r asset_path; do content_type="$(curl --fail --silent --show-error --max-time 15 \ --output /dev/null --write-out '%{content_type}' \ "http://127.0.0.1:${app_port}${asset_path}")" || return 1 case "${asset_path}:${content_type}" in *.js:application/javascript*|*.js:text/javascript*|*.css:text/css*) ;; *) log "Некорректный Content-Type для ${asset_path}: ${content_type}" return 1 ;; esac done <<< "$assets" } main() { command -v docker >/dev/null 2>&1 || fail "Не найден Docker" docker compose version >/dev/null 2>&1 || fail "Не найден Docker Compose Plugin" [[ -f "$ENV_FILE" ]] || fail "Не найден ${ENV_FILE}" cd "$ROOT_DIR" configure_compose "$@" local app_uid app_gid app_port legacy_id app_uid="$(env_value WWWUSER)" app_gid="$(env_value WWWGROUP)" app_port="$(env_value APP_PORT)" app_uid="${app_uid:-1000}" app_gid="${app_gid:-1000}" app_port="${app_port:-8080}" mkdir -p storage/app/public storage/framework/cache/data storage/framework/sessions storage/framework/views storage/logs if [[ "$(id -u)" -eq 0 ]]; then chown -R "${app_uid}:${app_gid}" storage fi chmod -R ug+rwX storage log "Собираю production-образы" prod build app web if (( SSH_TUNNEL )); then prod build core-db-tunnel fi log "Запускаю MySQL сайта" prod up -d mysql wait_for_health mysql if (( SSH_TUNNEL )); then log "Запускаю SSH-туннель к БД ядра" prod up -d --no-deps core-db-tunnel wait_for_health core-db-tunnel fi log "Запускаю новый PHP-FPM" prod up -d --no-deps app wait_for_health app log "Проверяю доступ приложения к обеим БД ядра" prod exec -T --user "${app_uid}:${app_gid}" app php artisan config:clear prod exec -T --user "${app_uid}:${app_gid}" app php artisan tinker --execute='foreach (["vmangos_auth", "vmangos_characters"] as $name) { DB::connection($name)->select("SELECT 1"); echo $name." OK\n"; }' log "Выполняю миграции и прогреваю кэш" prod exec -T --user "${app_uid}:${app_gid}" app php artisan migrate --force prod exec -T --user "${app_uid}:${app_gid}" app php artisan optimize legacy_id="$(legacy ps -q laravel.test 2>/dev/null || true)" if [[ -n "$legacy_id" ]] && [[ "$(docker inspect -f '{{.State.Running}}' "$legacy_id")" == true ]]; then LEGACY_WAS_RUNNING=1 log "Останавливаю legacy Sail-контейнер" legacy stop laravel.test fi log "Переключаю nginx на production web-контейнер" if ! prod up -d --no-deps web || ! wait_for_health web; then rollback_legacy exit 1 fi if ! curl --fail --silent --max-time 15 "http://127.0.0.1:${app_port}/" >/dev/null; then rollback_legacy fail "HTTP-проверка production web завершилась ошибкой" fi log "Проверяю соответствие сборок frontend и доступность JavaScript/CSS" if ! verify_frontend_assets "$app_port"; then rollback_legacy fail "Проверка frontend завершилась ошибкой" fi log "Production-деплой завершён: app и web healthy" prod ps app web } main "$@"