diff --git a/.env.example b/.env.example index 50367c8..758e38f 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ APP_ENV=dev APP_HOST=0.0.0.0 APP_PORT=8000 +APP_PUBLIC_IP= +APP_PUBLIC_DOMAIN= +APP_BASE_URL= DB_PATH=data/app.db SESSION_SECRET=change-me @@ -22,4 +25,3 @@ EXTERNAL_API_CREATE_URL_WITH_KEY= OPERATOR_CHAT_IDS= REQUEST_CATEGORIES=Общая консультация,Техническая проблема,Оплата,Другое - diff --git a/.woodpecker.yml b/.woodpecker.yml index 1cc01d5..a42e35f 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -6,47 +6,29 @@ steps: image: python:3.11-slim commands: - pip install --no-cache-dir -r requirements.txt - - pytest -q + - pytest -q tests -p no:cacheprovider - docker_build_push: - image: woodpeckerci/plugin-docker-buildx - settings: - registry: ${CI_REGISTRY} - repo: ${CI_REGISTRY_IMAGE} - username: - from_secret: registry_user - password: - from_secret: registry_password - dockerfile: Dockerfile - platforms: linux/amd64 - tags: - - ${CI_COMMIT_SHA} - - stable - when: - - branch: [main, master] - - deploy_pull_on_host: + deploy_no_docker: image: appleboy/drone-ssh settings: host: - from_secret: deploy_host + from_secret: forgejo_host_ssh username: - from_secret: deploy_user + from_secret: forgejo_user_ssh key: - from_secret: deploy_ssh_key + from_secret: forgejo_key_ssh port: - from_secret: deploy_port + from_secret: forgejo_port_ssh script: - - docker login ${CI_REGISTRY} -u ${REGISTRY_USER} -p ${REGISTRY_PASSWORD} - - docker pull ${CI_REGISTRY_IMAGE}:stable - - docker rm -f max-support-bot || true - - docker run -d --name max-support-bot --restart unless-stopped -p 8000:8000 --env-file /opt/max-support/.env -v /opt/max-support/data:/app/data ${CI_REGISTRY_IMAGE}:stable - - curl -fsS http://127.0.0.1:8000/health - environment: - REGISTRY_USER: - from_secret: registry_user - REGISTRY_PASSWORD: - from_secret: registry_password + - cd /home/caps/intra_max_bot + - git pull --ff-only + - if [ ! -d .venv ]; then python3 -m venv .venv; fi + - . .venv/bin/activate + - pip install --no-cache-dir -r requirements.txt + - sudo systemctl restart max-support + - set -a + - . /home/caps/intra_max_bot/.env + - set +a + - curl -fsS http://127.0.0.1:${APP_PORT}/health when: - branch: [main, master] - diff --git a/DEPLOY_VARIANT_A.md b/DEPLOY_VARIANT_A.md new file mode 100644 index 0000000..aa7744d --- /dev/null +++ b/DEPLOY_VARIANT_A.md @@ -0,0 +1,130 @@ +# Deploy Guide (Variant A: No Docker Runtime) + +This guide is for deployment from source code on the server: +- `git pull` after each push +- restart `systemd` service +- no Docker runtime + +## 0) What is already prepared in this repo + +- CI pipeline for Variant A: [`.woodpecker.yml`](.woodpecker.yml) +- Env template with host/port/public IP/domain vars: [`.env.example`](.env.example) +- App runner that uses `APP_HOST`/`APP_PORT`: [`app/run.py`](app/run.py) +- `systemd` template: [`deploy/systemd/max-support.service.template`](deploy/systemd/max-support.service.template) +- `sudoers` template: [`deploy/sudoers/max-support-restart.template`](deploy/sudoers/max-support-restart.template) +- One-shot setup script: [`deploy/scripts/setup_variant_a.sh`](deploy/scripts/setup_variant_a.sh) + +## 1) Create repository and connect CI + +1. Create repository in GitHub/GitLab. +2. Push current project to `main`. +3. Connect repository to Woodpecker. + +## 2) Prepare server + +Example for Ubuntu/Debian: + +```bash +sudo apt update +sudo apt install -y git curl python3 python3-venv python3-pip +sudo mkdir -p /opt/max-support +sudo chown -R $USER:$USER /opt/max-support +git clone /opt/max-support +cd /opt/max-support +``` + +## 3) Create server `.env` + +Create `/opt/max-support/.env`: + +```env +APP_ENV=prod +APP_HOST=0.0.0.0 +APP_PORT=8000 + +# Public endpoint metadata (for ops/documentation) +APP_PUBLIC_IP=203.0.113.10 +APP_PUBLIC_DOMAIN=bot.example.com +APP_BASE_URL=https://bot.example.com + +DB_PATH=/opt/max-support/data/app.db +SESSION_SECRET=CHANGE_ME_LONG_RANDOM_STRING +WEB_ADMIN_LOGIN=admin +WEB_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD + +MAX_BOT_TOKEN=YOUR_MAX_BOT_TOKEN +MAX_BOT_API_URL=https://botapi.max.ru + +# Recommended first start: polling mode +MAX_USE_WEBHOOK=false +MAX_WEBHOOK_URL= +MAX_WEBHOOK_SECRET= + +CONTEXT_BACKEND=memory +REDIS_URL=redis://127.0.0.1:6379/0 + +CHANNEL_LOCAL_ENABLED=true +CHANNEL_EXTERNAL_ENABLED=true +EXTERNAL_API_CREATE_URL_WITH_KEY=https://external.example/api/create?key=YOUR_KEY + +OPERATOR_CHAT_IDS=123456,987654 +REQUEST_CATEGORIES=Общая консультация,Техническая проблема,Оплата,Другое +``` + +## 4) Run one-shot setup on server + +```bash +cd /opt/max-support +chmod +x deploy/scripts/setup_variant_a.sh +APP_DIR=/opt/max-support APP_USER=$USER SERVICE_NAME=max-support ./deploy/scripts/setup_variant_a.sh +``` + +What this does: +1. Creates/updates `.venv` +2. Installs dependencies +3. Installs `systemd` unit +4. Installs `sudoers` rule for restart +5. Enables and starts `max-support` +6. Runs healthcheck on `127.0.0.1:${APP_PORT}` + +## 5) Woodpecker secrets (required) + +Create these repository secrets: + +1. `deploy_host` - server host (IP or domain), e.g. `203.0.113.10` +2. `deploy_user` - SSH user on server, e.g. `deploy` +3. `deploy_port` - SSH port, usually `22` +4. `deploy_ssh_key` - private SSH key (PEM, multiline) + +No registry secrets are needed in Variant A. + +## 6) SSH and sudo requirements + +`deploy_user` must be able to: +1. `cd /opt/max-support && git pull --ff-only` +2. `sudo systemctl restart max-support` + +The setup script already installs sudoers rule in `/etc/sudoers.d/max-support-restart`: + +```text + ALL=(ALL) NOPASSWD:/bin/systemctl restart max-support,/bin/systemctl status max-support +``` + +## 7) CI/CD flow now + +On each push to `main/master`: +1. Tests run +2. CI connects via SSH +3. Executes `git pull --ff-only` +4. Updates dependencies in `.venv` +5. Restarts `max-support` service +6. Checks `/health` using `APP_PORT` from `/opt/max-support/.env` + +## 8) Useful commands on server + +```bash +sudo systemctl status max-support +sudo journalctl -u max-support -n 200 --no-pager +sudo systemctl restart max-support +curl -fsS http://127.0.0.1:8000/health +``` diff --git a/app/run.py b/app/run.py new file mode 100644 index 0000000..ce62cd5 --- /dev/null +++ b/app/run.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import uvicorn + +from app.config import get_settings +from app.main import create_app + + +def main() -> None: + settings = get_settings() + app = create_app(settings) + uvicorn.run(app, host=settings.app_host, port=settings.app_port) + + +if __name__ == "__main__": + main() + diff --git a/deploy/scripts/setup_variant_a.sh b/deploy/scripts/setup_variant_a.sh new file mode 100644 index 0000000..6e01498 --- /dev/null +++ b/deploy/scripts/setup_variant_a.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# One-shot setup script for Variant A (no Docker runtime). +# It prepares venv, installs dependencies, installs systemd unit, +# installs sudoers rule for CI restart, and starts the service. + +APP_DIR="${APP_DIR:-/opt/max-support}" +APP_USER="${APP_USER:-$USER}" +SERVICE_NAME="${SERVICE_NAME:-max-support}" +PYTHON_BIN="${PYTHON_BIN:-python3}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +SYSTEMD_TEMPLATE="${PROJECT_ROOT}/deploy/systemd/max-support.service.template" +SUDOERS_TEMPLATE="${PROJECT_ROOT}/deploy/sudoers/max-support-restart.template" + +if [[ ! -d "${APP_DIR}" ]]; then + echo "ERROR: APP_DIR '${APP_DIR}' does not exist." + exit 1 +fi + +if [[ ! -f "${APP_DIR}/requirements.txt" ]]; then + echo "ERROR: '${APP_DIR}/requirements.txt' not found. Clone repo first." + exit 1 +fi + +if [[ ! -f "${APP_DIR}/.env" ]]; then + echo "ERROR: '${APP_DIR}/.env' not found. Create .env first." + exit 1 +fi + +echo "==> Ensuring ownership for ${APP_DIR}" +sudo chown -R "${APP_USER}:${APP_USER}" "${APP_DIR}" + +echo "==> Creating/updating venv" +if [[ ! -d "${APP_DIR}/.venv" ]]; then + sudo -u "${APP_USER}" "${PYTHON_BIN}" -m venv "${APP_DIR}/.venv" +fi + +echo "==> Installing Python dependencies" +sudo -u "${APP_USER}" bash -lc "cd '${APP_DIR}' && . .venv/bin/activate && pip install --no-cache-dir -r requirements.txt" + +echo "==> Installing systemd unit" +SERVICE_RENDERED="$(sed "s|__APP_USER__|${APP_USER}|g; s|__APP_DIR__|${APP_DIR}|g" "${SYSTEMD_TEMPLATE}")" +echo "${SERVICE_RENDERED}" | sudo tee "/etc/systemd/system/${SERVICE_NAME}.service" > /dev/null + +echo "==> Installing sudoers rule for CI restart" +SUDOERS_RENDERED="$(sed "s|__APP_USER__|${APP_USER}|g" "${SUDOERS_TEMPLATE}")" +echo "${SUDOERS_RENDERED}" | sudo tee "/etc/sudoers.d/${SERVICE_NAME}-restart" > /dev/null +sudo chmod 0440 "/etc/sudoers.d/${SERVICE_NAME}-restart" +sudo visudo -cf "/etc/sudoers.d/${SERVICE_NAME}-restart" + +echo "==> Reloading and starting service" +sudo systemctl daemon-reload +sudo systemctl enable "${SERVICE_NAME}" +sudo systemctl restart "${SERVICE_NAME}" +sudo systemctl --no-pager status "${SERVICE_NAME}" || true + +echo "==> Health check" +set -a +# shellcheck disable=SC1090 +source "${APP_DIR}/.env" +set +a +curl -fsS "http://127.0.0.1:${APP_PORT}/health" || { + echo "ERROR: health check failed." + exit 1 +} + +echo "Setup completed successfully." + diff --git a/deploy/sudoers/max-support-restart.template b/deploy/sudoers/max-support-restart.template new file mode 100644 index 0000000..1228942 --- /dev/null +++ b/deploy/sudoers/max-support-restart.template @@ -0,0 +1,2 @@ +__APP_USER__ ALL=(ALL) NOPASSWD:/bin/systemctl restart max-support,/bin/systemctl status max-support + diff --git a/deploy/systemd/max-support.service.template b/deploy/systemd/max-support.service.template new file mode 100644 index 0000000..fab392b --- /dev/null +++ b/deploy/systemd/max-support.service.template @@ -0,0 +1,18 @@ +[Unit] +Description=MAX Support Bot (FastAPI + MAX bot runtime) +After=network.target + +[Service] +Type=simple +User=__APP_USER__ +WorkingDirectory=__APP_DIR__ +EnvironmentFile=__APP_DIR__/.env +ExecStart=__APP_DIR__/.venv/bin/python -m app.run +Restart=always +RestartSec=3 +KillSignal=SIGINT +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target +