From 79ab146b1dcf392888a9e196e7ae124a3a85c4ce Mon Sep 17 00:00:00 2001 From: Nikolay Unuchek Date: Sat, 17 Oct 2026 15:45:11 +0300 Subject: [PATCH] =?UTF-8?q?chore(deploy):=20Docker-=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D1=8B,=20compose,=20deploy.sh,=20setup-nginx.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 38 +++++ deploy/Dockerfile.api | 69 ++++++++ deploy/Dockerfile.landing | 45 ++++++ deploy/api.env.example | 14 ++ deploy/deploy.sh | 230 +++++++++++++++++++++++++++ deploy/docker-compose_vps.yml | 37 +++++ deploy/nginx/kvarta.unuchak.com.conf | 42 +++++ deploy/setup-nginx.sh | 90 +++++++++++ 8 files changed, 565 insertions(+) create mode 100644 .dockerignore create mode 100644 deploy/Dockerfile.api create mode 100644 deploy/Dockerfile.landing create mode 100644 deploy/api.env.example create mode 100755 deploy/deploy.sh create mode 100644 deploy/docker-compose_vps.yml create mode 100644 deploy/nginx/kvarta.unuchak.com.conf create mode 100755 deploy/setup-nginx.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..62131d8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,38 @@ +# зависимости и артефакты сборки — пересобираются внутри образа +**/node_modules +**/.next +**/dist +**/out +**/coverage +**/*.tsbuildinfo +**/next-env.d.ts +.pnpm-store + +# vcs, служебное агентов и редакторов +.git +.claude +.worktrees +.idea +.vscode +**/.DS_Store + +# секреты и данные +**/.env +**/.env.* +**/*.env +data +**/data +**/*.sqlite +**/*.sqlite-* +**/*.sqlite.gz + +# артефакты проверок +.screenshots +.lighthouse +test-results +playwright-report + +# не нужно для сборки образов +docs +deploy +**/*.md diff --git a/deploy/Dockerfile.api b/deploy/Dockerfile.api new file mode 100644 index 0000000..20ec1b1 --- /dev/null +++ b/deploy/Dockerfile.api @@ -0,0 +1,69 @@ +# syntax=docker/dockerfile:1 +# Сборка из корня репо: +# docker buildx build --platform linux/amd64 -f deploy/Dockerfile.api \ +# --build-arg APP_VERSION=$(git rev-parse --short HEAD) -t kvarta-api . + +# builder на том же node:22-slim (glibc), что и рантайм: нативный better-sqlite3, +# скачанный/собранный здесь под целевую платформу, гарантированно совместим +FROM node:22-slim AS build +# toolchain нужен только если prebuilt better-sqlite3 не скачается; в рантайм не попадает +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ ca-certificates \ + && rm -rf /var/lib/apt/lists/* +# corepack из node 22 может не знать ключи свежего pnpm — обновляем его +RUN npm install -g corepack@latest >/dev/null && corepack enable +WORKDIR /repo +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ +COPY apps/api/package.json apps/api/ +COPY packages/shared/package.json packages/shared/ +RUN corepack install +RUN --mount=type=cache,id=kvarta-pnpm-slim,target=/pnpm-store \ + pnpm install --frozen-lockfile --store-dir /pnpm-store \ + --filter kvarta --filter "@kvarta/api..." +COPY apps/api apps/api +COPY packages/shared packages/shared +RUN pnpm --filter @kvarta/api build +# отдельный каталог только с production-зависимостями api (shared уже вшит tsup'ом) +RUN --mount=type=cache,id=kvarta-pnpm-slim,target=/pnpm-store \ + pnpm --filter @kvarta/api deploy --prod --legacy --store-dir /pnpm-store /out \ + && rm -rf /out/src /out/tsconfig.json /out/tsup.config.ts /out/vitest.config.ts +# better-sqlite3 13 везёт prebuilt-бинарники всех платформ (~17 МБ) и исходники SQLite (~10 МБ): +# оставляем только glibc-бинарник своей архитектуры (linux-x64 для linux/amd64) +RUN set -eu; \ + bs=$(readlink -f /out/node_modules/better-sqlite3); \ + keep="linux-$(node -p process.arch).node"; \ + test -f "$bs/prebuilds/$keep"; \ + find "$bs/prebuilds" -type f ! -name "$keep" -delete; \ + rm -rf "$bs/deps" "$bs/src" "$bs/build" "$bs/binding.gyp" \ + /out/node_modules/.pnpm/node-addon-api@*; \ + find /out/node_modules -name '*.md' -type f -delete; \ + cd /out && node -e "const D=require('better-sqlite3'); const db=new D(':memory:'); console.log('better-sqlite3 ok, sqlite', db.prepare('select sqlite_version() v').get().v); db.close()" + +# рантайму не нужны npm/corepack/yarn и документация (~25 МБ); удалить их слоем поверх базы +# нельзя — размер не уменьшится, поэтому чистим и сплющиваем базу в один слой через scratch +FROM node:22-slim AS node-base +RUN rm -rf /usr/local/lib/node_modules /usr/local/bin/npm /usr/local/bin/npx \ + /usr/local/bin/corepack /usr/local/bin/yarn /usr/local/bin/yarnpkg /opt/yarn-* \ + /usr/local/include /usr/local/share/doc /usr/local/share/man \ + /usr/share/doc /usr/share/man /usr/share/info /usr/share/locale \ + /var/lib/apt/lists/* /var/cache/* /var/log/* /root/.npm /tmp/* \ + && groupadd -r -g 1001 kvarta \ + && useradd -r -u 1001 -g kvarta -d /app -s /usr/sbin/nologin kvarta \ + && mkdir -p /app /data && chown kvarta:kvarta /data + +FROM scratch AS runtime +COPY --from=node-base / / +ARG APP_VERSION=dev +ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + NODE_ENV=production \ + PORT=3000 \ + DB_PATH=/data/kvarta.sqlite \ + APP_VERSION=${APP_VERSION} +WORKDIR /app +COPY --from=build --chown=kvarta:kvarta /out ./ +USER kvarta +VOLUME ["/data"] +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"] +CMD ["node", "dist/index.js"] diff --git a/deploy/Dockerfile.landing b/deploy/Dockerfile.landing new file mode 100644 index 0000000..461ebf9 --- /dev/null +++ b/deploy/Dockerfile.landing @@ -0,0 +1,45 @@ +# syntax=docker/dockerfile:1 +# Сборка из корня репо: +# docker buildx build --platform linux/amd64 -f deploy/Dockerfile.landing -t kvarta-landing . + +FROM node:22-alpine AS build +# corepack из node 22 может не знать ключи свежего pnpm — обновляем его +RUN npm install -g corepack@latest >/dev/null && corepack enable +ENV NEXT_TELEMETRY_DISABLED=1 +WORKDIR /repo +# сначала только манифесты — слой с зависимостями кешируется +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ +COPY apps/landing/package.json apps/landing/ +COPY packages/shared/package.json packages/shared/ +RUN corepack install +RUN --mount=type=cache,id=kvarta-pnpm-alpine,target=/pnpm-store \ + pnpm install --frozen-lockfile --store-dir /pnpm-store \ + --filter kvarta --filter "@kvarta/landing..." +COPY apps/landing apps/landing +COPY packages/shared packages/shared +RUN pnpm --filter @kvarta/landing build \ + && mkdir -p apps/landing/public + +# рантайму не нужны npm/corepack/yarn (~23 МБ); удалить их слоем поверх базы нельзя — +# размер не уменьшится, поэтому чистим и сплющиваем базу в один слой через scratch +FROM node:22-alpine AS node-base +RUN rm -rf /usr/local/lib/node_modules /usr/local/bin/npm /usr/local/bin/npx \ + /usr/local/bin/corepack /usr/local/bin/yarn /usr/local/bin/yarnpkg /opt/yarn-* \ + /usr/local/include /root/.npm /tmp/* \ + && addgroup -S -g 1001 kvarta && adduser -S -u 1001 -G kvarta kvarta + +FROM scratch AS runtime +COPY --from=node-base / / +ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 +WORKDIR /app +# outputFileTracingRoot = корень репо, поэтому server.js лежит в apps/landing/ внутри standalone +COPY --from=build --chown=kvarta:kvarta /repo/apps/landing/.next/standalone ./ +COPY --from=build --chown=kvarta:kvarta /repo/apps/landing/.next/static ./apps/landing/.next/static +COPY --from=build --chown=kvarta:kvarta /repo/apps/landing/public ./apps/landing/public +USER kvarta +EXPOSE 3000 +CMD ["node", "apps/landing/server.js"] diff --git a/deploy/api.env.example b/deploy/api.env.example new file mode 100644 index 0000000..899b93a --- /dev/null +++ b/deploy/api.env.example @@ -0,0 +1,14 @@ +# Секреты api. На сервере: /root/kvarta_site/api.env, chmod 600, заполняет автор. +# deploy.sh кладёт этот шаблон, только если api.env на сервере ещё нет, и дальше его не трогает. +# PORT, DB_PATH и APP_VERSION задаёт образ — здесь их не указывать. + +# Токен бота от @BotFather +TELEGRAM_BOT_TOKEN= +# ID группы с темами, куда бот пишет (отрицательное число вида -100…) +TELEGRAM_CHAT_ID= +# ID темы «Заявки» (message_thread_id) — сюда приходят новые заявки +TELEGRAM_LEADS_THREAD_ID= +# ID темы «Система» — ошибки, бэкапы, служебные сообщения +TELEGRAM_SYSTEM_THREAD_ID= +# Telegram user ID автора: только ему бот отвечает на команды администрирования +TELEGRAM_ADMIN_ID= diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..e3a189e --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,230 @@ +#!/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= / previous= +read_deployed() { + remote "cat $REMOTE_DIR/.deployed 2>/dev/null || true" +} + +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=$(sed -n 's/^current=//p' <<<"$state") + previous=$(sed -n 's/^previous=//p' <<<"$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" + local name + for name in landing api; do + info "kvarta-$name" + docker buildx build --platform linux/amd64 --load \ + --provenance=false --sbom=false \ + -f "deploy/Dockerfile.$name" \ + --build-arg "APP_VERSION=$sha" \ + -t "kvarta-$name:$sha" -t "kvarta-$name:latest" \ + . || die "сборка kvarta-$name упала; $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 "kvarta-landing:$sha" "kvarta-api:$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" ") 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 diff --git a/deploy/docker-compose_vps.yml b/deploy/docker-compose_vps.yml new file mode 100644 index 0000000..38be964 --- /dev/null +++ b/deploy/docker-compose_vps.yml @@ -0,0 +1,37 @@ +# Прод на VPS: /root/kvarta_site/docker-compose_vps.yml (кладёт deploy/deploy.sh). +# На сервере только docker-compose v2.23 (без плагина `docker compose`): +# KVARTA_TAG= docker-compose -f docker-compose_vps.yml -p kvarta up -d +# Наружу порты не торчат — только 127.0.0.1, снаружи ходит nginx хоста. +name: kvarta + +x-logging: &logging + # диск сервера почти полный — логи ротируются: не больше 3 × 10 МБ на контейнер + driver: json-file + options: + max-size: "10m" + max-file: "3" + +services: + landing: + image: kvarta-landing:${KVARTA_TAG:-latest} + container_name: kvarta-landing + restart: unless-stopped + ports: + - "127.0.0.1:13120:3000" + logging: *logging + + api: + image: kvarta-api:${KVARTA_TAG:-latest} + container_name: kvarta-api + restart: unless-stopped + ports: + - "127.0.0.1:13121:3000" + env_file: api.env + volumes: + - kvarta-data:/data + logging: *logging + +volumes: + kvarta-data: + # явное имя: не зависит от project name, его ищут бэкапы и восстановление + name: kvarta-data diff --git a/deploy/nginx/kvarta.unuchak.com.conf b/deploy/nginx/kvarta.unuchak.com.conf new file mode 100644 index 0000000..5cf8649 --- /dev/null +++ b/deploy/nginx/kvarta.unuchak.com.conf @@ -0,0 +1,42 @@ +# /etc/nginx/sites-available/kvarta.unuchak.com (ставит deploy/setup-nginx.sh) +# Только 80-й порт: certbot --nginx сам допишет 443-блок и редирект. +server { + listen 80; + listen [::]:80; + server_name kvarta.unuchak.com; + + # заявка — это небольшой JSON; всё крупнее отсекаем ещё на nginx + client_max_body_size 32k; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 5; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml application/javascript application/json + application/xml application/rss+xml image/svg+xml application/manifest+json; + + location /api/ { + proxy_pass http://127.0.0.1:13121; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location / { + proxy_pass http://127.0.0.1:13120; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } +} diff --git a/deploy/setup-nginx.sh b/deploy/setup-nginx.sh new file mode 100755 index 0000000..7be70f1 --- /dev/null +++ b/deploy/setup-nginx.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Однократная настройка nginx и HTTPS для kvarta.unuchak.com. Запускать локально из корня: +# CERTBOT_EMAIL=you@example.com deploy/setup-nginx.sh +# Хост: DEPLOY_HOST (по умолчанию root@185.5.249.52). Повторный запуск безопасен. +# +# На сервере чужие боевые сайты: при ошибке `nginx -t` наш симлинк убирается +# и reload не делается — общий nginx не должен упасть из-за нашего конфига. +set -euo pipefail + +DEPLOY_HOST="${DEPLOY_HOST:-root@185.5.249.52}" +DOMAIN=kvarta.unuchak.com +CONF_SRC="deploy/nginx/$DOMAIN.conf" +AVAILABLE="/etc/nginx/sites-available/$DOMAIN" +ENABLED="/etc/nginx/sites-enabled/$DOMAIN" + +SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=10 + -o ControlMaster=auto -o "ControlPath=$HOME/.ssh/kvarta-nginx-%C" -o ControlPersist=60) + +STEP=0 +step() { STEP=$((STEP + 1)); printf '\n\033[1m[%d] %s\033[0m\n' "$STEP" "$*"; } +info() { printf ' %s\n' "$*"; } +die() { printf '\033[31mОШИБКА: %s\033[0m\n' "$*" >&2; exit 1; } +# shellcheck disable=SC2029 # команды для сервера собираются локально намеренно +remote() { ssh "${SSH_OPTS[@]}" "$DEPLOY_HOST" "$@"; } + +cd "$(dirname "$0")/.." + +step "Проверка параметров" +if [[ -z ${CERTBOT_EMAIL:-} ]]; then + die "задайте CERTBOT_EMAIL — на него Let's Encrypt шлёт предупреждения об истечении сертификата: + CERTBOT_EMAIL=you@example.com deploy/setup-nginx.sh" +fi +[[ $CERTBOT_EMAIL =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] \ + || die "CERTBOT_EMAIL не похож на email: $CERTBOT_EMAIL" +[[ -f $CONF_SRC ]] || die "нет $CONF_SRC" +info "хост $DEPLOY_HOST, домен $DOMAIN, email $CERTBOT_EMAIL" + +step "Проверка сервера" +remote true || die "нет ssh-доступа к $DEPLOY_HOST" +remote 'command -v nginx >/dev/null && command -v certbot >/dev/null' \ + || die "на сервере нет nginx или certbot" +# общий nginx должен быть исправен до нас — иначе мы не отличим свою ошибку от чужой +remote 'nginx -t 2>&1' >/dev/null || die "nginx -t падает ещё до наших изменений — разберитесь вручную, скрипт ничего не трогал" + +step "Конфиг в $AVAILABLE" +# если certbot уже дописал в конфиг 443-блок — не затираем его исходным 80-м +if remote "grep -q 'managed by Certbot' $AVAILABLE 2>/dev/null"; then + info "конфиг уже обработан certbot — оставляю как есть" +else + # прежняя версия нашего конфига — чтобы вернуть её, если новая не пройдёт nginx -t + remote "[ ! -e $AVAILABLE ] || cp -a $AVAILABLE $AVAILABLE.bak" + scp -q "${SSH_OPTS[@]}" "$CONF_SRC" "$DEPLOY_HOST:$AVAILABLE" + info "скопирован" +fi + +step "Симлинк в sites-enabled" +created_link=0 +if remote "[ -e $ENABLED ] || [ -L $ENABLED ]"; then + info "уже есть — не трогаю" +else + remote "ln -s $AVAILABLE $ENABLED" + created_link=1 + info "создан $ENABLED" +fi + +step "nginx -t" +if ! remote 'nginx -t'; then + if ((created_link)); then + remote "rm -f $ENABLED" + info "наш симлинк удалён" + fi + if remote "[ -e $AVAILABLE.bak ]"; then + remote "mv -f $AVAILABLE.bak $AVAILABLE" + info "восстановлена прежняя версия $AVAILABLE" + fi + die "конфиг не прошёл nginx -t — reload не выполнен" +fi + +remote "rm -f $AVAILABLE.bak" + +step "systemctl reload nginx" +remote 'systemctl reload nginx' + +step "Сертификат Let's Encrypt (certbot --nginx)" +remote "certbot --nginx -d $DOMAIN --non-interactive --agree-tos --redirect -m '$CERTBOT_EMAIL'" + +step "Итоговая проверка" +remote 'nginx -t' && remote "curl -fsS -o /dev/null -w 'https://$DOMAIN/ → %{http_code}\n' https://$DOMAIN/ || true" + +printf '\nГотово: https://%s\n' "$DOMAIN"