Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e8eaa08
feat(api): проверка здоровья и плавная остановка
granstel Aug 7, 2026
f988957
fix(docker): восстановление сборки образа и стартующая конфигурация п…
granstel Aug 7, 2026
8e0d44e
ci: публикация образа и бесшовная раскатка на сервер
granstel Aug 7, 2026
9fbc4d9
feat(deploy): шаблоны и скрипт бесшовной раскатки, трекинг deploy/ бе…
granstel Aug 30, 2026
84eb751
Merge remote-tracking branch 'origin/master' into upgrade/14-infra
granstel Aug 30, 2026
287187d
ci: выпуск образа по релизу, а не по пушу в master
Aug 30, 2026
6b9a4c4
ci(deps): обновление версий GitHub Actions
Aug 30, 2026
732811a
chore(api): уровни логирования Information вместо Debug
Aug 30, 2026
6655183
chore(api): версия сборки 1.25.0
Aug 30, 2026
2b5c929
fix(deploy): устранение простоя при раскатке
Aug 30, 2026
8af5d9a
feat(deploy): синхронизация deploy/ с тегом версии при раскатке
Aug 30, 2026
8e0043d
chore(api): версия сборки 1.26.0
Aug 30, 2026
268c29d
feat(deploy): docker-сеть создаётся автоматически
Aug 30, 2026
d47262e
chore(deploy): redis в репозитории, короткие комментарии, без путей с…
Aug 30, 2026
8be0598
feat(deploy): доступ к redis через SSH-туннель
Aug 30, 2026
55e8c33
chore(deploy): убрано обоснование из комментария к обёртке
Aug 30, 2026
73289d9
docs(deploy): README только про то, чего нет в файлах
Aug 30, 2026
8e140d4
docs(deploy): подготовка сервера вынесена из репозитория сервиса
Aug 30, 2026
c1a97f4
docs(deploy): убрано упоминание внешней документации
Aug 30, 2026
c13edc4
docs(deploy): вместо рассуждения про голый IP — только требование к A…
Aug 30, 2026
0d4bdb1
docs(deploy): убран README, уникальное перенесено к коду
Aug 30, 2026
1e4ccd4
refactor(deploy): сеть создаёт стек прокси, проверка прокси — до раск…
Aug 30, 2026
b273a9c
refactor(deploy): сервис поднимается через compose, blue/green
Aug 30, 2026
8daa72f
chore(deploy): комментарии сокращены до одной-двух строк
Aug 30, 2026
c454985
chore(docker): короткий комментарий про curl
Aug 30, 2026
d163237
refactor: HEALTHCHECK и curl убраны из образа
Aug 30, 2026
1d1837d
поправки
granstel Sep 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.sh text eol=lf
/deploy/** text eol=lf
4 changes: 2 additions & 2 deletions .github/workflows/build&test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.x
- name: Build and test
Expand Down
51 changes: 45 additions & 6 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -1,29 +1,68 @@
name: Docker publish

on:
# Выпуск версии — публикация релиза в GitHub. Имя тега релиза становится
# тегом образа и версией, которую раскатывает rollout.sh на сервере.
release:
types: [published]
types: [ published ]

concurrency:
# Раскатки не должны идти параллельно: две одновременные сломают порядок замены экземпляров
group: deploy-production
cancel-in-progress: false

jobs:
build-and-push:
publish:
runs-on: ubuntu-latest

outputs:
version: ${{ steps.version.outputs.value }}

steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7

- name: Read version
id: version
run: |
version='${{ github.event.release.tag_name }}'
if [ -z "$version" ]; then
echo "У релиза пустой тег — нечего собирать" >&2
exit 1
fi
echo "value=${version}" >> "$GITHUB_OUTPUT"
echo "Version: ${version}"

- name: Log in to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ./src
file: ./src/FillInTheTextBot.Api/Dockerfile
push: true
tags: |
granstel/fillinthetextbot:${{ github.event.release.tag_name }}
granstel/fillinthetextbot:${{ steps.version.outputs.value }}
granstel/fillinthetextbot:${{ github.sha }}
granstel/fillinthetextbot:latest

deploy:
needs: publish
runs-on: ubuntu-latest
# Шаг пропускается, пока не заданы секреты доступа к серверу
if: ${{ vars.DEPLOY_ENABLED == 'true' }}

steps:
- name: Rollout on server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd ${{ secrets.DEPLOY_PATH }}
./rollout.sh ${{ needs.publish.outputs.version }}
2 changes: 1 addition & 1 deletion .github/workflows/loadtest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Run k6 load test
env:
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
**/.hg*
**/.hg
**/Keys/
**/deploy/
/deploy/.env
/deploy/app.env
/deploy/acme/
**/.config/
**/Properties/
/src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj.user
Expand Down
39 changes: 39 additions & 0 deletions deploy/app-env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Секреты и настройки приложения, передаётся контейнеру как --env-file.
#
# cp app-env.example app.env
#
# В репозиторий app.env не коммитится. Двойное подчёркивание = ':' в конфигурации:
# AppConfiguration__Redis__ConnectionString == AppConfiguration:Redis:ConnectionString

