docs(runbook): tg-digest VDS deploy runbook + stack artifacts (task:1311)
Ранбук первого деплоя паттерна sched+worker (модель для yt-digest): стек tg-digest (воркер internal, mem_limit 256m), runtime-регистрация задачи POST /tasks, env-контракт, smoke/verify/rollback, gotchas (сессия Telethon, TZ UTC, timeoutMs). Артефакты: compose source-of-truth, Dockerfile + http_worker.py (черновики для коммита в victor/tg-digest). task:1311 разблокирован (1305 done) -> ready.
This commit is contained in:
52
host-stacks/vds-kzntsv/tg-digest.compose.yml
Normal file
52
host-stacks/vds-kzntsv/tg-digest.compose.yml
Normal file
@@ -0,0 +1,52 @@
|
||||
# tg-digest — HTTP-воркер дневного дайджеста телеграм-каналов.
|
||||
# sched cron 0 5 * * * (tz UTC = 08:00 MSK), runner http, simple mode.
|
||||
# Internal: сеть proxy, БЕЗ traefik-labels — наружу не публикуется (см. ранбук tg-digest-vds-deploy-runbook).
|
||||
# Deploy: Portainer-managed (см. portainer-stack-management-vds). Env через Portainer (не env_file).
|
||||
# Image: registry.kzntsv.site/tg-digest-worker:<tag> (python:3.13-alpine, telethon+requests, из main victor/tg-digest).
|
||||
|
||||
services:
|
||||
tg-digest:
|
||||
image: registry.kzntsv.site/tg-digest-worker:<tag>
|
||||
container_name: tg-digest
|
||||
restart: unless-stopped
|
||||
mem_limit: 256m
|
||||
networks:
|
||||
- proxy
|
||||
environment:
|
||||
# MTProto (оператор; pass telegram/api-id + api-hash)
|
||||
TG_API_ID: ${TG_API_ID}
|
||||
TG_API_HASH: ${TG_API_HASH}
|
||||
TG_SESSION: /data/session.session
|
||||
TG_PHONE: ${TG_PHONE}
|
||||
TG_CACHE: /data/tg-cache
|
||||
TG_WINDOW_HOURS: ${TG_WINDOW_HOURS:-24}
|
||||
# Доставка (pass telegram/full-env)
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID}
|
||||
# Стадия-2 LLM (deepseek; ключ — у оператора)
|
||||
LLM_API_KEY: ${LLM_API_KEY}
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-https://api.deepseek.com}
|
||||
LLM_MODEL: ${LLM_MODEL:-deepseek-chat}
|
||||
# Ингест raw в mappa (pass mappa/full-env)
|
||||
MAPPA_URL: ${MAPPA_URL:-https://mappa.vds.kzntsv.site}
|
||||
MAPPA_API_TOKEN: ${MAPPA_API_TOKEN}
|
||||
MAPPA_PROJECT: ${MAPPA_PROJECT:-tg-digest}
|
||||
# Auth sched → воркер (pass sched/tg-digest-api-key)
|
||||
WORKER_API_KEY: ${WORKER_API_KEY}
|
||||
# 0 — dry-run smoke без отправки в TG
|
||||
TG_SEND: ${TG_SEND:-1}
|
||||
volumes:
|
||||
- tg-digest-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8080/healthz')"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
volumes:
|
||||
tg-digest-data:
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
21
host-stacks/vds-kzntsv/tg-digest/Dockerfile
Normal file
21
host-stacks/vds-kzntsv/tg-digest/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
# tg-digest worker — python (telethon + requests) + HTTP-обёртка для sched http-runner.
|
||||
# Файл коммитится в КОРЕНЬ репо victor/tg-digest (рядом http_worker.py).
|
||||
# Build (из чекаута victor/tg-digest, main с d3aefb2):
|
||||
# docker build -t registry.kzntsv.site/tg-digest-worker:<tag> .
|
||||
# Env-контракт — см. ранбук tg-digest-vds-deploy-runbook (все секреты env, pass в контейнере нет).
|
||||
FROM python:3.13-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY src/ src/
|
||||
COPY http_worker.py ./
|
||||
|
||||
RUN mkdir -p /data
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
EXPOSE 8080
|
||||
VOLUME ["/data"]
|
||||
|
||||
CMD ["python", "http_worker.py"]
|
||||
63
host-stacks/vds-kzntsv/tg-digest/http_worker.py
Normal file
63
host-stacks/vds-kzntsv/tg-digest/http_worker.py
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
"""tg-digest HTTP-воркер для sched (http runner, simple mode).
|
||||
|
||||
GET /healthz -> 200 {"ok": true} (healthcheck стека)
|
||||
POST /run -> проверка x-sched-api-key (WORKER_API_KEY); запуск
|
||||
`python -m src.worker`; 200 {ok, runId, summary} | 5xx {error}.
|
||||
|
||||
Simple mode: sched шлёт POST и ждёт ответ до config.timeoutMs; любой 2xx = succeeded.
|
||||
runId приходит в заголовке x-sched-run-id (пишется в ответ, в лог).
|
||||
Заголовки x-sched-api-key от sched — auth per-task (task config.auth.apiKey).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
WORKER_API_KEY = os.environ.get("WORKER_API_KEY", "")
|
||||
WORKER_CMD = [sys.executable, "-m", "src.worker"]
|
||||
WORKER_TIMEOUT = float(os.environ.get("WORKER_TIMEOUT_S") or 3600)
|
||||
PORT = int(os.environ.get("PORT") or 8080)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args): # тишина в stdout (логи — по runId)
|
||||
pass
|
||||
|
||||
def _send(self, code, obj):
|
||||
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/healthz":
|
||||
self._send(200, {"ok": True})
|
||||
else:
|
||||
self._send(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
if self.path != "/run":
|
||||
return self._send(404, {"error": "not found"})
|
||||
if WORKER_API_KEY and self.headers.get("x-sched-api-key") != WORKER_API_KEY:
|
||||
return self._send(401, {"error": "unauthorized"})
|
||||
run_id = self.headers.get("x-sched-run-id", "?")
|
||||
try:
|
||||
r = subprocess.run(WORKER_CMD, capture_output=True, text=True,
|
||||
env=os.environ, timeout=WORKER_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
return self._send(504, {"error": "worker timeout", "runId": run_id})
|
||||
if r.returncode != 0:
|
||||
return self._send(500, {"error": (r.stderr or r.stdout)[-500:], "runId": run_id})
|
||||
try:
|
||||
summary = json.loads(r.stdout)
|
||||
except ValueError:
|
||||
summary = {"stdout": r.stdout[-500:]}
|
||||
self._send(200, {"ok": True, "runId": run_id, "summary": summary})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
|
||||
Reference in New Issue
Block a user