diff --git a/.env.example b/.env.example index 4544f00..e5fdc34 100644 --- a/.env.example +++ b/.env.example @@ -10,5 +10,7 @@ SCHEDULE_CACHE_TTL=900 SCHEDULE_RETRY_AFTER=60 # --- порты (только для docker) ------------------------------------------------- +# Оба порта публикуются на 127.0.0.1 (жёстко задано в docker-compose.yml): +# снаружи сервисы отдаёт nginx, напрямую в интернет они не смотрят. API_PORT=8000 FRONTEND_PORT=8080 diff --git a/Makefile b/Makefile index 78c4ab2..4f8a049 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ PY := $(CURDIR)/.venv/bin/python PIP := $(CURDIR)/.venv/bin/pip -.PHONY: help venv run dev run-api run-front test build up down restart logs ps deploy +.PHONY: help venv run dev run-api run-front test build up down restart logs ps deploy nginx-print nginx-install help: ## Показать этот список команд @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}' @@ -57,3 +57,11 @@ ps: ## Статус сервисов deploy: ## Собрать и запустить (docker) ./deploy.sh + +# --- nginx (на сервере) ------------------------------------------------------ + +nginx-print: ## Показать конфиг nginx для этого стенда, ничего не меняя + deploy/nginx-site.sh --print + +nginx-install: ## Сгенерировать, включить и перечитать конфиг nginx (запускать с sudo) + sudo deploy/nginx-site.sh diff --git a/README.md b/README.md index 3eb4fcc..5c9e54a 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ make run # создаст .venv, поставит зависим Яндекс.Диск (xlsx) │ скачивание по публичной ссылке ▼ -api (FastAPI), порт 8000, наружу — только 127.0.0.1 (в docker так публикуется порт) +api (FastAPI), порт 8000, в docker публикуется на 127.0.0.1 • скачивает и разбирает xlsx без сторонних библиотек • кэш в памяти: TTL + проверка даты изменения файла + stale-while-error • один воркер, поэтому кэш общий @@ -128,7 +128,8 @@ src/ tools/ make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert) deploy/ - nginx-dev.conf пример сайта nginx для отдельного стенда + nginx-site.sh генерация и включение конфига nginx (SSL — сертификат родителя) + nginx-dev.conf конфиг-заготовка для стенда без wildcard у родителя README-dev.md как поднять второй стенд рядом с основным tests/ conftest.py общие фикстуры @@ -176,8 +177,8 @@ tests/ | `SCHEDULE_RETRY_AFTER` | `60` | api | пауза между попытками при сбое источника | | `SCHEDULE_API` | `http://127.0.0.1:8000` | frontend | адрес бэкенда (в docker: `http://api:8000`) | | `SCHEDULE_PROXY_TIMEOUT` | `150` | frontend | таймаут прокси к бэкенду, сек | -| `API_PORT` | `8000` | docker | внешний порт api | -| `FRONTEND_PORT` | `8080` | docker | внешний порт frontend (на проде: `127.0.0.1:8090`) | +| `API_PORT` | `8000` | docker | внешний порт api (публикуется на 127.0.0.1) | +| `FRONTEND_PORT` | `8080` | docker | внешний порт frontend (публикуется на 127.0.0.1) | `.env` ищется вверх от каталога модуля: и backend, и frontend находят общий `.env` в корне репозитория; `load_dotenv` не перезаписывает уже выставленные @@ -219,12 +220,35 @@ tests/ пакет venv: `sudo apt install python3-venv` (для нестандартной версии — например, `python3.12-venv`). - `httpx2` и `httpcore2` — новые пакеты (август 2026, httpx перешёл под - опеку Pydantic и был переименован). Если pip берёт пакеты из корпоративного - зеркала или старого индекса, этих пакетов там может ещё не быть; проверьте - установку напрямую: `pip install -r requirements-dev.txt -i https://pypi.org/simple`. - В коде и тестах модуль импортируется как `httpx2`; `import httpx` больше - не работает. В рабочем коде httpx2 не используется — нужен только тестам. -- Makefile написан под bash: Linux, macOS или WSL. На Windows без WSL + опеку Pydantic и был переименован). В коде и тестах модуль импортируется + как `httpx2`; `import httpx` больше не работает. В рабочем коде httpx2 + не используется — нужен только тестам. + +**Если pip не находит пакеты: зеркала** + +Если pip настроен на корпоративное/национальное зеркало, оно может отставать +от PyPI — свежие пакеты на нём ещё не появились. Диагностика и обход: + +```bash +pip index versions httpx2 # есть ли пакет на текущем индексе +pip install -r requirements-dev.txt -i <зеркало> # разово через другое зеркало +pip config set global.index-url <зеркало> # закрепить зеркало навсегда +``` + +Зеркала, проверенные 07.10.2026 (пакеты `httpx2`/`httpcore2` на месте; +URL подставлять как есть в `-i` / `index-url`): + +| Зеркало | URL | +|---|---| +| PyPI, официальный | `https://pypi.org/simple` | +| TUNA (Tsinghua) | `https://pypi.tuna.tsinghua.edu.cn/simple` | +| Huawei Cloud | `https://mirrors.huaweicloud.com/repository/pypi/simple` | +| BFSU | `https://mirrors.bfsu.edu.cn/pypi/web/simple` | +| SJTU | `https://mirror.sjtu.edu.cn/pypi/web/simple` | +| USTC | `https://mirrors.ustc.edu.cn/pypi/simple` | +| SUSTech | `https://mirrors.sustech.edu.cn/pypi/simple` | + +Makefile написан под bash: Linux, macOS или WSL. На Windows без WSL используйте Docker-вариант из раздела «В Docker». ## Запуск @@ -243,6 +267,8 @@ make run # = make venv + запуск api и сайта - API — (например, `/status` и `/groups`); - сайт — (его и открывать в браузере). +Чтобы отвязать процесс от терминала, стоит запускать через `nohup make run &` + Остановить — `Ctrl+C`. Данные скачиваются сразу на старте приложения (скачивание занимает несколько секунд) и дальше обновляются по интервалу. @@ -274,6 +300,24 @@ cp .env.example .env # вписать SCHEDULE_LINK (и порты при У обоих сервисов есть healthcheck на `/healthz`; frontend стартует после того, как api стал healthy. Обновление: `git pull && docker compose up -d --build`. +### Настройка nginx (прод) + +Скрипт генерирует и включает конфиг сайта: http → https, прокси на +`FRONTEND_PORT` из `.env`, после проверки `nginx -t` перечитывает nginx. +SSL обеспечивается сертификатом родительского домена (обычно wildcard +`*.our-place.ru`) — отдельный certbot для поддомена не нужен. + +```bash +sudo ls /etc/letsencrypt/live/ # имя сертификата родительского домена +sudo make nginx-install # или: sudo deploy/nginx-site.sh +``` + +Параметры: `--domain sched.example.ru`, `--cert example.ru` (по умолчанию — +родительский домен от `--domain`), `--port 8090` (по умолчанию — +`FRONTEND_PORT` из `.env`). Посмотреть конфиг, ничего не меняя: +`make nginx-print`. Если wildcard у родителя нет — выпустите сертификат +скриптом certbot, как для dev-стенда (см. `deploy/README-dev.md`). + ### Тесты ```bash diff --git a/deploy/README-dev.md b/deploy/README-dev.md index 5e41fdd..7345631 100644 --- a/deploy/README-dev.md +++ b/deploy/README-dev.md @@ -19,13 +19,13 @@ git clone <адрес репозитория> /opt/schedule-dev SCHEDULE_LINK=https://disk.yandex.ru/i/... SCHEDULE_INTERVAL_MINUTES=15 SCHEDULE_RETRY_AFTER=60 -API_PORT=127.0.0.1:8001 -FRONTEND_PORT=127.0.0.1:8091 +API_PORT=8001 +FRONTEND_PORT=8091 ``` Порты 8001/8091 выбраны, чтобы не пересечься с основным стендом (8000/8090). -Привязка к `127.0.0.1` — чтобы API и фронт не смотрели в интернет напрямую, -наружу их отдаёт nginx. +В docker-compose.yml оба порта публикуются на `127.0.0.1` — API и фронт не +смотрят в интернет напрямую, наружу их отдаёт nginx. ## 3. Запуск @@ -37,8 +37,16 @@ sudo docker compose ps ## 4. nginx и сертификат -В `deploy/nginx-dev.conf` порт `proxy_pass` должен совпадать с -`FRONTEND_PORT` из `.env` (по умолчанию 8091). +Конфиг генерирует `deploy/nginx-site.sh`: порт фронта возьмёт из `.env`, +сертификат — родительского домена (wildcard), затем включит сайт, проверит +`nginx -t` и перечитает nginx: + +```bash +sudo deploy/nginx-site.sh --domain dev.schedule.our-place.ru --cert our-place.ru +``` + +Если у родительского домена нет wildcard-сертификата — выпустите свой +(конфиг-заготовка для ручного пути: `deploy/nginx-dev.conf`): ```bash sudo cp deploy/nginx-dev.conf /etc/nginx/sites-available/dev.schedule.our-place.ru diff --git a/deploy/nginx-site.sh b/deploy/nginx-site.sh new file mode 100755 index 0000000..5a902ee --- /dev/null +++ b/deploy/nginx-site.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Генерирует и включает конфиг nginx для сайта расписания. +# +# SSL обеспечивает сертификат родительского домена (обычно wildcard *.example.ru): +# отдельный certbot для поддомена не нужен — достаточно имени сертификата. +# Список имён: sudo ls /etc/letsencrypt/live/ +# +# Имя сертификата берётся из --cert, иначе — родительский домен от --domain +# (schedule.example.ru -> example.ru). Порт фронта берётся из --port, иначе из +# FRONTEND_PORT файла .env в корне проекта, иначе 8090. +# +# Примеры: +# deploy/nginx-site.sh --print # показать конфиг, ничего не менять +# sudo deploy/nginx-site.sh # поставить и перечитать nginx +# sudo deploy/nginx-site.sh --domain sched.example.ru --cert example.ru +set -euo pipefail + +usage() { + grep '^#' "$0" | sed 's/^# \{0,1\}//' | tail -n +2 +} + +DOMAIN="schedule.our-place.ru" +CERT_NAME="" +FRONTEND_PORT="" +PRINT=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --domain) DOMAIN="$2"; shift 2 ;; + --cert) CERT_NAME="$2"; shift 2 ;; + --port) FRONTEND_PORT="$2"; shift 2 ;; + --print) PRINT=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Неизвестный аргумент: $1 (см. --help)" >&2; exit 2 ;; + esac +done + +[[ $DOMAIN =~ ^[a-zA-Z0-9.-]+$ ]] || { echo "Кривое имя домена: $DOMAIN" >&2; exit 2; } +if [[ -z $CERT_NAME ]]; then + CERT_NAME="${DOMAIN#*.}" # schedule.our-place.ru -> our-place.ru (wildcard родителя) +fi +[[ $CERT_NAME =~ ^[a-zA-Z0-9.-]+$ ]] || { echo "Кривое имя сертификата: $CERT_NAME" >&2; exit 2; } + +if [[ -z $FRONTEND_PORT ]]; then + # FRONTEND_PORT из .env; значение вида «127.0.0.1:8090» трактуем как «порт после двоеточия» + FRONTEND_PORT=$( + grep -E '^FRONTEND_PORT=' .env 2>/dev/null | tail -1 | cut -d= -f2- \ + | tr -d ' "' | awk -F: '{print $NF}' + ) || FRONTEND_PORT="" +fi +FRONTEND_PORT=${FRONTEND_PORT:-8090} +[[ $FRONTEND_PORT =~ ^[0-9]+$ ]] || { echo "Не разобрал FRONTEND_PORT из .env, задайте --port" >&2; exit 2; } + +read -r -d '' CONFIG <<'TEMPLATE' || true +# Сгенерировано deploy/nginx-site.sh — правки вносить в скрипт, не сюда. +server { + listen 80; + listen [::]:80; + server_name __DOMAIN__; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name __DOMAIN__; + + # сертификат родительского домена (обычно wildcard), обновляет certbot родителя + ssl_certificate /etc/letsencrypt/live/__CERT__/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/__CERT__/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + client_max_body_size 8m; + + location / { + # порт FRONTEND_PORT из .env стенда + proxy_pass http://127.0.0.1:__PORT__; + 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 https; + # холодный кэш: бэкенд качает таблицу, до ~2 минут + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } +} +TEMPLATE + +CONFIG=${CONFIG//__DOMAIN__/$DOMAIN} +CONFIG=${CONFIG//__CERT__/$CERT_NAME} +CONFIG=${CONFIG//__PORT__/$FRONTEND_PORT} + +if [[ $PRINT == 1 ]]; then + printf '%s\n' "$CONFIG" + exit 0 +fi + +if [[ $EUID -ne 0 ]]; then + echo "Установка требует root: sudo deploy/nginx-site.sh (посмотреть конфиг — deploy/nginx-site.sh --print)" >&2 + exit 1 +fi + +DEST="/etc/nginx/sites-available/${DOMAIN}" +printf '%s\n' "$CONFIG" > "$DEST" +ln -sf "$DEST" "/etc/nginx/sites-enabled/${DOMAIN}" + +if nginx -t 2>&1; then + systemctl reload nginx + echo "Готово: https://${DOMAIN} -> 127.0.0.1:${FRONTEND_PORT} (сертификат ${CERT_NAME})" +else + echo "nginx -t не прошёл: конфиг записан в ${DEST} и включён, но nginx НЕ перечитан. Разберитесь и выполните: sudo nginx -t && sudo systemctl reload nginx" >&2 + exit 1 +fi diff --git a/docker-compose.yml b/docker-compose.yml index 9b14e8a..abee3c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,8 @@ services: # внутри compose api доступен по имени сервиса; менять не нужно SCHEDULE_API: ${SCHEDULE_API:-http://api:8000} ports: - - "${FRONTEND_PORT:-8080}:8080" + # фронт обслуживает nginx; наружу напрямую не смотрит + - "127.0.0.1:${FRONTEND_PORT:-8080}:8080" restart: unless-stopped depends_on: api: diff --git a/requirements-dev.txt b/requirements-dev.txt index 7f11a94..e18a0ad 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,2 @@ --r requirements.txt pytest==9.1.1 httpx2==2.12.0