Files
chatballs/deploy/cli/lib/deploy.sh
T
Andrey 41a4b0dd84 ✨ feat: запуск без .env — всё настраивается в UI, и порталы по дизайну
Первичное требование: поднял докер — прошёл мастер — дальше всё в интерфейсе.
Ни одной переменной окружения задавать не нужно и негде.

Запуск и секреты:
- .env.example удалён, мёртвые переменные вычищены; секреты инстанса
  генерирует одноразовый сервис secrets в именованный том, пароли БД —
  через POSTGRES_*_PASSWORD_FILE;
- Caddy: catch-all :80 и on_demand TLS вместо хостов в конфиге — свежая
  коробка отвечает по IP и по любому домену, до мастера дойти можно;
- адрес установки, SMTP и TURN переехали в настройки (InstanceSettings,
  миграции 0027–0030), внешние ссылки строятся от него;
- deploy/cli больше не читает .env; сборка образов в ghcr через GitHub
  Actions, release.env с digest-пинами;
- куки Secure/__Host- выставляются по факту TLS запроса, а не настройкой.

Порталы (дизайн-базлайн v2, кадры PT1–PT8):
- список, карточка портала, библиотека материалов, редактор статьи и
  настройки — ширины и ритм как в остальных разделах;
- файлы статьи: изображение вставляется в текст своим механизмом, файл
  прикрепляется вложением и выводится на портале списком с иконкой формата;
- ссылки на файлы приводятся к относительным: абсолютный хост резал CSP
  портала и картинка не появлялась;
- «Опубликовать» сверяется с сервером и публикует то, что на экране, а не
  ранее выбранную редакцию;
- колонка «Оценки» в списке статей и блок оценок в редакторе.

Первый запуск: полоса «демо-данные устанавливаются» — установка идёт в
worker, и без неё человек видел пустые разделы без объяснения.

Починен фон: вторичное хранилище стало best-effort — при живых остаточных
S3-ключах повторная загрузка файла падала в 500.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 20:37:48 +03:00

204 lines
8.4 KiB
Bash

