Files
kvarta_project/deploy/deploy.sh
T
nunuchek 88b6e1395e refactor: simplify — мёртвые поля контента, общие хелперы api и тестов, липкая CTA без site в бандле
landing:
- site.ts: убраны неиспользуемые roast, brew, priceDisplay, hero.eyebrow, footer.phone (Footer берёт CONTACT_PHONE)
- form.ts: убраны FORM_TEXT.title/lead, CTAS, CtaPreset
- StickyCta получает тексты пропсами от layout: клиентский чанк больше не тащит все тексты страницы
- LeadForm/useLeadForm: без неиспользуемой подмены sendLead и className; leadPrefill без счётчика seq
- Stamp без мёртвого tone; убраны data-interest, data-hot, класс lead-form (ничем не читались)
- одно LEAD_FIELDS на sendLead и leadFormModel; регэксп typo вынесен из вызова; дубль фона body

api:
- changedFields и Version общие для leads и outbox; countLeads и leadStamps без копий SQL
- errorMessage в system.ts вместо четырёх копий; take() для ожидающих подтверждений
- backup: один Intl.DateTimeFormat на модуль, без невызываемых параметров timeZone/hour
- без мёртвых LOOPBACK '::ffff:127.0.0.1', client.call, Pick<BackupCommands> со всеми ключами
- testing.ts: validLead, addLead, count, leadIds вместо копий в тестах

deploy.sh: deployed_field и цикл по REPOS вместо повторов имён
2026-10-17 17:31:11 +03:00