# Dialogflow: агенты 0..N. JsonPath — путь внутри контейнера, /app/keys смонтирован
# из KEYS_DIR. ScopeId должен быть уникальным и непустым, иначе агент не участвует
# в выборе; он же уезжает клиенту в состоянии сессии, поэтому менять его нельзя —
# у пользователей с активной сессией сохранённое значение перестанет совпадать.
AppConfiguration__Dialogflow__0__ProjectId=your-project-0
AppConfiguration__Dialogflow__0__ScopeId=scope-0
AppConfiguration__Dialogflow__0__JsonPath=keys/your-project-0.json
AppConfiguration__Dialogflow__0__Region=europe-west1

AppConfiguration__Dialogflow__1__ProjectId=your-project-1
AppConfiguration__Dialogflow__1__ScopeId=scope-1
AppConfiguration__Dialogflow__1__JsonPath=keys/your-project-1.json
AppConfiguration__Dialogflow__1__Region=europe-west1

# ...остальные агенты по тому же образцу

# Redis доступен по имени внутри docker-сети; пароль тот же, что REDIS_PASSWORD в .env
AppConfiguration__Redis__ConnectionString=redis:6379,password=CHANGE-ME,abortConnect=false,syncTimeout=2000,asyncTimeout=2000
AppConfiguration__Redis__KeyPrefix=

# Пустой Host выключает экспорт трейсов
AppConfiguration__Tracing__Host=
AppConfiguration__Tracing__Port=4317

# Плавная остановка; значения по умолчанию подходят
# AppConfiguration__Shutdown__DrainDelaySeconds=10
# AppConfiguration__Shutdown__TimeoutSeconds=30

# Токены проверки входящих запросов от площадок
FITB-YANDEX-INCOMINGTOKEN=CHANGE-ME
FITB-SBER-INCOMINGTOKEN=CHANGE-ME
FITB-MARUSIA-INCOMINGTOKEN=CHANGE-ME
54 changes: 54 additions & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Пограничный прокси: TLS, сертификат Let's Encrypt, маршрутизация по docker-меткам.
# Сервис поднимает rollout.sh.
#
# docker compose -f docker-compose.yml up -d

name: fitb-edge

networks:
# Создаётся этим стеком; redis и сервис подключаются к ней как к внешней
network:
name: network

services:
traefik:
image: traefik:v3.7
container_name: traefik
restart: always
networks:
- network
ports:
- "80:80"
- "443:443"
command:
- --providers.docker=true
# Без явной метки traefik.enable=true наружу ничего не выходит
- --providers.docker.exposedbydefault=false
- --providers.docker.network=network

- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
# Наружу не публикуется; API опрашивает rollout.sh изнутри сети
- --entrypoints.traefik.address=:8080
- --api=true
- --api.insecure=true

# ACME-проверку Traefik обслуживает до редиректа, выпуск не ломается
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https

- --certificatesresolvers.le.acme.email=${ACME_EMAIL}
- --certificatesresolvers.le.acme.storage=/acme/acme.json
- --certificatesresolvers.le.acme.httpchallenge=true
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web

# Иначе клиент может подменить X-Forwarded-* заголовком-алиасом
- --entrypoints.web.http.aliasHeadersStrategy=delete
- --entrypoints.websecure.http.aliasHeadersStrategy=delete

- --log.level=INFO
- --accesslog=true
volumes:
# Только чтение: Traefik лишь наблюдает за контейнерами
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./acme:/acme
28 changes: 28 additions & 0 deletions deploy/env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Параметры раскатки и пограничного прокси.
#
# cp env.example .env
#
# В репозиторий .env не коммитится.

# Домен сервиса; на него должна быть A-запись на IP сервера
DOMAIN=example.com

# Почта для регистрации в Let's Encrypt
ACME_EMAIL=admin@example.com

# Каталоги на сервере, монтируемые в контейнер
KEYS_DIR=/путь/к/ключам/dialogflow
LOGS_DIR=/путь/к/логам/сервиса

# Пароль Redis; тот же попадает в строку подключения в app.env
REDIS_PASSWORD=CHANGE-ME

