diff --git a/compose.prod.ssh-tunnel.yaml b/compose.prod.ssh-tunnel.yaml index 5cc7cf2..adf9666 100644 --- a/compose.prod.ssh-tunnel.yaml +++ b/compose.prod.ssh-tunnel.yaml @@ -43,6 +43,11 @@ services: - ALL app: + environment: + VMANGOS_AUTH_DB_HOST: core-db-tunnel + VMANGOS_AUTH_DB_PORT: '3306' + VMANGOS_CHARACTERS_DB_HOST: core-db-tunnel + VMANGOS_CHARACTERS_DB_PORT: '3306' depends_on: core-db-tunnel: condition: service_healthy diff --git a/docs/ssh-core-tunnel.md b/docs/ssh-core-tunnel.md index b6546ad..3122af8 100644 --- a/docs/ssh-core-tunnel.md +++ b/docs/ssh-core-tunnel.md @@ -194,6 +194,24 @@ VMANGOS_CHARACTERS_DB_PASSWORD="ПАРОЛЬ_БД_ЯДРА" ## 4. Запуск на веб-сервере +Для полного деплоя используйте обновлённый скрипт: + +```bash +./scripts/deploy-production.sh --ssh-tunnel +``` + +Флаг добавляет файл туннеля во все production-команды Compose, собирает и +запускает SSH-контейнер, ждёт его `healthy`, затем проверяет обе БД через +`SELECT 1` перед миграциями. Если `VMANGOS_AUTH_DB_HOST` либо +`VMANGOS_CHARACTERS_DB_HOST` уже равен `core-db-tunnel`, режим включается +автоматически. Файл туннеля задаёт адреса и порты БД приложения независимо +от устаревших `*_DB_HOST=db` в `.env`; имена баз и учётные данные берутся из `.env`. +Удалите `VMANGOS_*_DB_URL`, чтобы они не переопределяли адрес подключения. +Предупреждение про `stalwart` относится к отдельному `compose.mail.yaml`; +скрипт не удаляет orphan-контейнеры. + +Команды ниже позволяют запустить и проверить туннель отдельно от полного деплоя. + Из папки `moonwell-web`: ```bash diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh index c210d79..a3aa756 100755 --- a/scripts/deploy-production.sh +++ b/scripts/deploy-production.sh @@ -8,6 +8,8 @@ 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')" "$*" @@ -19,12 +21,42 @@ fail() { } env_value() { - local key="$1" - grep -E "^${key}=" "$ENV_FILE" | tail -n 1 | cut -d '=' -f 2- | sed -e 's/^"//' -e 's/"$//' + 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 -f "$PROD_COMPOSE" "$@" + docker compose "${PROD_COMPOSE_ARGS[@]}" "$@" } legacy() { @@ -63,12 +95,50 @@ rollback_legacy() { 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)" @@ -86,15 +156,28 @@ main() { 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 @@ -117,6 +200,12 @@ main() { 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 } diff --git a/tests/deployment/test_production_deploy.py b/tests/deployment/test_production_deploy.py new file mode 100644 index 0000000..e6fb96e --- /dev/null +++ b/tests/deployment/test_production_deploy.py @@ -0,0 +1,117 @@ +"""Deployment ordering checks using isolated fixtures and stubbed external commands.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +REPO = Path(__file__).resolve().parents[2] +DOCKER_STUB = r"""#!/usr/bin/env python3 +import json, os, sys +args = sys.argv[1:] +with open(os.environ['DEPLOY_TEST_TRACE'], 'a') as log: + log.write(json.dumps(args) + '\n') +if args[0] == 'inspect': + print('false' if '.State.Running' in args[2] else 'healthy') +elif args[0] == 'compose': + command = args[1:] + while command and command[0] == '-f': + command = command[2:] + if command[:2] == ['ps', '-q']: + if command[2] != 'laravel.test': + print('fixture-' + command[2]) + if command and command[0] == 'exec' and 'sha256sum' in command: + print('fixture-hash /var/www/html/public/build/manifest.json') + if command and command[0] == 'exec' and '-r' in command: + print('/build/fixture.js') + print('/build/fixture.css') + if command and command[0] == 'exec' and any('SELECT 1' in value for value in command): + if os.environ.get('DEPLOY_TEST_DB_FAILURE') == '1': + sys.exit(17) +""" + + +class ProductionDeployTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='moonwell-deploy-test-') + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + (self.root / 'scripts').mkdir() + (self.root / 'bin').mkdir() + shutil.copy2(REPO / 'scripts/deploy-production.sh', self.root / 'scripts') + for name in ['compose.prod.yaml', 'compose.prod.ssh-tunnel.yaml', 'compose.yaml']: + (self.root / name).touch() + self.trace = self.root / 'trace.jsonl' + for name, content in [('docker', DOCKER_STUB), ('curl', '#!/bin/sh\ncase "$*" in *fixture.js*) printf application/javascript ;; *fixture.css*) printf text/css ;; esac\n')]: + path = self.root / 'bin' / name + path.write_text(content) + path.chmod(0o755) + + def run_deploy(self, content='', args=(), fail_db=False): + # Deliberately omit optional WWWUSER/WWWGROUP/APP_PORT settings. + (self.root / '.env').write_text(content) + env = dict(os.environ, PATH=str(self.root / 'bin') + ':' + os.environ['PATH'], + DEPLOY_TEST_TRACE=str(self.trace), DEPLOY_TEST_DB_FAILURE=str(int(fail_db))) + result = subprocess.run(['bash', str(self.root / 'scripts/deploy-production.sh'), *args], + cwd=self.root, env=env, capture_output=True, text=True) + calls = [json.loads(line) for line in self.trace.read_text().splitlines()] if self.trace.exists() else [] + return result, calls + + def operations(self, calls): + result = [] + for call in calls: + if call[0] != 'compose': + continue + command = call[1:] + while command and command[0] == '-f': + command = command[2:] + result.append(command) + return result + + def test_direct_database_mode_and_preflight_order(self): + result, calls = self.run_deploy('VMANGOS_AUTH_DB_HOST=192.0.2.1\n') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(any('compose.prod.ssh-tunnel.yaml' in arg for call in calls for arg in call)) + ops = self.operations(calls) + probe = next(i for i, op in enumerate(ops) if any('SELECT 1' in arg for arg in op)) + migration = next(i for i, op in enumerate(ops) if 'migrate' in op) + clear = next(i for i, op in enumerate(ops) if 'config:clear' in op) + self.assertLess(clear, probe) + self.assertLess(probe, migration) + + def test_ssh_flag_starts_tunnel_before_app(self): + result, calls = self.run_deploy('VMANGOS_AUTH_DB_HOST=db\n', ['--ssh-tunnel']) + self.assertEqual(result.returncode, 0, result.stderr) + production_calls = [call for call in calls if any(arg.endswith('/compose.prod.yaml') for arg in call)] + self.assertTrue(production_calls) + self.assertTrue(all(any(arg.endswith('/compose.prod.ssh-tunnel.yaml') for arg in call) + for call in production_calls)) + ops = self.operations(calls) + tunnel = ops.index(['up', '-d', '--no-deps', 'core-db-tunnel']) + app = ops.index(['up', '-d', '--no-deps', 'app']) + self.assertLess(tunnel, app) + self.assertTrue(all('--remove-orphans' not in call for call in calls)) + + def test_single_quoted_host_enables_ssh_automatically(self): + result, calls = self.run_deploy("VMANGOS_AUTH_DB_HOST='core-db-tunnel'\n") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(['build', 'core-db-tunnel'], self.operations(calls)) + + def test_failed_database_probe_prevents_migrations(self): + result, calls = self.run_deploy('VMANGOS_AUTH_DB_HOST=core-db-tunnel\n', fail_db=True) + self.assertNotEqual(result.returncode, 0) + ops = self.operations(calls) + self.assertTrue(any(any('SELECT 1' in arg for arg in op) for op in ops)) + self.assertFalse(any('migrate' in op or 'optimize' in op for op in ops)) + + def test_database_url_rejected_before_build(self): + result, calls = self.run_deploy('VMANGOS_AUTH_DB_HOST=core-db-tunnel\nVMANGOS_AUTH_DB_URL=mysql://fixture\n') + self.assertNotEqual(result.returncode, 0) + self.assertIn('VMANGOS_AUTH_DB_URL', result.stderr) + self.assertFalse(any(op and op[0] in ['build', 'up', 'exec'] for op in self.operations(calls))) + + +if __name__ == '__main__': + unittest.main()