#!/usr/bin/env bash
# deploy.sh — canonical deployment workflow (ADR-HUB-0028 / SPEC-HUB-0019).
cmd_deploy() {
ensure_instance_dirs
acquire_lock
trap release_lock EXIT
log "deploy: validating release and instance config"
_deploy_validate || die "deploy: validation failed" 1
log "deploy: pulling immutable images"
run_compose pull || die "deploy: image pull failed" 1
log "deploy: starting infrastructure (postgres, redis)"
run_compose up -d postgres redis || die "deploy: infrastructure start failed" 1
_wait_healthy postgres 60 || die "deploy: postgres did not become healthy" 1
_wait_healthy redis 30 || die "deploy: redis did not become healthy" 1
log "deploy: normalizing schema ownership for migrations"
_normalize_schema_ownership || die "deploy: schema ownership normalization failed" 1
log "deploy: running one-shot init (migrate)"
run_compose run --rm init || die "deploy: init (migrate) failed" 1
log "deploy: starting application services"
local app_services=(backend-app backend-platform backend-admin worker frontend gateway)
if profile_enabled calls; then
app_services+=(coturn)
fi
run_compose up -d "${app_services[@]}" || die "deploy: application start failed" 1
log "deploy: waiting for application health"
_wait_healthy backend-app 90 || die "deploy: app backend did not become healthy" 1
_wait_healthy backend-platform 90 || die "deploy: platform backend did not become healthy" 1
_wait_running backend-admin 30 || die "deploy: admin backend did not start" 1
_wait_running frontend 30 || die "deploy: frontend did not start" 1
_wait_running gateway 30 || die "deploy: gateway did not start" 1
if profile_enabled calls; then
_wait_healthy coturn 60 || die "deploy: coturn did not become healthy" 1
fi
log "deploy: running smoke checks"
_smoke || die "deploy: smoke checks failed" 1
_record_release
log_ok "deploy: complete (release: $(_applied_version_target))"
}
_deploy_validate() {
[[ -f "$(compose_file)" ]] || { log_err "compose.yaml missing"; return 1; }
[[ -f "$(release_env_file)" ]] || { log_err "release.env missing"; return 1; }
verify_release_checksums || return 1
validate_release_image_refs || return 1
local app_domain platform_domain
app_domain="$(env_get "$(instance_env_file)" CHATBALLS_APP_DOMAIN)"
platform_domain="$(env_get "$(instance_env_file)" CHATBALLS_PLATFORM_DOMAIN)"
# Домены знать необязательно: свежая установка отвечает по адресу сервера,
# а свой домен владелец задаёт потом в «Настройках». Проверяем только то,
# что если домены заданы оба — они разные.
if [[ -n "$app_domain" && -n "$platform_domain" && "$app_domain" == "$platform_domain" ]]; then
log_err "app and platform domains must be distinct"
return 1
fi
if profile_enabled calls; then
validate_calls_network_boundary || return 1
fi
compose_config_validate >/dev/null 2>&1 || {
log_err "compose config invalid"
return 1
}
}
_wait_healthy() {
local svc="$1" timeout="$2" waited=0 state
while [[ "$waited" -lt "$timeout" ]]; do
state="$(run_compose ps --format json "$svc" 2>/dev/null | _first_json_service_state)"
[[ "$state" == "healthy" ]] && return 0
sleep 3
waited=$((waited + 3))
done
return 1
}
_wait_running() {
local svc="$1" timeout="$2" waited=0 state
while [[ "$waited" -lt "$timeout" ]]; do
state="$(run_compose ps --format json "$svc" 2>/dev/null | _first_json_service_state)"
case "$state" in
healthy|running|Up*) return 0 ;;
esac
sleep 3
waited=$((waited + 3))
done
return 1
}
_normalize_schema_ownership() {
# Приводит владение объектов public-схемы к роли chatballs_schema, в которую
# входит migration-user. Идемпотентно: безопасно на каждом деплое. Без этого
# миграции от migration-user падают на таблицах, созданных не им
# («must be owner of table …»). Выполняется под суперпользователем POSTGRES_USER.
local env_file pg_user pg_db
env_file="$(instance_env_file)"
pg_user="$(env_get "$env_file" POSTGRES_USER)"
pg_db="$(env_get "$env_file" POSTGRES_DB)"
[[ -n "$pg_user" ]] || { log_err "POSTGRES_USER not set"; return 1; }
[[ -n "$pg_db" ]] || { log_err "POSTGRES_DB not set"; return 1; }
run_compose exec -T postgres \
psql -v ON_ERROR_STOP=1 -U "$pg_user" -d "$pg_db" \
-f /chatballs-reassign-ownership.sql >/dev/null
}
_first_json_service_state() {
local line
IFS= read -r line || return 0
if [[ $line =~ \"Health\":\"([^\"]*)\" ]] && [[ -n "${BASH_REMATCH[1]}" ]]; then
printf '%s\n' "${BASH_REMATCH[1]}"
elif [[ $line =~ \"State\":\"([^\"]*)\" ]]; then
printf '%s\n' "${BASH_REMATCH[1]}"
elif [[ $line =~ \"Status\":\"([^\"]*)\" ]]; then
printf '%s\n' "${BASH_REMATCH[1]}"
fi
}
_smoke() {
local app_domain platform_domain
app_domain="$(env_get "$(instance_env_file)" CHATBALLS_APP_DOMAIN)"
platform_domain="$(env_get "$(instance_env_file)" CHATBALLS_PLATFORM_DOMAIN)"
# Свежая установка домена не знает: проверяем её так же, как её открывает
# человек — по адресу сервера, без домена и без https.
[[ -n "$app_domain" ]] || app_domain="localhost"
[[ -n "$platform_domain" ]] || platform_domain="localhost"
# Значения передаём через env внутри контейнера, а не флагами -e: так
# команда остаётся привычной формы «exec -T backend-app …».
run_compose exec -T backend-app \
env SMOKE_APP_HOST="$app_domain" SMOKE_PLATFORM_HOST="$platform_domain" \
python - <<'PY' >/dev/null 2>&1 || {
import os
import urllib.request
app_host = os.environ["SMOKE_APP_HOST"]
platform_host = os.environ["SMOKE_PLATFORM_HOST"]
app_health = urllib.request.Request(
"http://127.0.0.1:8000/api/v1/health/ready/",
headers={"Host": app_host},
)
with urllib.request.urlopen(app_health, timeout=5) as response:
assert response.status == 200
platform_health = urllib.request.Request(
"http://backend-platform:8000/api/v1/health/ready/",
headers={"Host": platform_host},
)
with urllib.request.urlopen(platform_health, timeout=5) as response:
assert response.status == 200
with urllib.request.urlopen("http://frontend/", timeout=5) as response:
assert response.status == 200
# Шлюз обязан отвечать по http на любом адресе: домена и сертификата у свежей
# установки нет, а человек открывает её сразу после docker compose up.
gateway = urllib.request.Request("http://gateway/", headers={"Host": app_host})
with urllib.request.urlopen(gateway, timeout=5) as response:
assert response.status == 200
PY
log_err "smoke: internal app/platform/frontend/gateway checks failed"
return 1
}
log_ok "smoke: app, platform, frontend and gateway"
# Публичные https-адреса проверяем, только если домен задан: у свежей
# установки его нет, и «недоступен» здесь ничего не значит.
if [[ "$app_domain" != "localhost" ]] && command -v curl >/dev/null 2>&1; then
local app_code platform_code
app_code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "https://$app_domain/" 2>/dev/null || true)"
platform_code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "https://$platform_domain/api/v1/health/live/" 2>/dev/null || true)"
if [[ "$app_code" =~ ^(200|30[12378])$ ]] && [[ "$platform_code" == "200" ]]; then
log_ok "smoke: public app and platform HTTPS endpoints"
else
log_warn "smoke: public HTTPS endpoints are not reachable yet (app $app_code, platform $platform_code)"
fi
fi
}
_state_file() { printf '%s/applied_release' "$(state_dir)"; }
_record_release() {
local ver
ver="$(env_get "$(release_env_file)" CHATBALLS_VERSION)"
printf 'applied_version=%s\napplied_at=%s\n' "${ver:-unknown}" \
"$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)" > "$(_state_file)"
}
_applied_version_target() {
env_get "$(release_env_file)" CHATBALLS_VERSION || printf 'unknown'
}