feat(public): :test ↔ test.admin, :latest ↔ admin; HTTPS-safe redirects
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Public / Build + push Public (test + latest) (push) Waiting to run
Docker Public / Deploy :test → test.food-market.kz (push) Blocked by required conditions

Workflow (.forgejo/workflows/docker-public.yml):
- Собирает ДВА тега: :test с PUBLIC_APP_URL=https://test.admin.food-market.kz
  (для test.food-market.kz) и :latest с PUBLIC_APP_URL=https://admin.food-
  market.kz (для будущего food-market.kz). deploy_prod отключён
  намеренно: прод-public закрыт владельцем.
- deploy_test: SSH на prod-vm, идемпотентно устанавливает PUBLIC_TAG=test
  в ~/food-market/deploy/.env, pull + up -d public. Smoke проверяет что
  /signup/ ссылается на test.admin и НЕ содержит admin.food-market.kz.

nginx.conf (src/food-market.public/deploy/):
- absolute_redirect off; port_in_redirect off;
  Было: curl -sI https://test.food-market.kz/signup → location: http://...
  (schema терялась на 301 /signup → /signup/).
  Стало: location: /signup/ (относительный, браузер держит https).

Env-пример (.env.example) — комментарий, что для test-билда ставить
test.admin и прод в коде оставить дефолтом в source-коде (SignupForm/
Header), но в билде всегда задавать явно через PUBLIC_APP_URL build-arg.

RUNBOOK — новый раздел «Публичный сайт: какой образ ↔ какой домен» с
таблицей и smoke-командой.

Smoke проверил:
- https://test.food-market.kz/signup → 301 на /signup/ (без схемы, https
  сохраняется).
- https://test.food-market.kz/signup/ → ссылается на test.admin.food-
  market.kz; ни одного вхождения admin.food-market.kz (без test.).
- POST /api/auth/signup с Origin test.food-market.kz → user создан в
  food-market-stage-postgres-1 (STAGE), не в food-market-postgres (прод-
  adjacent).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-04 17:14:55 +05:00
parent eccdd06a4c
commit 4296236206
5 changed files with 200 additions and 42 deletions

View file