233 lines
9.5 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# Деплой «Кварты» на VPS. Запускать локально из корня репозитория:
# deploy/deploy.sh — собрать HEAD и выкатить
# deploy/deploy.sh --rollback — вернуть предыдущий тег из /root/kvarta_site/.deployed
# Хост: DEPLOY_HOST (по умолчанию root@185.5.249.52).
#
# На сервере рядом чужие боевые сайты: скрипт трогает только /root/kvarta_site,
# контейнеры проекта kvarta и образы kvarta-landing/kvarta-api. Никаких prune.
set -euo pipefail
DEPLOY_HOST="${DEPLOY_HOST:-root@185.5.249.52}"
REMOTE_DIR=/root/kvarta_site
COMPOSE_FILE=docker-compose_vps.yml
PROJECT=kvarta
REPOS=(kvarta-landing kvarta-api)
MIN_FREE_MB=1024
HEALTH_TIMEOUT=30
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=10
-o ControlMaster=auto -o "ControlPath=$HOME/.ssh/kvarta-deploy-%C" -o ControlPersist=60)
STEP=0
step() { STEP=$((STEP + 1)); printf '\n\033[1m[%d] %s\033[0m\n' "$STEP" "$*"; }
info() { printf ' %s\n' "$*"; }
warn() { printf '\033[33m ВНИМАНИЕ: %s\033[0m\n' "$*" >&2; }
die() { printf '\033[31mОШИБКА: %s\033[0m\n' "$*" >&2; exit 1; }
# shellcheck disable=SC2029 # команды для сервера собираются локально намеренно
remote() { ssh "${SSH_OPTS[@]}" "$DEPLOY_HOST" "$@"; }
usage() {
sed -n '2,5p' "$0" | sed 's/^# \{0,1\}//'
}
cd "$(dirname "$0")/.."
# --- общие шаги на сервере ---------------------------------------------------
check_server() {
step "Проверка сервера $DEPLOY_HOST"
remote true || die "нет ssh-доступа к $DEPLOY_HOST (нужен ключ, BatchMode)"
remote 'command -v docker-compose >/dev/null' \
|| die "на сервере нет docker-compose (ожидается v2 в /usr/local/bin)"
info "$(remote 'docker-compose version --short 2>/dev/null || docker-compose version' | head -1)"
}
# compose up с указанным тегом, затем ждём health обоих сервисов
compose_up() {
local tag=$1
step "Запуск контейнеров с тегом $tag"
remote "cd $REMOTE_DIR && KVARTA_TAG=$tag docker-compose -f $COMPOSE_FILE -p $PROJECT up -d --remove-orphans"
step "Ожидание здоровья (до ${HEALTH_TIMEOUT} с)"
if remote bash -s -- "$HEALTH_TIMEOUT" <<'REMOTE'
set -u
for _ in $(seq 1 "$1"); do
if curl -fs -o /dev/null http://127.0.0.1:13121/api/health \
&& curl -fs -o /dev/null http://127.0.0.1:13120/; then
curl -fsS http://127.0.0.1:13121/api/health; echo
exit 0
fi
sleep 1
done
exit 1
REMOTE
then
info "api и landing отвечают"
else
warn "сервисы не ответили за ${HEALTH_TIMEOUT} с; последние логи:"
remote "cd $REMOTE_DIR && docker-compose -f $COMPOSE_FILE -p $PROJECT logs --tail 40" || true
die "деплой $tag не поднялся. Откат: deploy/deploy.sh --rollback"
fi
}
# latest на сервере — всегда то, что сейчас запущено (для ручного docker-compose up)
retag_latest() {
local tag=$1 repo
for repo in "${REPOS[@]}"; do
remote "docker tag $repo:$tag $repo:latest"
done
}
# .deployed: current=<tag> / previous=<tag>
read_deployed() {
remote "cat $REMOTE_DIR/.deployed 2>/dev/null || true"
}
# значение поля из вывода read_deployed: deployed_field current "$state"
deployed_field() { sed -n "s/^$1=//p" <<<"$2"; }
write_deployed() {
local current=$1 previous=$2
remote "printf 'current=%s\nprevious=%s\n' '$current' '$previous' > $REMOTE_DIR/.deployed"
info "current=$current previous=${previous:-—}"
}
# --- откат -------------------------------------------------------------------
rollback() {
check_server
step "Чтение $REMOTE_DIR/.deployed"
local state current previous repo
state=$(read_deployed)
current=$(deployed_field current "$state")
previous=$(deployed_field previous "$state")
[[ -n $previous ]] || die "в .deployed нет предыдущего тега — откатываться некуда"
info "сейчас $current, откатываемся на $previous"
for repo in "${REPOS[@]}"; do
remote "docker image inspect $repo:$previous >/dev/null 2>&1" \
|| die "на сервере нет образа $repo:$previous"
done
compose_up "$previous"
retag_latest "$previous"
step "Запись .deployed"
# меняем местами: повторный --rollback вернёт обратно
write_deployed "$previous" "$current"
printf '\nГотово: откат на %s.\n' "$previous"
}
# --- деплой ------------------------------------------------------------------
deploy() {
step "Проверка рабочего дерева"
if [[ -n $(git status --porcelain) ]]; then
git status --short
die "есть незакоммиченные или неотслеживаемые файлы — закоммитьте или уберите их"
fi
local sha
sha=$(git rev-parse --short HEAD)
info "коммит $sha: $(git log -1 --format=%s)"
check_server
step "Свободное место на сервере"
local free_mb
# меряем раздел, где docker хранит образы (туда ляжет docker load)
free_mb=$(remote bash -s <<'REMOTE'
dir=$(docker info -f '{{.DockerRootDir}}' 2>/dev/null)
[ -d "$dir" ] || dir=/
echo "$dir $(df -Pm "$dir" | awk 'NR==2 {print $4}')"
REMOTE
)
local docker_root=${free_mb% *}
free_mb=${free_mb##* }
[[ $free_mb =~ ^[0-9]+$ ]] || die "не удалось узнать свободное место на сервере (получено: '$free_mb')"
info "свободно ${free_mb} МБ на разделе с $docker_root"
if ((free_mb < MIN_FREE_MB)); then
die "на сервере меньше ${MIN_FREE_MB} МБ свободно (${free_mb} МБ). Освободите место (своё, не чужое) и повторите"
fi
local need_upload=0 repo
for repo in "${REPOS[@]}"; do
remote "docker image inspect $repo:$sha >/dev/null 2>&1" || need_upload=1
done
if ((need_upload)); then
step "Сборка образов linux/amd64 ($sha)"
local hint="если в логе 'exec format error' — на этой машине нет эмуляции amd64:"
hint+=" Docker Desktop → Settings → General → «Use Rosetta for x86_64/amd64 emulation»"
hint+=" или docker run --privileged --rm tonistiigi/binfmt --install amd64"
for repo in "${REPOS[@]}"; do
info "$repo"
docker buildx build --platform linux/amd64 --load \
--provenance=false --sbom=false \
-f "deploy/Dockerfile.${repo#kvarta-}" \
--build-arg "APP_VERSION=$sha" \
-t "$repo:$sha" -t "$repo:latest" \
. || die "сборка $repo упала; $hint"
done
docker image ls --format ' {{.Repository}}:{{.Tag}} {{.Size}}' | grep -E "^ kvarta-(landing|api):$sha " || true
step "Передача образов на сервер (docker save | gzip | ssh docker load)"
docker save "${REPOS[@]/%/:$sha}" | gzip | remote 'gunzip | docker load'
else
step "Образы $sha уже есть на сервере — сборка и передача пропущены"
fi
step "Файлы в $REMOTE_DIR"
remote "mkdir -p $REMOTE_DIR"
scp -q "${SSH_OPTS[@]}" "deploy/$COMPOSE_FILE" "$DEPLOY_HOST:$REMOTE_DIR/$COMPOSE_FILE"
info "$COMPOSE_FILE обновлён"
local env_state
env_state=$(remote "cd $REMOTE_DIR && if [ -e api.env ]; then echo exists; else umask 077 && cat > api.env && chmod 600 api.env && echo created; fi" <deploy/api.env.example)
if [[ $env_state == created ]]; then
warn "api.env не было — положен пустой шаблон $REMOTE_DIR/api.env (chmod 600)."
warn "Заполните его на сервере и перезапустите api: cd $REMOTE_DIR && docker-compose -f $COMPOSE_FILE -p $PROJECT up -d api"
else
info "api.env уже есть — не трогаем"
fi
local state previous
state=$(read_deployed)
previous=$(deployed_field current "$state")
# повторный деплой того же коммита не должен затирать предыдущий тег
if [[ $previous == "$sha" ]]; then
previous=$(deployed_field previous "$state")
fi
compose_up "$sha"
retag_latest "$sha"
step "Запись .deployed"
write_deployed "$sha" "$previous"
step "Удаление старых образов kvarta-landing/kvarta-api (кроме $sha${previous:+ и $previous})"
# ssh склеивает аргументы в строку — пустой previous пропал бы, поэтому заглушка "-"
remote bash -s -- "$sha" "${previous:--}" "${REPOS[@]}" <<'REMOTE'
set -u
keep_current=$1 keep_previous=$2
shift 2
for repo in "$@"; do
docker image ls "$repo" --format '{{.Tag}}' | while read -r tag; do
case $tag in
latest|"$keep_current"|"$keep_previous"|"<none>") continue ;;
esac
if docker rmi "$repo:$tag" >/dev/null; then
echo " удалён $repo:$tag"
else
echo " не удалось удалить $repo:$tag (используется?) — пропускаю"
fi
done
done
REMOTE
printf '\nГотово: %s выкачен на %s.\n' "$sha" "$DEPLOY_HOST"
}
case "${1:-}" in
"") deploy ;;
--rollback) rollback ;;
-h | --help) usage ;;
*) usage; die "неизвестный аргумент: $1" ;;
esac