diff --git a/Makefile b/Makefile index 4f8a049..90de524 100644 --- a/Makefile +++ b/Makefile @@ -60,8 +60,9 @@ deploy: ## Собрать и запустить (docker) # --- nginx (на сервере) ------------------------------------------------------ -nginx-print: ## Показать конфиг nginx для этого стенда, ничего не меняя - deploy/nginx-site.sh --print +# DOMAIN=sched.example.ru make nginx-print / nginx-install +nginx-print: ## Показать конфиг nginx (DOMAIN=домен), ничего не меняя + deploy/nginx-site.sh --print --domain $(DOMAIN) -nginx-install: ## Сгенерировать, включить и перечитать конфиг nginx (запускать с sudo) - sudo deploy/nginx-site.sh +nginx-install: ## Сгенерировать, включить и перечитать конфиг nginx (DOMAIN=домен, с sudo) + sudo deploy/nginx-site.sh --domain $(DOMAIN) diff --git a/README.md b/README.md index 5c9e54a..7ac283e 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,19 @@ # Расписание -Публичное расписание учебных групп факультета: источник данных — Яндекс.Таблица -университета (файл xlsx), который скачивается, разбирается, превращается в JSON -API и отображается на фронте. +Публичное расписание учебных групп. Источник данных — Яндекс.Таблица (xlsx на +Яндекс.Диске): файл скачивается по публичной ссылке, разбирается без сторонних +библиотек и отдаётся как JSON API и как сайт, который можно «установить» на +главный экран телефона (PWA). -## Быстрый старт +Умеет: -Нужны Python 3.11+, bash (Linux, macOS или WSL на Windows) и публичная ссылка -на таблицу. - -```bash -python3 --version # должен быть 3.11 или новее -cp .env.example .env # в SCHEDULE_LINK вписать публичную ссылку Я.Диска -make run # создаст .venv, поставит зависимости и запустит оба сервиса -``` - -Открыть . Остановить — `Ctrl+C`. -Подробнее — разделы «Зависимости» и «Запуск» ниже. +- расписание по группам, преподавателям и аудиториям с разделением + числитель/знаменатель и бейджем текущей недели; +- занятость всех аудиторий по дням и поиск свободных на конкретную пару; +- совпадающие недели показываются одним списком, различающиеся — двумя + колонками, пустая — «Окно»; +- примечания из таблицы (красный текст) — отдельной строкой в карточке; +- PWA: манифест, service worker, иконки — «на главный экран». ## Архитектура @@ -24,20 +21,324 @@ make run # создаст .venv, поставит зависим Яндекс.Диск (xlsx) │ скачивание по публичной ссылке ▼ -api (FastAPI), порт 8000, в docker публикуется на 127.0.0.1 - • скачивает и разбирает xlsx без сторонних библиотек +api — FastAPI, порт 8000 + • качает и разбирает xlsx стандартной библиотекой (zipfile + xml.etree) • кэш в памяти: TTL + проверка даты изменения файла + stale-while-error • один воркер, поэтому кэш общий - ▲ SCHEDULE_API=http://api:8000 (внутри docker-сети) - │ SCHEDULE_API=http://127.0.0.1:8000 (локальная разработка) -frontend (FastAPI: статика + прокси), прод :8090, dev :8080 - • отдаёт index.html / app.js / style.css / картинки + ▲ SCHEDULE_API=http://api:8000 (внутри docker-сети) + │ +frontend — FastAPI, порт из .env (по умолчанию 8080) + • отдаёт index.html / app.js / style.css / иконки • /api/* — тонкий прокси к api (браузер не знает адрес бэкенда) ▲ -nginx (https://schedule.our-place.ru) → 127.0.0.1:8090 +nginx — порты 80/443, https://<домен сайта> → 127.0.0.1:<порт фронта> ``` -## Как устроено расписание в таблице +Роли: + +- **docker** изолирует оба сервиса, перезапускает их при падении + (`restart: unless-stopped`) и следит за здоровьем (healthcheck на + `/healthz`); frontend стартует, только когда api стал healthy. +- Оба порта публикуются **только на 127.0.0.1** (жёстко задано в + `docker-compose.yml`): из интернета контейнеры напрямую недоступны. +- **nginx** — единственная дверь из интернета: принимает https и проксирует на + фронт. Его конфиг генерирует `deploy/nginx-site.sh` (это делает установщик). + +## Установка на сервер + +Скачайте zip с проектом, распакуйте и запустите одну команду: + +```bash +unzip schedule.zip && cd schedule +bash install.sh +``` + +Установка требует root — скрипт сам перезапустится через sudo. До запуска DNS +домена уже должен указывать на сервер, а порты 80/443 — быть открытыми: +сертификат Let's Encrypt выпускается прямо в процессе установки. + +Скрипт спросит: + +| Вопрос | Что ответить | +|---|---| +| Как ставить: Docker или систему | Docker — рекомендуется (разница ниже) | +| Публичная ссылка на таблицу | ссылка Я.Диска, единственная обязательная настройка | +| Домен сайта | например `sched.example.ru`; пусто = без nginx, сайт на 127.0.0.1 | +| Порт api и порт сайта | по умолчанию 8000 и 8080 | +| Пользователь служб (только для «в систему») | от кого работают сервисы | +| Почта (только если задан домен) | для уведомлений Let's Encrypt | + +Ответы можно передать и флагами: `--mode`, `--link`, `--domain`, `--api-port`, +`--front-port`, `--user`, `--email`. `install.sh --dry-run` показывает план +установки, ничего не меняя. + +### Docker или система — что выбрать + +**Docker** — рекомендуется: сервисы изолированы, автоматически перезапускаются, +обновляются одной командой, не зависят от Python в системе. Установщик поставит +Docker (если нет), запишет `.env`, соберёт образы, запустит контейнеры и +дождётся их готовности. + +**Система (без Docker)** — если Docker не нужен: установщик проверит Python +3.11+, создаст `.venv`, поставит зависимости и зарегистрирует две службы +systemd — `schedule-api` и `schedule-frontend` (автозапуск после ребута, +перезапуск при падении). + +В обоих случаях, если домен задан, установщик дополнительно поставит nginx и +certbot, выпустит сертификат Let's Encrypt (webroot — продление автоматическое, +сайт не останавливается) и настроит конфиг: http → https, прокси на порт +фронта. Снаружи виден только nginx. + +### Ручная установка (без скрипта) + +Установщик делает ровно следующее — при желании те же шаги выполняются руками. + +**Docker:** + +```bash +cp .env.example .env && nano .env # SCHEDULE_LINK (+ порты при желании) +sudo ./deploy.sh # собрать и запустить +curl -s http://127.0.0.1:8080/api/status # {"has_data":true} — работает +``` + +**Система:** + +```bash +sudo apt install -y python3 python3-venv python3-pip # Python 3.11+ +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +cp .env.example .env && nano .env # SCHEDULE_LINK +``` + +Две службы systemd (`/etc/systemd/system/`), затем +`sudo systemctl daemon-reload && sudo systemctl enable --now schedule-api schedule-frontend`: + +```ini +# schedule-api.service +[Unit] +Description=Schedule API (FastAPI) +After=network-online.target +Wants=network-online.target +[Service] +User=www-data +WorkingDirectory=/путь/к/проекту/src/backend +ExecStart=/путь/к/проекту/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 8000 +Restart=always +RestartSec=3 +[Install] +WantedBy=multi-user.target + +# schedule-frontend.service +[Unit] +Description=Schedule frontend (FastAPI) +After=network-online.target schedule-api.service +Wants=network-online.target schedule-api.service +[Service] +User=www-data +WorkingDirectory=/путь/к/проекту/src/frontend +Environment=SCHEDULE_API=http://127.0.0.1:8000 +ExecStart=/путь/к/проекту/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 8080 +Restart=always +RestartSec=3 +[Install] +WantedBy=multi-user.target +``` + +**nginx + сертификат** (общий шаг для обоих способов; `.env` уже должен быть): + +```bash +sudo apt install -y nginx certbot +# временный http-конфиг домена с location /.well-known/acme-challenge/ → /var/www/html +sudo certbot certonly --webroot -w /var/www/html -d sched.example.ru +sudo deploy/nginx-site.sh --domain sched.example.ru --cert sched.example.ru --port 8080 +curl -sI https://sched.example.ru/ | head -3 # HTTP/2 200 — готово +``` + +`deploy/nginx-site.sh` генерирует финальный конфиг (http → https, прокси на +`FRONTEND_PORT` из `.env`, таймауты под холодный кэш), включает его, проверяет +`nginx -t` и перечитывает nginx. Посмотреть конфиг, ничего не меняя: +`deploy/nginx-site.sh --print --domain sched.example.ru`. + +### Обслуживание + +- **Обновление**: Docker — `cd <каталог проекта> && sudo ./deploy.sh`; + система — заменить файлы проекта и + `sudo systemctl restart schedule-api schedule-frontend`. +- **Диагностика**: Docker — `sudo docker compose ps` и + `sudo docker logs api --tail 50`; система — `systemctl status schedule-api` + и `journalctl -u schedule-api -u schedule-frontend`. Состояние кэша — + `/api/status` (смотреть поле `error`). +- **Ротация логов контейнеров** — настроить сразу: без лимита логи растут + вечно и однажды забивают диск. `/etc/docker/daemon.json`: + + ```json + { "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } } + ``` + + затем `sudo systemctl restart docker`. +- **Диск**: следить `df -h /`; раз в месяц + `sudo docker system prune -af && sudo journalctl --vacuum-size=200M`. + +## Локальная разработка + +Нужны Python 3.11+ и bash (Linux, macOS или WSL; на Windows без WSL — Docker). + +```bash +python3 --version # должен быть 3.11 или новее +cp .env.example .env # вписать SCHEDULE_LINK +make run # создаст .venv, поставит зависимости, поднимет оба сервиса +``` + +Открыть (API — ). Остановить — +`Ctrl+C`; отвязать от терминала — `nohup make run &`. + +Данные скачиваются сразу на старте (несколько секунд) и дальше обновляются по +интервалу. + +Полезные команды (`make help` — весь список): + +| Команда | Что делает | +|---|---| +| `make venv` | создать `.venv` и установить зависимости | +| `make run` (или `make dev`) | запустить API и сайт | +| `make run-api` / `make run-front` | запустить только один из сервисов | +| `make test` | прогнать тесты | +| `make nginx-print` / `make nginx-install` | показать / установить конфиг nginx | + +Без make — то же самое руками: + +```bash +python3 -m venv .venv +.venv/bin/pip install -r requirements-dev.txt +.venv/bin/python -m uvicorn main:app --reload --port 8000 # в каталоге src/backend +.venv/bin/python -m uvicorn main:app --reload --port 8080 # в каталоге src/frontend +``` + +### Тесты + +```bash +make test # 121 тест, сеть не нужна +``` + +## Зависимости + +**Системные требования** + +| Что | Зачем | +|---|---| +| Python 3.11+ | локальная разработка и режим «в систему» | +| Docker + docker compose v2 | режим Docker (опционально) | + +**Python-пакеты** — закреплены в `requirements.txt` (рабочие) и +`requirements-dev.txt` (тесты): + +| Пакет | Версия | Назначение | +|---|---|---| +| fastapi | 0.141.1 | веб-фреймворк обоих сервисов | +| uvicorn | 0.52.4 | ASGI-сервер, которым запускаются сервисы | +| python-dotenv | 1.2.3 | чтение `.env` | +| requests | 2.34.2 | скачивание файла с Я.Диска и прокси к API | +| pytest | 9.1.1 | тесты — только dev | +| httpx2 | 2.12.0 | HTTP-клиент (наследник httpx) для `TestClient` — только dev | + +Больше ничего не нужно: xlsx разбирается стандартной библиотекой, базы данных +нет, кэш — в памяти процесса. httpx2 в рабочем коде не используется и нужен +только тестам; модуль импортируется как `httpx2` — httpx перешёл под опеку +Pydantic и был переименован в августе 2026, `import httpx` больше не работает. + +### Если установка не идёт + +- Проверьте `python3 --version`: нужен 3.11 или новее. Если под `python3` + скрывается старая версия, создайте окружение нужной явно: + `python3.12 -m venv .venv`. +- Debian/Ubuntu: если `make venv` падает на создании окружения, доставьте + пакет venv: `sudo apt install python3-venv` (для нестандартной версии — + например, `python3.12-venv`). + +### Если 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` | + +Примечания: Alibaba Cloud (`https://mirrors.aliyun.com/pypi/simple`) обычно +тоже свежий, но из части сетей недоступен; Яндекс-зеркало (`mirror.yandex.ru`) +PyPI не раздаёт вовсе. Зеркала отстают по-разному, поэтому для свежих пакетов +надёжнее разово ставить с официального индекса. + +## Переменные окружения + +| Переменная | По умолчанию | Где | Описание | +|---|---|---|---| +| `SCHEDULE_LINK` | — | api | публичная ссылка Я.Диска, **обязательна** | +| `SCHEDULE_INTERVAL_MINUTES` | `15` | api | как часто перечитывать таблицу, минут | +| `SCHEDULE_CACHE_TTL` | `900` | api | устаревшее: интервал в секундах (если не задан предыдущий) | +| `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 (публикуется на 127.0.0.1) | +| `FRONTEND_PORT` | `8080` | docker | внешний порт frontend (публикуется на 127.0.0.1) | + +`.env` ищется вверх от каталога модуля: и backend, и frontend находят общий +`.env` в корне репозитория; `load_dotenv` не перезаписывает уже выставленные +переменные окружения. + +## Структура кода + +``` +install.sh интерактивная установка на сервер: Docker или systemd + nginx +src/ + backend/ + main.py FastAPI: маршруты, create_app(cache), /healthz + schedule/ + xlsx.py чтение xlsx: общие строки, объединённые ячейки, + скрытые столбцы, красный текст, серийные даты + source.py скачивание файла по публичной ссылке Я.Диска + lessons.py разбор ячеек занятий (общий конвейер ролей) + parsing.py лист → расписание, недели, индексы преподавателей/аудиторий + cache.py ScheduleCache: блокировка, интервал, stale-while-error + errors.py ScheduleError + frontend/ + main.py FastAPI: статика + прокси /api/* (таймаут 150 c) + static/ + index.html, app.js, style.css + manifest.json PWA: имя, иконки, standalone-режим + sw.js минимальный service worker (без кэша) для установки приложения + img/ логотипы и иконки приложения +tools/ + make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert) +deploy/ + nginx-site.sh генерация и включение конфига nginx (используется install.sh) +tests/ + conftest.py общие фикстуры + xlsx_builder.py генерация xlsx в памяти (без openpyxl) + fixtures.py общая тестовая таблица + test_lessons.py все форматы ячеек + test_xlsx.py чтение книги + test_parsing.py разбор листов, недели, индексы + test_api.py API и поведение кэша (интервал/stale, 404/400/502) + test_deploy.py проверка, что образы копируют все нужные файлы +``` + +## Как устроена таблица-источник Лист «Бакалавры и магистры» — это сетка: @@ -103,45 +404,6 @@ nginx (https://schedule.our-place.ru) → 127.0.0.1:8090 - Приложение можно «установить» на главный экран (манифест + service worker + иконки приложения); на iOS иконку берёт `apple-touch-icon`. -## Структура кода - -``` -src/ - backend/ - main.py FastAPI: маршруты, create_app(cache), /healthz - schedule/ - xlsx.py чтение xlsx: общие строки, объединённые ячейки, - скрытые столбцы, красный текст, серийные даты - source.py скачивание файла по публичной ссылке Я.Диска - lessons.py разбор ячеек занятий (общий конвейер ролей) - parsing.py лист → расписание, недели, индексы преподавателей/аудиторий - cache.py ScheduleCache: блокировка, интервал, stale-while-error - errors.py ScheduleError - frontend/ - main.py FastAPI: статика + прокси /api/* (таймаут 150 c) - static/ - index.html, app.js, style.css - manifest.json PWA: имя, иконки, standalone-режим - sw.js минимальный service worker (без кэша) для установки приложения - img/ логотипы (owl.svg, owl-white.svg), иконки приложения - (app-icon.svg, icon-192/512.png, apple-touch-icon.png, favicon-32.png) -tools/ - make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert) -deploy/ - nginx-site.sh генерация и включение конфига nginx (SSL — сертификат родителя) - nginx-dev.conf конфиг-заготовка для стенда без wildcard у родителя - README-dev.md как поднять второй стенд рядом с основным -tests/ - conftest.py общие фикстуры - xlsx_builder.py генерация xlsx в памяти (без openpyxl) - fixtures.py общая тестовая таблица - test_lessons.py все форматы ячеек - test_xlsx.py чтение книги - test_parsing.py разбор листов, недели, индексы - test_api.py API и поведение кэша (интервал/stale, 404/400/502) - test_deploy.py проверка, что образы копируют все нужные файлы -``` - ## API Полный список методов отдаёт сам бэкенд: `GET /`. @@ -159,167 +421,10 @@ tests/ | GET | `/rooms-day?day={день}` | занятость всех аудиторий за день | | GET | `/rooms-free?day={день}&period={N}` | свободные аудитории | | GET | `/week` | недели семестра и текущая чётность | -| GET | `/status` | состояние кэша (`cache_ttl_seconds`, `last_updated`, `table_modified`, `error`) | +| GET | `/status` | состояние кэша (`last_updated`, `table_modified`, `error`, …) | | GET | `/healthz` | живость процесса (не обращается к таблице) | Фронт проксирует только то, что использует, под префиксом `/api/...`: `groups`, `schedule/{group}`, `teachers`, `teacher/{name}`, `rooms`, `room/{label}`, `rooms-day`, `rooms-free`, `week`, `status`. Остальное (`today`, `schedule/{group}/{day}`) доступно только на бэкенде. - -## Переменные окружения - -| Переменная | По умолчанию | Где | Описание | -|---|---|---|---| -| `SCHEDULE_LINK` | — | api | публичная ссылка Я.Диска, **обязательна** | -| `SCHEDULE_INTERVAL_MINUTES` | `15` | api | как часто перечитывать таблицу, минут | -| `SCHEDULE_CACHE_TTL` | `900` | api | устаревшее: интервал в секундах (если не задан предыдущий) | -| `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 (публикуется на 127.0.0.1) | -| `FRONTEND_PORT` | `8080` | docker | внешний порт frontend (публикуется на 127.0.0.1) | - -`.env` ищется вверх от каталога модуля: и backend, и frontend находят общий -`.env` в корне репозитория; `load_dotenv` не перезаписывает уже выставленные -переменные окружения. - -## Зависимости - -Проект состоит из двух сервисов на Python (FastAPI): `api` и `frontend`. -Всё ставится одной командой `make venv` (или вручную, см. ниже). - -**Системные требования** - -| Что | Зачем | -|---|---| -| Python 3.11+ | оба сервиса | -| Docker + docker compose v2 | только для запуска в контейнерах (опционально) | - -**Python-зависимости** — закреплены в `requirements.txt` (рабочие) и -`requirements-dev.txt` (тесты): - -| Пакет | Версия | Назначение | -|---|---|---| -| fastapi | 0.141.1 | веб-фреймворк обоих сервисов | -| uvicorn | 0.52.4 | ASGI-сервер, которым запускаются сервисы | -| python-dotenv | 1.2.3 | чтение `.env` | -| requests | 2.34.2 | скачивание файла с Яндекс.Диска и прокси к API | -| pytest | 9.1.1 | тесты (`make test`) — только dev | -| httpx2 | 2.12.0 | HTTP-клиент (наследник httpx) для `TestClient` в тестах — только dev | - -Ничего, кроме этого, не нужно: xlsx разбирается стандартной библиотекой -(`zipfile` + `xml.etree`), базы данных нет, кэш — в памяти процесса. - -**Если установка не идёт** - -- Проверьте `python3 --version`: нужен 3.11 или новее. Если в системе под - `python3` скрывается старая версия (например, 3.6 на старых CentOS), создайте - окружение нужной версией явно: `python3.12 -m venv .venv`. -- Debian/Ubuntu: если `make venv` падает на создании окружения, доставьте - пакет venv: `sudo apt install python3-venv` (для нестандартной версии — - например, `python3.12-venv`). -- `httpx2` и `httpcore2` — новые пакеты (август 2026, httpx перешёл под - опеку 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». - -## Запуск - -### Без Docker - -```bash -git clone <адрес репозитория> && cd Schedule -cp .env.example .env # открыть .env и вписать SCHEDULE_LINK -make run # = make venv + запуск api и сайта -``` - -`make run` создаёт виртуальное окружение `.venv`, ставит зависимости и -поднимает два сервиса с авто-перезагрузкой: - -- API — (например, `/status` и `/groups`); -- сайт — (его и открывать в браузере). - -Чтобы отвязать процесс от терминала, стоит запускать через `nohup make run &` - -Остановить — `Ctrl+C`. Данные скачиваются сразу на старте приложения -(скачивание занимает несколько секунд) и дальше обновляются по интервалу. - -Полезные команды (`make help` — весь список): - -| Команда | Что делает | -|---|---| -| `make venv` | создать `.venv` и установить зависимости | -| `make run` (или `make dev`) | запустить API и сайт | -| `make run-api` / `make run-front` | запустить только один из сервисов | -| `make test` | прогнать тесты | - -Если хочется без make — то же самое руками: - -```bash -python3 -m venv .venv -.venv/bin/pip install -r requirements-dev.txt -.venv/bin/python -m uvicorn main:app --reload --port 8000 # в каталоге src/backend -.venv/bin/python -m uvicorn main:app --reload --port 8080 # в каталоге src/frontend -``` - -### В Docker - -```bash -cp .env.example .env # вписать SCHEDULE_LINK (и порты при необходимости) -./deploy.sh # = docker compose build && up -d -``` - -У обоих сервисов есть 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 -make test # 121 тест, сеть не нужна -``` diff --git a/deploy/README-dev.md b/deploy/README-dev.md deleted file mode 100644 index 7345631..0000000 --- a/deploy/README-dev.md +++ /dev/null @@ -1,69 +0,0 @@ -# Второй стенд рядом с основным - -Как поднять ещё один экземпляр проекта (например, для проверки правок в таблице -на отдельном домене): свой каталог, свои порты, своя ссылка на таблицу. -Основной стенд при этом не трогаем. - -## 1. Код - -```bash -sudo mkdir -p /opt/schedule-dev && sudo chown "$USER" /opt/schedule-dev -git clone <адрес репозитория> /opt/schedule-dev -``` - -## 2. .env - -Скопировать `.env.example` в `/opt/schedule-dev/.env` и заполнить: - -```ini -SCHEDULE_LINK=https://disk.yandex.ru/i/... -SCHEDULE_INTERVAL_MINUTES=15 -SCHEDULE_RETRY_AFTER=60 -API_PORT=8001 -FRONTEND_PORT=8091 -``` - -Порты 8001/8091 выбраны, чтобы не пересечься с основным стендом (8000/8090). -В docker-compose.yml оба порта публикуются на `127.0.0.1` — API и фронт не -смотрят в интернет напрямую, наружу их отдаёт nginx. - -## 3. Запуск - -```bash -cd /opt/schedule-dev -sudo docker compose up -d --build -sudo docker compose ps -``` - -## 4. nginx и сертификат - -Конфиг генерирует `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 -sudo ln -sf /etc/nginx/sites-available/dev.schedule.our-place.ru /etc/nginx/sites-enabled/ -sudo nginx -t && sudo systemctl reload nginx -sudo certbot --nginx -d dev.schedule.our-place.ru -``` - -## 5. Проверка - -```bash -curl -s http://127.0.0.1:8091/healthz # {"ok":true} -curl -sI https://dev.schedule.our-place.ru/ | head -3 # 200 -``` - -## Обновление - -```bash -cd /opt/schedule-dev && git pull && sudo docker compose up -d --build -``` diff --git a/deploy/nginx-dev.conf b/deploy/nginx-dev.conf deleted file mode 100644 index 15d1485..0000000 --- a/deploy/nginx-dev.conf +++ /dev/null @@ -1,47 +0,0 @@ -# Тестовый стенд dev.schedule.our-place.ru -# -# Ставится рядом с прод-сайтом schedule.our-place.ru (тот не трогаем): -# отдельный каталог /opt/schedule-dev, отдельные порты, отдельная база настроек. -# -# sudo cp ~/schedule-dev/deploy/nginx-dev.conf /etc/nginx/sites-available/dev.schedule.our-place.ru -# sudo ln -sf /etc/nginx/sites-available/dev.schedule.our-place.ru /etc/nginx/sites-enabled/ -# sudo certbot --nginx -d dev.schedule.our-place.ru -# sudo nginx -t && sudo systemctl reload nginx - -server { - listen 80; - listen [::]:80; - server_name dev.schedule.our-place.ru; - - 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 dev.schedule.our-place.ru; - - ssl_certificate /etc/letsencrypt/live/dev.schedule.our-place.ru/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/dev.schedule.our-place.ru/privkey.pem; - ssl_protocols TLSv1.2 TLSv1.3; - - client_max_body_size 8m; - - location / { - # порт должен совпадать с FRONTEND_PORT из .env стенда - proxy_pass http://127.0.0.1:8091; - 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; - } -} diff --git a/deploy/nginx-site.sh b/deploy/nginx-site.sh index 5a902ee..992792b 100755 --- a/deploy/nginx-site.sh +++ b/deploy/nginx-site.sh @@ -6,12 +6,12 @@ # Список имён: sudo ls /etc/letsencrypt/live/ # # Имя сертификата берётся из --cert, иначе — родительский домен от --domain -# (schedule.example.ru -> example.ru). Порт фронта берётся из --port, иначе из -# FRONTEND_PORT файла .env в корне проекта, иначе 8090. +# (sched.example.ru -> example.ru). Порт фронта берётся из --port, иначе из +# FRONTEND_PORT файла .env в корне проекта, иначе 8080. # # Примеры: -# deploy/nginx-site.sh --print # показать конфиг, ничего не менять -# sudo deploy/nginx-site.sh # поставить и перечитать nginx +# deploy/nginx-site.sh --print --domain sched.example.ru # показать конфиг +# sudo deploy/nginx-site.sh --domain sched.example.ru # поставить и перечитать nginx # sudo deploy/nginx-site.sh --domain sched.example.ru --cert example.ru set -euo pipefail @@ -19,7 +19,9 @@ usage() { grep '^#' "$0" | sed 's/^# \{0,1\}//' | tail -n +2 } -DOMAIN="schedule.our-place.ru" +die() { echo "Ошибка: $*" >&2; exit 2; } + +DOMAIN="" CERT_NAME="" FRONTEND_PORT="" PRINT=0 @@ -35,9 +37,10 @@ while [[ $# -gt 0 ]]; do esac done -[[ $DOMAIN =~ ^[a-zA-Z0-9.-]+$ ]] || { echo "Кривое имя домена: $DOMAIN" >&2; exit 2; } +[[ -n $DOMAIN ]] || die "Укажите домен: --domain sched.example.ru (см. --help)" +[[ $DOMAIN =~ ^[a-zA-Z0-9.-]+$ ]] || die "Кривое имя домена: $DOMAIN" if [[ -z $CERT_NAME ]]; then - CERT_NAME="${DOMAIN#*.}" # schedule.our-place.ru -> our-place.ru (wildcard родителя) + CERT_NAME="${DOMAIN#*.}" # sched.example.ru -> example.ru (wildcard родителя) fi [[ $CERT_NAME =~ ^[a-zA-Z0-9.-]+$ ]] || { echo "Кривое имя сертификата: $CERT_NAME" >&2; exit 2; } @@ -48,7 +51,7 @@ if [[ -z $FRONTEND_PORT ]]; then | tr -d ' "' | awk -F: '{print $NF}' ) || FRONTEND_PORT="" fi -FRONTEND_PORT=${FRONTEND_PORT:-8090} +FRONTEND_PORT=${FRONTEND_PORT:-8080} [[ $FRONTEND_PORT =~ ^[0-9]+$ ]] || { echo "Не разобрал FRONTEND_PORT из .env, задайте --port" >&2; exit 2; } read -r -d '' CONFIG <<'TEMPLATE' || true diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..177a540 --- /dev/null +++ b/install.sh @@ -0,0 +1,316 @@ +#!/usr/bin/env bash +# Интерактивная установка «Расписания» на чистый сервер. +# +# Запуск из распакованного каталога проекта (нужен root — скрипт сам +# перезапустится через sudo): +# +# bash install.sh +# +# Скрипт спросит: как ставить (Docker или прямо в систему), публичную ссылку +# на таблицу и домен сайта. Затем поставит всё нужное, запустит сервисы и +# настроит nginx с сертификатом Let's Encrypt. +# +# Для автоматизации ответы можно передать аргументами: +# --mode docker|system способ установки +# --link URL публичная ссылка Яндекс.Диска с таблицей +# --domain DOMAIN домен сайта (пусто = без nginx, сайт на 127.0.0.1) +# --api-port N порт api (по умолчанию 8000) +# --front-port N порт сайта (по умолчанию 8080) +# --user NAME пользователь служб (режим «в систему») +# --email ADDR почта для уведомлений Let's Encrypt +# --dry-run показать план установки, ничего не меняя +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } +err() { printf '\033[31m==>\033[0m %s\n' "$*" >&2; } +die() { err "$*"; exit 1; } + +usage() { grep '^#' "$0" | sed 's/^# \{0,1\}//' | tail -n +2; } + +MODE="" LINK="" DOMAIN="" DOMAIN_SET=0 API_PORT="" FRONT_PORT="" RUN_USER="" EMAIL="" DRY_RUN=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --mode) MODE="$2"; shift 2 ;; + --link) LINK="$2"; shift 2 ;; + --domain) DOMAIN="$2"; DOMAIN_SET=1; shift 2 ;; + --api-port) API_PORT="$2"; shift 2 ;; + --front-port) FRONT_PORT="$2"; shift 2 ;; + --user) RUN_USER="$2"; shift 2 ;; + --email) EMAIL="$2"; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "Неизвестный аргумент: $1 (см. --help)" ;; + esac +done + +# ask "Вопрос" [по_умолчанию] — ответ в $REPLY +ask() { + local def="${2:-}" input + if ! read -rp "$1${def:+ [$def]}: " input; then + die "Нужен интерактивный ввод — запустите в терминале или передайте все параметры флагами (см. --help)" + fi + REPLY="${input:-$def}" +} + +# --- вопросы ------------------------------------------------------------------- + +if [[ -z $MODE ]]; then + log "Как ставить?" + log " 1) Docker — изолированные контейнеры, автоперезапуск, обновление одной командой (рекомендуется)" + log " 2) Система — без Docker: venv + службы systemd" + while true; do + ask "Способ установки (docker/system)" "docker" + REPLY=$(echo "$REPLY" | tr '[:upper:]' '[:lower:]' | xargs) + if [[ $REPLY == 1 ]]; then + REPLY=docker + elif [[ $REPLY == 2 ]]; then + REPLY=system + fi + [[ $REPLY =~ ^(docker|system)$ ]] && break || err "Ответьте docker или system" + done + MODE=$REPLY +fi +[[ $MODE =~ ^(docker|system)$ ]] || die "--mode: только docker или system" + +if [[ -z $LINK ]]; then + while true; do + ask "Публичная ссылка на таблицу (Яндекс.Диск)" + REPLY=$(echo "$REPLY" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr -d '"') + [[ -n $REPLY ]] && break || err "Без ссылки приложение не сможет скачать расписание" + done + LINK=$REPLY +fi + +if [[ $DOMAIN_SET == 0 && -z $DOMAIN ]]; then + ask "Домен сайта, например sched.example.ru (пусто = без nginx, сайт только на 127.0.0.1)" + DOMAIN=$(echo "$REPLY" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') +fi +if [[ -n $DOMAIN && ! $DOMAIN =~ ^[a-z0-9.-]+$ ]]; then die "Кривое имя домена: $DOMAIN"; fi + +if [[ -z $API_PORT ]]; then + ask "Порт api (нужен только для диагностики)" "8000" + API_PORT=$REPLY +fi +if [[ -z $FRONT_PORT ]]; then + ask "Порт сайта" "8080" + FRONT_PORT=$REPLY +fi +[[ $API_PORT =~ ^[0-9]+$ && $FRONT_PORT =~ ^[0-9]+$ ]] || die "Порты должны быть числами" + +if [[ $MODE == system ]]; then + if [[ -z $RUN_USER ]]; then + ask "Пользователь, от которого будут работать службы" "${SUDO_USER:-$(id -un)}" + RUN_USER=$REPLY + fi + id "$RUN_USER" >/dev/null 2>&1 || die "Пользователь $RUN_USER не существует" +fi +if [[ -n $DOMAIN && -z $EMAIL ]]; then + ask "Почта для уведомлений Let's Encrypt" "admin@$DOMAIN" + EMAIL=$REPLY +fi + +if [[ $DRY_RUN == 1 ]]; then + log "План установки (dry-run, ничего не выполнено):" + echo " режим: $MODE" + echo " таблица: $LINK" + echo " домен: ${DOMAIN:-(не задан — nginx и https пропускаются, сайт на 127.0.0.1)}" + echo " порты: api $API_PORT, сайт $FRONT_PORT" + if [[ $MODE == system ]]; then + echo " службы от: $RUN_USER" + fi + if [[ -n $DOMAIN ]]; then + echo " сертификат: Let's Encrypt для $DOMAIN (почта: $EMAIL)" + fi + exit 0 +fi + +# --- права ---------------------------------------------------------------------- + +if [[ $EUID -ne 0 ]]; then + if [[ ${INSTALL_NO_SUDO:-0} == 1 ]]; then + err "Запущено без root (INSTALL_NO_SUDO=1) — часть шагов может не пройти" + else + log "Нужны права root — перезапускаюсь через sudo" + exec sudo bash "$0" "$@" + fi +fi + +# --- помощники ------------------------------------------------------------------ + +pkg_install() { + local missing=() p + for p in "$@"; do command -v "$p" >/dev/null 2>&1 || missing+=("$p"); done + if [[ ${#missing[@]} -eq 0 ]]; then + return 0 + fi + log "Ставлю пакеты: ${missing[*]}" + if command -v apt-get >/dev/null 2>&1; then + apt-get update -y + DEBIAN_FRONTEND=noninteractive apt-get install -y "${missing[@]}" + elif command -v dnf >/dev/null 2>&1; then + dnf install -y "${missing[@]}" + else + die "Не нашёл apt-get/dnf — поставьте вручную: ${missing[*]}" + fi +} + +wait_health() { + local url="$1" name="$2" i + printf '%s' "==> Жду $name ($url)" + for i in $(seq 1 60); do + if curl -sf -m 2 "$url" >/dev/null 2>&1; then echo " — готово"; return 0; fi + printf '.'; sleep 1 + done + echo + die "$name не отвечает на $url — смотрите логи (docker logs / journalctl)" +} + +write_env() { + cd "$ROOT" + if [[ -f .env && ! -f .env.install-backup ]]; then + cp .env .env.install-backup + fi + cat > .env </dev/null 2>&1; then + log "Docker уже установлен" + else + log "Ставлю Docker..." + curl -fsSL https://get.docker.com | sh + fi + if [[ $EUID -eq 0 ]]; then + systemctl enable --now docker + fi +} + +setup_nginx() { + pkg_install nginx certbot curl + if [[ ! -d /etc/nginx/sites-available ]]; then + die "Нужен nginx с поддержкой sites-available (Debian/Ubuntu); на других системах настройте конфиг по README" + fi + log "Временный http-конфиг $DOMAIN — для выпуска сертификата" + cat > "/etc/nginx/sites-available/$DOMAIN" <<'NGINX' +# Сгенерировано install.sh: временный конфиг для certbot, потом заменяется +server { + listen 80; + listen [::]:80; + server_name __DOMAIN__; + root /var/www/html; + location /.well-known/acme-challenge/ { } +} +NGINX + sed -i "s/__DOMAIN__/$DOMAIN/" "/etc/nginx/sites-available/$DOMAIN" + ln -sf "/etc/nginx/sites-available/$DOMAIN" "/etc/nginx/sites-enabled/$DOMAIN" + nginx -t && systemctl reload nginx + + log "Выпускаю сертификат Let's Encrypt для $DOMAIN (webroot, без остановки nginx)" + certbot certonly --webroot -w /var/www/html -d "$DOMAIN" \ + --non-interactive --agree-tos -m "$EMAIL" + + log "Финальный конфиг сайта: https + прокси на 127.0.0.1:$FRONT_PORT" + "$ROOT/deploy/nginx-site.sh" --domain "$DOMAIN" --cert "$DOMAIN" --port "$FRONT_PORT" +} + +# --- установка ------------------------------------------------------------------ + +if [[ $MODE == docker ]]; then + install_docker + write_env + log "Собираю и запускаю контейнеры (первая сборка — несколько минут)..." + (cd "$ROOT" && docker compose up -d --build) + wait_health "http://127.0.0.1:$FRONT_PORT/healthz" "frontend" +else + log "Проверяю Python..." + pkg_install python3 python3-pip + if command -v apt-get >/dev/null 2>&1; then + apt-get install -y python3-venv # на Debian/Ubuntu venv без этого пакета не работает + fi + python3 - <<'PY' || die "Нужен Python 3.11 или новее (см. README, «Зависимости»)" +import sys +sys.exit(0 if sys.version_info >= (3, 11) else 1) +PY + write_env + log "Создаю виртуальное окружение и ставлю зависимости..." + [[ -d "$ROOT/.venv" ]] || python3 -m venv "$ROOT/.venv" + "$ROOT/.venv/bin/pip" install -q --upgrade pip + "$ROOT/.venv/bin/pip" install -q -r "$ROOT/requirements.txt" + + log "Настраиваю службы systemd..." + cat > /etc/systemd/system/schedule-api.service < /etc/systemd/system/schedule-frontend.service <