fix
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user