|
| 1 | +# Deploy — aaPanel (VPS com painel) |
| 2 | + |
| 3 | +[← Índice](README.md) |
| 4 | + |
| 5 | +Runbook completo para publicar o KernelBot num servidor com **aaPanel** (Linux). Cobre dois caminhos: |
| 6 | + |
| 7 | +- **Caminho A — Python nativo** (recomendado): venv + Uvicorn gerido pelo aaPanel, Nginx como proxy reverso. |
| 8 | +- **Caminho B — Docker**: usa o `Dockerfile`/`docker-compose.yml` do repo via gestor Docker do aaPanel. |
| 9 | + |
| 10 | +Variáveis detalhadas: [12-configuracao.md](12-configuracao.md). Deploy Railway/Docker genérico: [20-deploy-railway.md](20-deploy-railway.md). |
| 11 | + |
| 12 | +> **Analogia Laravel:** o fluxo é o mesmo de publicar um app Laravel num VPS — Nginx na frente, um processo de aplicação atrás (aqui Uvicorn no lugar do PHP-FPM), `.env` com credenciais, MySQL do painel e um "seeder" (ingest) para popular a base. |
| 13 | +
|
| 14 | +--- |
| 15 | + |
| 16 | +## 1. Pré-requisitos |
| 17 | + |
| 18 | +| Item | Detalhe | |
| 19 | +|------|---------| |
| 20 | +| VPS Linux | Ubuntu 22.04+ / Debian 12 recomendado, mínimo 1 GB RAM (índice BM25 vive em memória) | |
| 21 | +| aaPanel instalado | `https://www.aapanel.com/new/download.html` — script oficial de instalação | |
| 22 | +| Domínio | Apontado (registo A) para o IP do VPS | |
| 23 | +| Python 3.11+ | Instalado via App Store do aaPanel (Python Manager) ou pacote do sistema | |
| 24 | +| MySQL 5.7+/8.0 | O do próprio aaPanel serve | |
| 25 | +| Chave LLM | **OpenRouter recomendado em servidor** (`ACL_LLM_PROVIDER=openrouter`) — o provider `cursor` exige runtime local do Cursor, raramente adequado em VPS | |
| 26 | +| Conteúdo | `jsons/` do repo (aulas espelhadas) e, para o catálogo, `lessons.json` + `search-index.json` do ISS | |
| 27 | + |
| 28 | +Portas: o aaPanel usa 80/443 (Nginx) e a porta do painel (padrão 7800/8888 conforme instalação). A app escuta **8001 apenas em localhost** — não precisa abrir 8001 no firewall. |
| 29 | + |
| 30 | +--- |
| 31 | + |
| 32 | +## 2. MySQL — criar base e schema |
| 33 | + |
| 34 | +1. No aaPanel: **Databases → Add database**. |
| 35 | + - Nome: `kernelbot` (ou outro — vai para `DB_NAME`) |
| 36 | + - Utilizador/senha: anote — vão para `DB_USER` / `DB_PASSWORD` |
| 37 | + - Access permission: `localhost` (a app roda no mesmo servidor) |
| 38 | +2. Abra o **phpMyAdmin** (ou terminal `mysql`) e crie a tabela `knowledge` na base criada: |
| 39 | + |
| 40 | +```sql |
| 41 | +CREATE TABLE IF NOT EXISTS knowledge ( |
| 42 | + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, |
| 43 | + discipline VARCHAR(128) NOT NULL, |
| 44 | + slug VARCHAR(255) NOT NULL, |
| 45 | + title VARCHAR(512) NOT NULL, |
| 46 | + `order` INT NOT NULL DEFAULT 0, |
| 47 | + content LONGTEXT, |
| 48 | + active TINYINT(1) NOT NULL DEFAULT 1, |
| 49 | + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| 50 | + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
| 51 | + PRIMARY KEY (id), |
| 52 | + UNIQUE KEY uk_discipline_slug (discipline, slug), |
| 53 | + KEY idx_active (active) |
| 54 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
| 55 | +``` |
| 56 | + |
| 57 | +> É o mesmo schema de [`docker/init-knowledge.sql`](../../docker/init-knowledge.sql) (lá a base chama-se `kernelbot_staging`; em produção use o nome que criou). Garanta `utf8mb4` — conteúdo das aulas tem acentuação e símbolos. |
| 58 | +
|
| 59 | +--- |
| 60 | + |
| 61 | +## 3. Caminho A — Python nativo (recomendado) |
| 62 | + |
| 63 | +### 3.1 Obter o código |
| 64 | + |
| 65 | +No terminal do aaPanel (**Terminal** no menu, ou SSH): |
| 66 | + |
| 67 | +```bash |
| 68 | +cd /www/wwwroot |
| 69 | +git clone https://github.com/GaabDevWeb/KernelBot.git kernelbot |
| 70 | +cd kernelbot |
| 71 | +``` |
| 72 | + |
| 73 | +> `/www/wwwroot` é a raiz de sites do aaPanel (equivalente ao `/var/www` clássico). Usar git facilita atualizações (`git pull`) e rollback (`git checkout <tag>`). |
| 74 | +
|
| 75 | +### 3.2 Ambiente virtual e dependências |
| 76 | + |
| 77 | +```bash |
| 78 | +cd /www/wwwroot/kernelbot |
| 79 | +python3 -m venv .venv |
| 80 | +.venv/bin/pip install --upgrade pip |
| 81 | +.venv/bin/pip install -r requirements-prod.txt |
| 82 | +``` |
| 83 | + |
| 84 | +Use **`requirements-prod.txt`** (sem pytest/playwright/watchdog — mais leve, igual à imagem Docker). |
| 85 | + |
| 86 | +**Frontend não precisa de build**: o CSS Tailwind compilado (`frontend/assets/css/output.css`) já está versionado no repo. Node.js só é necessário se for alterar estilos. |
| 87 | + |
| 88 | +### 3.3 Configurar `.env` |
| 89 | + |
| 90 | +```bash |
| 91 | +cp .env.example .env |
| 92 | +nano .env |
| 93 | +``` |
| 94 | + |
| 95 | +Valores obrigatórios de produção (mesma tabela do [README](../../README.md#deploy-e-produção)): |
| 96 | + |
| 97 | +```bash |
| 98 | +# Ambiente |
| 99 | +KERNELBOT_ENV=production |
| 100 | +KERNELBOT_FORCE_HSTS=true # atrás do HTTPS do Nginx |
| 101 | + |
| 102 | +# LLM |
| 103 | +ACL_LLM_PROVIDER=openrouter |
| 104 | +OPENROUTER_API_KEY=sk-or-... |
| 105 | + |
| 106 | +# MySQL (criado no passo 2 — localhost, mesmo servidor) |
| 107 | +DB_HOST=127.0.0.1 |
| 108 | +DB_PORT=3306 |
| 109 | +DB_NAME=kernelbot |
| 110 | +DB_USER=kernelbot |
| 111 | +DB_PASSWORD=... |
| 112 | + |
| 113 | +# Segurança operacional (obrigatório em produção) |
| 114 | +ACL_RELOAD_BEARER_TOKEN=um-token-longo-e-aleatorio # ex.: openssl rand -hex 32 |
| 115 | + |
| 116 | +# Catálogo ISS |
| 117 | +ACL_CATALOG_ENABLED=true |
| 118 | +ACL_CATALOG_JSON_DIR=/www/wwwroot/kernelbot-content/ISS/content |
| 119 | +``` |
| 120 | + |
| 121 | +Para o catálogo, copie `lessons.json` e `search-index.json` do ISS para o diretório apontado por `ACL_CATALOG_JSON_DIR` (crie-o se não existir). Sem catálogo, deixe `ACL_CATALOG_ENABLED=false` — `/api/curriculum` responderá 503 e o frontend esconde o mapa curricular (comportamento esperado, sem erro). |
| 122 | + |
| 123 | +### 3.4 Ingestão do conteúdo (popular a tabela) |
| 124 | + |
| 125 | +> **Analogia Laravel:** este passo é o `php artisan db:seed` do projeto — sem ele o chat responde "contexto insuficiente" para tudo. |
| 126 | +
|
| 127 | +Com o `.env` já apontando para o MySQL de produção: |
| 128 | + |
| 129 | +```bash |
| 130 | +cd /www/wwwroot/kernelbot |
| 131 | +./bin/ingest-jsons.sh |
| 132 | +# ou diretamente: .venv/bin/python -m engine.jsons_ingest |
| 133 | +``` |
| 134 | + |
| 135 | +Isso faz UPSERT de `jsons/<disciplina>/*.json` na tabela `knowledge`. Confirme: |
| 136 | + |
| 137 | +```bash |
| 138 | +mysql -u kernelbot -p kernelbot -e "SELECT discipline, COUNT(*) FROM knowledge WHERE active=1 GROUP BY discipline;" |
| 139 | +``` |
| 140 | + |
| 141 | +### 3.5 Teste manual antes de daemonizar |
| 142 | + |
| 143 | +```bash |
| 144 | +cd /www/wwwroot/kernelbot |
| 145 | +.venv/bin/uvicorn main:app --host 127.0.0.1 --port 8001 |
| 146 | +# noutro terminal: |
| 147 | +curl -sS http://127.0.0.1:8001/health |
| 148 | +``` |
| 149 | + |
| 150 | +Se `/health` responder, pare (`Ctrl+C`) e passe ao processo permanente. |
| 151 | + |
| 152 | +**Importante — 1 worker apenas.** Não use `--workers N`: o índice BM25 e o pin de sessão (`PinnedSessionStore`) vivem em memória do processo; múltiplos workers multiplicam RAM e quebram a consistência do pin entre requisições. |
| 153 | + |
| 154 | +### 3.6 Manter o processo vivo (Supervisor do aaPanel) |
| 155 | + |
| 156 | +O aaPanel tem o plugin **Supervisor Manager** (App Store → Supervisor → Install). É o equivalente ao Supervisor que mantém `queue:work` vivo no Laravel. |
| 157 | + |
| 158 | +**App Store → Supervisor Manager → Add Daemon:** |
| 159 | + |
| 160 | +| Campo | Valor | |
| 161 | +|-------|-------| |
| 162 | +| Name | `kernelbot` | |
| 163 | +| Run user | `www` | |
| 164 | +| Run dir | `/www/wwwroot/kernelbot` | |
| 165 | +| Start command | `/www/wwwroot/kernelbot/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 8001` | |
| 166 | +| Processes | `1` | |
| 167 | + |
| 168 | +Antes de iniciar, dê permissão ao utilizador `www`: |
| 169 | + |
| 170 | +```bash |
| 171 | +chown -R www:www /www/wwwroot/kernelbot |
| 172 | +``` |
| 173 | + |
| 174 | +Alternativa sem plugin — **systemd** (via SSH): |
| 175 | + |
| 176 | +```bash |
| 177 | +cat > /etc/systemd/system/kernelbot.service <<'EOF' |
| 178 | +[Unit] |
| 179 | +Description=KernelBot (FastAPI/Uvicorn) |
| 180 | +After=network.target mysql.service |
| 181 | +
|
| 182 | +[Service] |
| 183 | +User=www |
| 184 | +Group=www |
| 185 | +WorkingDirectory=/www/wwwroot/kernelbot |
| 186 | +ExecStart=/www/wwwroot/kernelbot/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 8001 |
| 187 | +Restart=always |
| 188 | +RestartSec=5 |
| 189 | +
|
| 190 | +[Install] |
| 191 | +WantedBy=multi-user.target |
| 192 | +EOF |
| 193 | + |
| 194 | +systemctl daemon-reload |
| 195 | +systemctl enable --now kernelbot |
| 196 | +systemctl status kernelbot |
| 197 | +``` |
| 198 | + |
| 199 | +> Nota: alguns aaPanel trazem "Python Manager"/"Python Project" na App Store que cria venv + daemon + site numa tela só. Se a sua versão tiver, pode usá-lo no lugar dos passos 3.2/3.6 — aponte o startup para `uvicorn main:app --host 127.0.0.1 --port 8001` e confira que o venv usa Python 3.11+. |
| 200 | +
|
| 201 | +### 3.7 Site + proxy reverso no Nginx |
| 202 | + |
| 203 | +1. **Website → Add site**: |
| 204 | + - Domain: `kernelbot.seudominio.com` |
| 205 | + - PHP version: **Pure static** (não é PHP) |
| 206 | +2. Entre no site criado → **Reverse Proxy → Add reverse proxy**: |
| 207 | + - Target URL: `http://127.0.0.1:8001` |
| 208 | + - Send domain: `$host` |
| 209 | +3. **Edite a config do proxy** (Website → site → Config, ou o ficheiro do proxy em `/www/server/panel/vhost/nginx/proxy/<site>/`). O bloco `location /` precisa de suporte a **SSE** — sem `proxy_buffering off` o streaming do chat chega "de uma vez só" no fim, em vez de token a token: |
| 210 | + |
| 211 | +```nginx |
| 212 | +location / { |
| 213 | + proxy_pass http://127.0.0.1:8001; |
| 214 | + proxy_http_version 1.1; |
| 215 | + proxy_set_header Host $host; |
| 216 | + proxy_set_header X-Real-IP $remote_addr; |
| 217 | + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |
| 218 | + proxy_set_header X-Forwarded-Proto $scheme; |
| 219 | +
|
| 220 | + # SSE (POST /chat com stream) — essencial: |
| 221 | + proxy_buffering off; |
| 222 | + proxy_cache off; |
| 223 | + proxy_read_timeout 3600s; |
| 224 | + proxy_set_header Connection ""; |
| 225 | +} |
| 226 | +``` |
| 227 | + |
| 228 | +4. Desative cache do proxy se o template do aaPanel tiver criado bloco de cache (`proxy_cache_valid` etc.) — remove ou comenta. |
| 229 | +5. Recarregue o Nginx (o painel faz isso ao salvar). |
| 230 | + |
| 231 | +> O `X-Forwarded-For` importa: o rate limit de `POST /chat` (30 req/min) é por IP; sem o header, todos os utilizadores partilhariam o IP do proxy. |
| 232 | +
|
| 233 | +### 3.8 HTTPS (Let's Encrypt) |
| 234 | + |
| 235 | +Website → site → **SSL → Let's Encrypt** → selecionar domínio → Apply. Ative **Force HTTPS**. Com o certificado ativo, mantenha `KERNELBOT_FORCE_HSTS=true` no `.env`. |
| 236 | + |
| 237 | +--- |
| 238 | + |
| 239 | +## 4. Caminho B — Docker no aaPanel |
| 240 | + |
| 241 | +Se preferir container (mesma imagem do deploy Railway): |
| 242 | + |
| 243 | +1. App Store → **Docker** (instala Docker + Compose e a UI de gestão). |
| 244 | +2. Via terminal: |
| 245 | + |
| 246 | +```bash |
| 247 | +cd /www/wwwroot |
| 248 | +git clone https://github.com/GaabDevWeb/KernelBot.git kernelbot |
| 249 | +cd kernelbot |
| 250 | +cp .env.docker.example .env |
| 251 | +nano .env # MySQL + LLM + ACL_RELOAD_BEARER_TOKEN (ver passo 3.3) |
| 252 | +docker compose up -d --build |
| 253 | +curl -sS http://127.0.0.1:8001/health |
| 254 | +``` |
| 255 | + |
| 256 | +3. MySQL: o container acessa o MySQL do aaPanel via `DB_HOST=host.docker.internal` (o compose já define `extra_hosts`). No aaPanel, o utilizador MySQL precisa aceitar conexões desse host — em **Databases**, mude a permissão do utilizador de `localhost` para `%` (ou para o IP da bridge Docker, mais restrito). |
| 257 | +4. Ingestão: rode o ingest a partir do host (passo 3.4) ou de qualquer máquina com acesso ao MySQL. |
| 258 | +5. Proxy reverso + SSL: igual aos passos 3.7 e 3.8 (o alvo continua `http://127.0.0.1:8001`, porta publicada pelo compose — configurável com `KERNELBOT_PUBLISH_PORT`). |
| 259 | + |
| 260 | +--- |
| 261 | + |
| 262 | +## 5. Verificação pós-deploy |
| 263 | + |
| 264 | +```bash |
| 265 | +# Saúde básica |
| 266 | +curl -sS https://kernelbot.seudominio.com/health |
| 267 | + |
| 268 | +# Config pública — deve trazer "catalog_enabled": true (se catálogo ativo) |
| 269 | +curl -sS https://kernelbot.seudominio.com/api/public-config |
| 270 | + |
| 271 | +# Catálogo — 200 em produção com ACL_CATALOG_ENABLED=true |
| 272 | +curl -sS -o /dev/null -w "%{http_code}\n" https://kernelbot.seudominio.com/api/curriculum |
| 273 | + |
| 274 | +# Drift catálogo ↔ índice (usa o token do .env) |
| 275 | +curl -sS -H "Authorization: Bearer SEU_TOKEN" https://kernelbot.seudominio.com/health/catalog |
| 276 | +``` |
| 277 | + |
| 278 | +Teste de SSE: abra o site, envie uma pergunta no chat e confirme que a resposta chega **progressivamente** (streaming). Se vier tudo de uma vez, revise `proxy_buffering off` (passo 3.7). |
| 279 | + |
| 280 | +Smoke UI opcional a partir da sua máquina local: |
| 281 | + |
| 282 | +```bash |
| 283 | +SMOKE_BASE_URL=https://kernelbot.seudominio.com python3 bin/validate-frontend.py |
| 284 | +``` |
| 285 | + |
| 286 | +--- |
| 287 | + |
| 288 | +## 6. Operação |
| 289 | + |
| 290 | +| Ação | Como | |
| 291 | +|------|------| |
| 292 | +| Ver logs | Supervisor Manager → kernelbot → Log; ou `journalctl -u kernelbot -f` (systemd) | |
| 293 | +| Reiniciar app | Supervisor/systemd restart — necessário após editar `.env` | |
| 294 | +| Atualizar código | `cd /www/wwwroot/kernelbot && git pull && .venv/bin/pip install -r requirements-prod.txt` → restart | |
| 295 | +| Recarregar índice sem restart | `POST /chat` com `{"message": "/reload"}` + header `Authorization: Bearer SEU_TOKEN` (após novo ingest) | |
| 296 | +| Novo conteúdo | Atualizar `jsons/` (git pull ou pipeline ISS) → `./bin/ingest-jsons.sh` → `/reload` | |
| 297 | +| Logs em JSON | `ACL_LOG_FORMAT=json` no `.env` (uma linha por evento — bom para grep/agregadores) | |
| 298 | + |
| 299 | +### Rollback |
| 300 | + |
| 301 | +1. `cd /www/wwwroot/kernelbot && git log --oneline` → `git checkout <commit-anterior>` (Docker: rebuild da tag anterior). |
| 302 | +2. Restart do daemon e conferir `GET /health` + `/api/public-config`. |
| 303 | +3. Se o problema for de dados: re-correr ingest + `/reload`. |
| 304 | + |
| 305 | +--- |
| 306 | + |
| 307 | +## 7. Troubleshooting |
| 308 | + |
| 309 | +| Sintoma | Causa provável | Correção | |
| 310 | +|---------|----------------|----------| |
| 311 | +| `502 Bad Gateway` | Uvicorn parado ou porta errada | Ver status no Supervisor/systemd; conferir `127.0.0.1:8001` | |
| 312 | +| Boot lento / healthcheck falha no arranque | Índice BM25 constrói no boot (pode levar dezenas de segundos com muitas aulas) | Aumentar `start_period`/tolerância do healthcheck; não é erro | |
| 313 | +| Chat responde de uma vez, sem streaming | Buffering do Nginx | `proxy_buffering off` no bloco do proxy (passo 3.7) | |
| 314 | +| Todas as respostas: "contexto insuficiente" | Tabela `knowledge` vazia ou `DB_*` errado | Rodar ingest (3.4); conferir credenciais e `SELECT COUNT(*)` | |
| 315 | +| `GET /api/curriculum` → 503 | `ACL_CATALOG_ENABLED=false` ou `ACL_CATALOG_JSON_DIR` inválido | Conferir `.env` e existência de `lessons.json`/`search-index.json`; restart | |
| 316 | +| `GET /health/catalog` → 503 `reload token not configured` | `ACL_RELOAD_BEARER_TOKEN` vazio | Definir token no `.env`; obrigatório em produção | |
| 317 | +| HTTP 429 no `/chat` | Rate limit: 30 req/min por IP (fixo em `api/routes.py`) | Esperado; se todos os IPs parecem um só, conferir `X-Forwarded-For` no Nginx | |
| 318 | +| Timeout ao conectar MySQL | `DB_HOST=127.0.0.0` (typo comum) ou permissão do utilizador | Usar `127.0.0.1`; em Docker, permissão `%`/bridge para o utilizador | |
| 319 | +| `Permission denied` no arranque | Ficheiros pertencem a root | `chown -R www:www /www/wwwroot/kernelbot` | |
| 320 | +| Erro TLS/`cryptography` no MySQL | Faltou dependência | `cryptography` está no `requirements-prod.txt` — reinstalar deps no venv | |
| 321 | + |
| 322 | +--- |
| 323 | + |
| 324 | +## Ver também |
| 325 | + |
| 326 | +- [20-deploy-railway.md](20-deploy-railway.md) — Railway, Docker genérico, rollback |
| 327 | +- [12-configuracao.md](12-configuracao.md) — todas as variáveis `.env` |
| 328 | +- [04-dados-e-mysql.md](04-dados-e-mysql.md) — contrato da tabela `knowledge` |
| 329 | +- [10-integracao-iss-fase5b.md](10-integracao-iss-fase5b.md) — pipeline de conteúdo ISS |
| 330 | +- [14-seguranca-observabilidade.md](14-seguranca-observabilidade.md) — segurança e logs |
0 commit comments