Compare commits

...

2 Commits

Author SHA1 Message Date
3d5bfe3023 README: убрана пустая строка в блоке структуры после удаления install.sh 2026-10-10 11:51:33 +03:00
b49ef93619 Убран install.sh: установка через deploy.sh
Для личного использования достаточно deploy.sh (проверяет окружение, собирает
образы и поднимает контейнеры). Раздел «Установка на сервер» переписан:
Docker через deploy.sh + вариант без Docker (systemd) и nginx, без интерактивного
установщика и его флагов.
2026-10-10 11:51:14 +03:00
2 changed files with 17 additions and 377 deletions

View File

@@ -46,68 +46,29 @@ nginx — порты 80/443, https://<домен сайта> → 127.0.0.1:<по
## Установка на сервер
Скачайте zip с проектом, распакуйте и запустите одну команду:
### Docker (deploy.sh)
Скачайте zip с проектом, распакуйте и заполните `.env`:
```bash
unzip schedule.zip && cd schedule
bash install.sh
cp .env.example .env && nano .env # SCHEDULE_LINK обязателен; ADMIN_* — по желанию
sudo ./deploy.sh # собрать образы и запустить
curl -s http://127.0.0.1:8080/api/status # {"has_data":true} — работает
```
Установка требует root — скрипт сам перезапустится через sudo. До запуска DNS
домена уже должен указывать на сервер, а порты 80/443 — быть открытыми:
сертификат Let's Encrypt выпускается прямо в процессе установки.
`deploy.sh` проверяет, что Docker и `.env` на месте, собирает образы и поднимает
контейнеры. Обновление — тем же скриптом: `git pull && sudo ./deploy.sh`.
Почему Docker: сервисы изолированы, автоматически перезапускаются, не зависят
от Python в системе и обновляются одной командой.
Скрипт спросит:
| Вопрос | Что ответить |
|---|---|
| Как ставить: 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} — работает
```
**Система:**
### Без Docker (systemd)
```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
cp .env.example .env && nano .env # SCHEDULE_LINK (+ ADMIN_*, если нужна админка)
```
Две службы systemd (`/etc/systemd/system/`), затем
@@ -144,6 +105,10 @@ RestartSec=3
WantedBy=multi-user.target
```
Если службы работают не от владельца каталога проекта, выдайте пользователю
службы права на запись в `data/` (там админка хранит ссылку, интервал и историю
входов) или задайте свой путь в `SETTINGS_FILE`.
**nginx + сертификат** (общий шаг для обоих способов; `.env` уже должен быть):
```bash
@@ -345,7 +310,6 @@ ADMIN_PASSWORD=<длинный пароль>
## Структура кода
```
install.sh интерактивная установка на сервер: Docker или systemd + nginx
src/
backend/
main.py FastAPI: маршруты, create_app(cache), /healthz
@@ -370,7 +334,7 @@ src/
tools/
make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert)
deploy/
nginx-site.sh генерация и включение конфига nginx (используется install.sh)
nginx-site.sh генерация и включение конфига nginx (ручная настройка)
tests/
conftest.py общие фикстуры
xlsx_builder.py генерация xlsx в памяти (без openpyxl)

View File

@@ -1,324 +0,0 @@
#!/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 <<EOF
# Сгенерировано install.sh
SCHEDULE_LINK=$LINK
SCHEDULE_INTERVAL_MINUTES=15
SCHEDULE_RETRY_AFTER=60
API_PORT=$API_PORT
FRONTEND_PORT=$FRONT_PORT
# Админка (необязательно). Заполните path/логин/пароль, чтобы включить панель:
# страница будет доступна только по https://<домен><ADMIN_PATH>
ADMIN_PATH=
ADMIN_LOGIN=
ADMIN_PASSWORD=
ADMIN_MAX_ATTEMPTS=3
ADMIN_LOCKOUT_MINUTES=15
EOF
log ".env записан (прежний, если был, сохранён в .env.install-backup)"
}
install_docker() {
if command -v docker >/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 <<EOF
[Unit]
Description=Schedule API (FastAPI)
After=network-online.target
Wants=network-online.target
[Service]
User=$RUN_USER
WorkingDirectory=$ROOT/src/backend
ExecStart=$ROOT/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port $API_PORT
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
cat > /etc/systemd/system/schedule-frontend.service <<EOF
[Unit]
Description=Schedule frontend (FastAPI)
After=network-online.target schedule-api.service
Wants=network-online.target schedule-api.service
[Service]
User=$RUN_USER
WorkingDirectory=$ROOT/src/frontend
Environment=SCHEDULE_API=http://127.0.0.1:$API_PORT
ExecStart=$ROOT/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port $FRONT_PORT
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
chown -R "$RUN_USER" "$ROOT"
systemctl daemon-reload
systemctl enable --now schedule-api.service schedule-frontend.service
wait_health "http://127.0.0.1:$API_PORT/healthz" "api"
wait_health "http://127.0.0.1:$FRONT_PORT/healthz" "frontend"
fi
if [[ -n $DOMAIN ]]; then
setup_nginx
else
log "Домен не задан — nginx и https пропущены. Сайт работает на http://127.0.0.1:$FRONT_PORT"
fi
# --- итог -----------------------------------------------------------------------
log "Установка завершена."
if [[ -n $DOMAIN ]]; then
echo " Сайт: https://$DOMAIN"
else
echo " Сайт: http://127.0.0.1:$FRONT_PORT (с самого сервера)"
fi
echo " Таблица: $LINK"
echo " Настройки: $ROOT/.env"
if [[ $MODE == docker ]]; then
echo " Логи: sudo docker logs api / frontend"
echo " Обновление: cd $ROOT && sudo docker compose up -d --build"
echo " Напоминание: настройте ротацию логов Docker (/etc/docker/daemon.json), см. README"
else
echo " Службы: sudo systemctl status schedule-api schedule-frontend"
echo " Логи: journalctl -u schedule-api -u schedule-frontend"
echo " Обновление: заменить файлы проекта, затем sudo systemctl restart schedule-api schedule-frontend"
fi