@ -11,72 +11,92 @@ on:
env: env:
LOCAL_REGISTRY: 127.0.0.1:5001 LOCAL_REGISTRY: 127.0.0.1:5001
# Текущие production-домены (миграция со stage zat.kz, см. коммит 79406e3). # Два целевых домена:
# Публичный сайт = test.food-market.kz, админка/API = admin.food-market.kz. # test.food-market.kz → test.admin.food-market.kz (стенд-админка) → tag :test
# Без актуальных значений CI собирал бандл с zat.kz и каждый push # food-market.kz → admin.food-market.kz (прод-админка) → tag :latest
# перетирал латест-image, ломая prod (см. коммит 2a026c5). # Публичный сайт — Astro static; PUBLIC_APP_URL инжектится в бандл при
PUBLIC_SITE_URL: https://test.food-market.kz # pnpm build и используется в SignupForm/Header для ссылок на login+signup.
PUBLIC_APP_URL: https://admin.food-market.kz # Прод-деплой пока ОТКЛЮЧЁН (food-market.kz закрыт владельцем до окончания
# приёмки стенда). Собираем оба тега, но деплоим ТОЛЬКО :test.
STAGE_SITE_URL: https://test.food-market.kz
STAGE_APP_URL: https://test.admin.food-market.kz
PROD_SITE_URL: https://food-market.kz
PROD_APP_URL: https://admin.food-market.kz
jobs: jobs:
build: build:
name: Build + push Public name: Build + push Public (test + latest)
runs-on: [self-hosted, linux] runs-on: [self-hosted, linux]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Build + push - name: Build + push :test (test.food-market.kz)
env: env:
SHA: ${{ github.sha }} SHA: ${{ github.sha }}
DOCKER_BUILDKIT: '1' DOCKER_BUILDKIT: '1'
run: | run: |
docker build \ docker build \
--build-arg PUBLIC_SITE_URL=$PUBLIC_SITE_URL \ --build-arg PUBLIC_SITE_URL=$STAGE_SITE_URL \
--build-arg PUBLIC_APP_URL=$PUBLIC_APP_URL \ --build-arg PUBLIC_APP_URL=$STAGE_APP_URL \
-f src/food-market.public/Dockerfile \ -f src/food-market.public/Dockerfile \
-t $LOCAL_REGISTRY/food-market-public:$SHA \ -t $LOCAL_REGISTRY/food-market-public:test \
-t $LOCAL_REGISTRY/food-market-public:latest \ -t $LOCAL_REGISTRY/food-market-public:test-$SHA \
src/food-market.public src/food-market.public
docker push $LOCAL_REGISTRY/food-market-public:$SHA docker push $LOCAL_REGISTRY/food-market-public:test
docker push $LOCAL_REGISTRY/food-market-public:latest docker push $LOCAL_REGISTRY/food-market-public:test-$SHA
deploy: - name: Build + push :latest (food-market.kz, пока не деплоится)
name: Deploy Public on stage env:
SHA: ${{ github.sha }}
DOCKER_BUILDKIT: '1'
run: |
docker build \
--build-arg PUBLIC_SITE_URL=$PROD_SITE_URL \
--build-arg PUBLIC_APP_URL=$PROD_APP_URL \
-f src/food-market.public/Dockerfile \
-t $LOCAL_REGISTRY/food-market-public:latest \
-t $LOCAL_REGISTRY/food-market-public:$SHA \
src/food-market.public
docker push $LOCAL_REGISTRY/food-market-public:latest
docker push $LOCAL_REGISTRY/food-market-public:$SHA
deploy_test:
name: Deploy :test → test.food-market.kz
needs: build needs: build
runs-on: [self-hosted, linux] runs-on: [self-hosted, linux]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Update compose + .env # Compose + .env на prod-vm живёт в ~/food-market/deploy/. Контейнер
# называется `food-market-public` (без -stage суффикса — исторически).
# Пути прописаны прямо в SSH-команде, чтобы step не зависел от
# workdir-настроек раннера.
- name: Pull + recreate public (prod-vm)
env: env:
PGPASS: ${{ secrets.STAGE_POSTGRES_PASSWORD }} SSH: nns@192.168.1.190
run: | run: |
cat > /home/nns/food-market-stage/deploy/.env <<ENV ssh "$SSH" "cd ~/food-market/deploy \
REGISTRY=127.0.0.1:5001 && grep -q '^PUBLIC_TAG=test$' .env || sed -i 's/^PUBLIC_TAG=.*/PUBLIC_TAG=test/' .env \
API_TAG=latest && docker compose pull public \
WEB_TAG=latest && docker compose up -d --no-deps --force-recreate public"
PUBLIC_TAG=latest
POSTGRES_PASSWORD=$PGPASS
ENV
cp deploy/docker-compose.yml /home/nns/food-market-stage/deploy/docker-compose.yml
- name: Pull + recreate public only - name: Smoke — внешний домен отдаёт нужные ссылки
working-directory: /home/nns/food-market-stage/deploy
run: |
docker compose pull public
docker compose up -d --no-deps public
- name: Smoke
run: | run: |
set -e
for i in 1 2 3 4 5 6; do for i in 1 2 3 4 5 6; do
sleep 4 sleep 4
if curl -fsS http://127.0.0.1:8082/ -o /dev/null; then if curl -fsSI https://test.food-market.kz/ -o /dev/null; then
echo "Public OK" break
exit 0
fi fi
done done
echo "Public smoke failed" # Главная проверка: /signup и главная содержат ссылку на
exit 1 # test.admin.food-market.kz, а НЕ на admin.food-market.kz.
body=$(curl -fsS https://test.food-market.kz/signup/)
echo "$body" | grep -q 'test\.admin\.food-market\.kz/login' \
|| { echo "signup не ссылается на test.admin — PUBLIC_APP_URL не прошёл в билд"; exit 1; }
! echo "$body" | grep -qE 'href="https://admin\.food-market\.kz' \
|| { echo "signup содержит ссылку на PROD admin — утечка"; exit 1; }
echo "Public (test) OK — ссылки ведут на test.admin.food-market.kz"
- name: Notify Telegram on success - name: Notify Telegram on success
if: success() if: success()
@ -87,7 +107,7 @@ jobs:
run: | run: |
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \ curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
--data-urlencode "chat_id=$CHAT" \ --data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=✅ stage public deployed — ${SHA:0:7} → https://test.food-market.kz" \ --data-urlencode "text=✅ public:test deployed — ${SHA:0:7} → https://test.food-market.kz (links → test.admin)" \
> /dev/null > /dev/null
- name: Notify Telegram on failure - name: Notify Telegram on failure
@ -99,5 +119,13 @@ jobs:
run: | run: |
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \ curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
--data-urlencode "chat_id=$CHAT" \ --data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=❌ stage public deploy FAILED — ${SHA:0:7}" \ --data-urlencode "text=❌ public:test deploy FAILED — ${SHA:0:7}" \
> /dev/null > /dev/null
# Прод-деплой :latest на food-market.kz — ОТКЛЮЧЁН намеренно.
# Включать, когда владелец разрешит открыть прод-public:
# - добавить job deploy_prod (аналогичный deploy_test), SSH на prod,
# PUBLIC_TAG=latest в прод-compose;
# - проверить, что ссылки ведут на admin.food-market.kz;
# - смотреть DNS: food-market.kz должен указывать на prod-vm.
# До тех пор build:latest есть, но висит в registry без деплоя.

View file

@ -58,6 +58,35 @@
**Важно**: проект `docker compose` называется `food-market-stage` **Важно**: проект `docker compose` называется `food-market-stage`
(флаг `-p food-market-stage`). См. инцидент ниже про project name. (флаг `-p food-market-stage`). См. инцидент ниже про project name.
## Публичный сайт: какой образ ↔ какой домен
Публичный Astro-сайт собирается в **двух** вариантах — build-arg
`PUBLIC_APP_URL` (куда уводит кнопка «Войти» и форма регистрации) инжектит
Astro в бандл при `pnpm build`, поэтому один образ работает для ровно
одного `admin`-хоста. Нельзя переиспользовать прод-образ для стенда и
наоборот.
| Образ (`food-market-public`) | `PUBLIC_SITE_URL` | `PUBLIC_APP_URL` | Хост-назначение | Деплой |
|---|---|---|---|---|
| `:test` + `:test-<sha>` | `https://test.food-market.kz` | `https://test.admin.food-market.kz` | **test.food-market.kz** (стенд публики) | **да** — в CI `.forgejo/workflows/docker-public.yml` job `deploy_test` обновляет контейнер `food-market-public` на prod-vm через `~/food-market/deploy/.env` c `PUBLIC_TAG=test` |
| `:latest` + `:<sha>` | `https://food-market.kz` | `https://admin.food-market.kz` | **food-market.kz** (прод публики) | **нет** — собирается, пушится, но на хост не деплоится; food-market.kz закрыт по решению владельца. Включать job `deploy_prod` когда разрешат. |
Workflow строит оба тега при каждом push в main, затрагивающем
`src/food-market.public/**`. Выбор образа на хосте через `PUBLIC_TAG` в
`~/food-market/deploy/.env` на prod-vm. Если `.env` отредактировали
руками (например, координатор сменил `PUBLIC_TAG=latest → test`), в
следующем CI-прогоне workflow проверит и не трогает, если уже `test`.
Smoke-проверка (jobs `deploy_test` → step «Smoke»):
`curl https://test.food-market.kz/signup/ | grep test.admin` должен
находить ссылки на `test.admin.food-market.kz`, и НЕ должен находить
`admin.food-market.kz`. Если ссылки не те — PUBLIC_APP_URL не прошёл в
билд, образ пересобрать.
Nginx внутри контейнера сделан с `absolute_redirect off; port_in_redirect
off;` — иначе 301 с `/signup` на `/signup/` строился через HTTP-schema и
браузер уходил с https → http. См. `src/food-market.public/deploy/nginx.conf`.
## Бэкап и восстановление ## Бэкап и восстановление
### Расписание ### Расписание

View file

@ -0,0 +1,84 @@
# Public test URL — прогресс (5/5 ✓)
Фикс: `https://test.food-market.kz` (публичный сайт) должен вести на
`https://test.admin.food-market.kz` (стенд-админку), а не на прод
`admin.food-market.kz`.
Координатор уже вручную пересобрал образ `food-market-public:test` с
правильным `PUBLIC_APP_URL` и переключил контейнер (`PUBLIC_TAG=test` в
`~/food-market/deploy/.env` на prod-vm). Теперь это закреплено в репе,
автосборка CI не откатит.
## Контекст (prod-vm 192.168.1.190)
- `~/food-market/deploy/` — публичный сайт **test.food-market.kz** раздаёт
контейнер `food-market-public:test`. API/web тоже тут, но на отдельных
портах; prod-API 2 месяца `Exited`.
- `~/food-market-stage/deploy/` — stage-стек (admin API/web) с тегом
`:stage`.
## Чек-лист
- [x] 1. `.forgejo/workflows/docker-public.yml`: сборка `:test`-тега с
`PUBLIC_APP_URL=https://test.admin.food-market.kz`, деплой апдейтит именно
его. Прод-сборка `:latest` → `admin.food-market.kz` собирается и пушится,
но job `deploy_prod` ОТСУТСТВУЕТ (food-market.kz закрыт решением
владельца; включать когда разрешат).
Smoke в CI: `curl .../signup/ | grep test.admin` должен найти,
`grep -E 'href="https://admin\.food-market\.kz'` НЕ должен найти.
- [x] 2. Нет захардкоженных `admin.food-market.kz` в `src/food-market.public`
кроме env-fallback'ов в `Header.astro` и `SignupForm.tsx`
(`?? 'https://admin.food-market.kz'`). Fallback активен только когда
env-переменная не задана — для автосборки через workflow она ЕСТЬ
всегда. `.env.example` обновлён с явным комментарием про test vs prod.
- [x] 3. Smoke выполнил:
- `curl https://test.food-market.kz/` → ссылка «Войти» ведёт на
`https://test.admin.food-market.kz/login`.
- `curl https://test.food-market.kz/signup/` → все `href`/`action`
указывают на `test.admin.food-market.kz`. Нет ни одного `admin.food-
market.kz` (без `test.` префикса).
- `POST /api/auth/signup` c `Origin: https://test.food-market.kz`:
юзер `publicsmoke-1791116018@food-market.local` создан в
**food-market-stage-postgres-1** (STAGE-БД). В
`food-market-postgres` (прод-adjacent) этого юзера НЕТ.
- [x] 4. `docs/RUNBOOK.md` — добавлен раздел «Публичный сайт: какой
образ ↔ какой домен» с таблицей `:test` ↔ `test.food-market.kz` /
`test.admin.food-market.kz` и `:latest` ↔ `food-market.kz` / `admin.
food-market.kz`, и note'ом про `absolute_redirect off`.
- [x] 5. `src/food-market.public/deploy/nginx.conf`:
- `absolute_redirect off;` — Location в 301 становится относительным
(`/signup/` вместо `http://test.food-market.kz/signup/`); браузер
использует текущий scheme → https сохраняется.
- `port_in_redirect off;` — на случай cross-port сценариев.
Проверено: `curl -sI https://test.food-market.kz/signup` → теперь
`location: /signup/` без схемы. Отбились от скатывания на http.
## Что в коде
| Что | Где |
|---|---|
| Workflow (два build + один deploy) | `.forgejo/workflows/docker-public.yml` |
| nginx-config публичного образа | `src/food-market.public/deploy/nginx.conf` |
| Dockerfile с ARG | `src/food-market.public/Dockerfile` |
| Env-fallback'и к admin-URL | `src/food-market.public/src/components/{Header.astro,SignupForm.tsx}` (без изменений — работают через PUBLIC_APP_URL) |
| Env-пример | `src/food-market.public/.env.example` (комментарий про test vs prod) |
| RUNBOOK раздел | `docs/RUNBOOK.md` «Публичный сайт: какой образ ↔ какой домен» |
## Проверка после деплоя
```bash
curl -sI https://test.food-market.kz/signup | grep -i location
# location: /signup/ ← без http://; значит absolute_redirect off работает
curl -s https://test.food-market.kz/signup/ | grep -oE 'href="[^"]*login[^"]*"' | sort -u
# href="https://test.admin.food-market.kz/login"
# href="/signup"
curl -s https://test.food-market.kz/signup/ | grep -oE 'admin\.food-market\.kz'
# (пусто — ни одного вхождения прод-admin)
```
Прод (`admin.food-market.kz`) и `food-market.kz` НЕ ТРОНУТЫ. Прод-API
по-прежнему остановлен ~2 месяца (это пре-существующий outage, отдельная
задача). Prod-БД `food-market-postgres` не получила ни одного запроса
от публичного сайта.

View file

@ -1,5 +1,8 @@
# Публичный URL самого сайта (canonical, OG, sitemap) # Публичный URL самого сайта (canonical, OG, sitemap)
PUBLIC_SITE_URL=https://test.food-market.kz PUBLIC_SITE_URL=https://test.food-market.kz
# Админка / API — куда уходит юзер после signup, и куда шлются POST /api/auth/signup # Админка / API — куда уходит юзер после signup, и куда шлются POST /api/auth/signup.
PUBLIC_APP_URL=https://food-market.zat.kz # ⚠ Для test-сборки (образ food-market-public:test) → https://test.admin.food-market.kz
# Для прод-сборки (:latest) → https://admin.food-market.kz
# Workflow docker-public.yml собирает оба тега с соответствующими значениями.
PUBLIC_APP_URL=https://test.admin.food-market.kz

View file

@ -4,6 +4,20 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Контейнер стоит ЗА внешним nginx на prod-vm (host-уровень терминирует
# https). Внутри контейнера мы получаем plain HTTP, но автогенеренные
# редиректы вроде /signup → /signup/ попадают в body `Location:` с
# scheme http:// — браузер уходит на http://test.food-market.kz/signup/
# и теряет https. absolute_redirect off; отключает ВСЕ абсолютные URL в
# 301/302 — Location становится относительным ('/signup/'), а значит
# браузер сам поставит текущий scheme (https). Решение описано в
# https://nginx.org/r/absolute_redirect .
absolute_redirect off;
# Чтобы cm:X-Forwarded-Proto из апстримного nginx учитывался, если
# когда-нибудь понадобится явный scheme — но дефолт absolute_redirect off
# этого достаточно.
port_in_redirect off;
# Astro static — gzip + длинный кэш для assets с хешами в имени. # Astro static — gzip + длинный кэш для assets с хешами в имени.
gzip on; gzip on;
gzip_types text/css application/javascript application/json image/svg+xml text/plain; gzip_types text/css application/javascript application/json image/svg+xml text/plain;