# --- ниже значения по умолчанию, менять не обязательно ---

# IMAGE_REPO=granstel/fillinthetextbot
# DOCKER_NETWORK=network
# SERVICE_ALIAS=fitb
# APP_ENV_FILE=./app.env
# HEALTH_TIMEOUT=60 # ждём готовности нового экземпляра, сек
# ROTATION_TIMEOUT=30 # ждём, пока Traefik возьмёт его в ротацию, сек
# STOP_TIMEOUT=45 # запас на слив трафика при остановке старого, сек
66 changes: 66 additions & 0 deletions deploy/fitb-deploy
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
#
# Форсированная команда для ключа раскатки из CI: достаёт из запроса номер версии,
# подтягивает deploy/ с тега этой версии и запускает раскатку. Больше ничего не умеет.
#
# Тег, а не ветка: откат вернёт и старые скрипты раскатки.
# Ставится на сервер один раз под root; себя не обновляет.

set -euo pipefail

# shellcheck disable=SC1091
[ -r /etc/default/fitb-deploy ] && . /etc/default/fitb-deploy

WORK_DIR="${FITB_WORK_DIR:?задайте FITB_WORK_DIR в /etc/default/fitb-deploy}"
REPO_DIR="${WORK_DIR}/.repo"

# Остальное в каталоге раскатки — локальная конфигурация с секретами.
SYNCED=(rollout.sh docker-compose.yml redis-compose.yml service-compose.yml env.example app-env.example)

requested="${SSH_ORIGINAL_COMMAND:-}"

version="$(printf '%s' "$requested" \
| grep -oE '(^|[^0-9A-Za-z.-])[0-9]+\.[0-9]+\.[0-9]+([-+.][A-Za-z0-9.-]+)?([^0-9A-Za-z.-]|$)' \
| grep -oE '[0-9]+\.[0-9]+\.[0-9]+([-+.][A-Za-z0-9.-]+)?' \
| head -1 || true)"

if [ -z "$version" ]; then
echo "!! Этому ключу доступна только раскатка версии. Запрошено: ${requested:-<пусто>}" >&2
exit 2
fi

echo "==> Раскатка версии ${version} (запрос от ключа CI)"

if [ -d "${REPO_DIR}/.git" ]; then
git -C "$REPO_DIR" fetch -q --filter=blob:none origin \
"refs/tags/${version}:refs/tags/${version}" 2>/dev/null || true

if git -C "$REPO_DIR" rev-parse -q --verify "refs/tags/${version}^{commit}" >/dev/null; then
git -C "$REPO_DIR" -c advice.detachedHead=false checkout -q --force "refs/tags/${version}"
echo "==> Сверяю deploy/ с тегом ${version}"

compose_changed=0
for f in "${SYNCED[@]}"; do
src="${REPO_DIR}/deploy/${f}"
[ -f "$src" ] || continue
if ! cmp -s "$src" "${WORK_DIR}/${f}"; then
cp -f "$src" "${WORK_DIR}/${f}"
echo " обновлён: ${f}"
case "$f" in *compose.yml) compose_changed=1 ;; esac
fi
done
chmod +x "${WORK_DIR}/rollout.sh"

if [ "$compose_changed" = "1" ]; then
echo "!! compose-файл изменился — конфигурация на сервере устарела." >&2
echo " Примените вручную: перезапуск прокси рвёт соединения, поэтому не автоматом." >&2
fi
else
echo "!! Тег ${version} не найден — работаю с текущей копией deploy/." >&2
fi
else
echo "!! Клона репозитория нет — синхронизация deploy/ пропущена." >&2
fi

cd "$WORK_DIR"
exec ./rollout.sh "$version"
39 changes: 39 additions & 0 deletions deploy/redis-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Кэш сессий. Наружу порт не открыт, снаружи — через туннель:
# ssh -L 6379:127.0.0.1:6379 <сервер>
#
# docker compose -f redis-compose.yml up -d

name: fitb-redis

networks:
network:
external: true

services:
redis:
image: redis:8-alpine
container_name: redis
restart: always
networks:
network:
aliases: [redis]
# Без явного адреса docker открыл бы порт наружу в обход ufw
ports:
- "127.0.0.1:6379:6379"
command: >
redis-server
--requirepass ${REDIS_PASSWORD:?задайте REDIS_PASSWORD в .env}
--appendonly yes
--maxmemory 512mb
--maxmemory-policy allkeys-lru
--save 900 1
volumes:
- redis-data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a '${REDIS_PASSWORD}' --no-auth-warning ping | grep -q PONG"]
interval: 10s
timeout: 3s
retries: 5

volumes:
redis-data:
Loading
Loading