init
This commit is contained in:
parent
e5e39ac045
commit
dc230e0b03
32
README.md
32
README.md
@ -1,2 +1,32 @@
|
||||
# k8s_log_bot
|
||||
###### Подготовка окружения
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
pip install python-telegram-bot==21.4
|
||||
```
|
||||
|
||||
В файле `run_bot.sh` установить нужную реализацию бота:
|
||||
`bot_local.py` - выполняет команды kubectl локально
|
||||
`bot_remote.py` - выполняяет команды на удалённом сервере, ходит по ssh
|
||||
|
||||
###### Заполнить алиасы:
|
||||
`contexts.json` - алиасы контекстов k8s: `kubectl config get-contexts`
|
||||
|
||||
###### Файл .env заполнить следующим образом:
|
||||
```bash
|
||||
TG_BOT_TOKEN=
|
||||
ALLOWED_CHATS= #вайтлист peer id чатов тг, заполнять через запятую без пробела
|
||||
|
||||
KUBECTL_BIN=/usr/local/bin/kubectl #указать корректный путь до бинарника kubectl на целевой машине
|
||||
CONTEXTS_FILE=./contexts.json
|
||||
|
||||
#Опционально, если используется bot_remote.py
|
||||
SSH_HOST=erot-adminbox
|
||||
SSH_USER=root
|
||||
SSH_PORT=22
|
||||
```
|
||||
|
||||
###### Запуск:
|
||||
`./run_bot.sh`
|
||||
|
||||
|
||||
240
bot_local.py
Executable file
240
bot_local.py
Executable file
@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Tuple, List
|
||||
|
||||
from telegram import Update, InputFile
|
||||
from telegram.ext import ApplicationBuilder, CommandHandler, ContextTypes
|
||||
|
||||
# ---------------------------
|
||||
# ЛОГИ
|
||||
# ---------------------------
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||
)
|
||||
log = logging.getLogger("logbot")
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# ENV ЗАГРУЗКА
|
||||
# ---------------------------
|
||||
def load_env():
|
||||
env_file = Path(".env")
|
||||
if not env_file.exists():
|
||||
log.error("Файл .env не найден")
|
||||
return
|
||||
|
||||
with env_file.open() as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, val = line.split("=", 1)
|
||||
os.environ[key.strip()] = val.strip()
|
||||
log.info(f"ENV: {key.strip()} загружено")
|
||||
|
||||
|
||||
load_env()
|
||||
|
||||
BOT_TOKEN = os.getenv("TG_BOT_TOKEN")
|
||||
ALLOWED_CHATS = {
|
||||
int(x.strip()) for x in os.getenv("ALLOWED_CHATS", "").split(",") if x.strip().isdigit()
|
||||
}
|
||||
KUBECTL = os.getenv("KUBECTL_BIN", "kubectl")
|
||||
CONTEXTS_FILE = os.getenv("CONTEXTS_FILE", "./contexts.json")
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Утилиты
|
||||
# ---------------------------
|
||||
def run_cmd(cmd: List[str]) -> Tuple[int, str, str]:
|
||||
log.info(f"RUN: {' '.join(cmd)}")
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
|
||||
|
||||
def load_contexts() -> Dict[str, str]:
|
||||
if not Path(CONTEXTS_FILE).exists():
|
||||
raise RuntimeError(f"contexts.json не найден: {CONTEXTS_FILE}")
|
||||
|
||||
with open(CONTEXTS_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError("contexts.json должен быть объектом {alias: context}")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def detect_kind(context: str, namespace: str, name: str) -> str:
|
||||
# Пробуем deployment
|
||||
code, _, _ = run_cmd([KUBECTL, "--context", context, "-n", namespace, "get", "deploy", name])
|
||||
if code == 0:
|
||||
return "deployment"
|
||||
|
||||
# Пробуем sts
|
||||
code, _, _ = run_cmd([KUBECTL, "--context", context, "-n", namespace, "get", "statefulset", name])
|
||||
if code == 0:
|
||||
return "statefulset"
|
||||
|
||||
raise RuntimeError(f"Не найден deployment/statefulset '{name}' в ns={namespace}")
|
||||
|
||||
|
||||
def get_selector(context: str, namespace: str, kind: str, name: str) -> Dict[str, str]:
|
||||
code, out, err = run_cmd([
|
||||
KUBECTL, "--context", context, "-n", namespace,
|
||||
"get", kind, name, "-o", "json"
|
||||
])
|
||||
if code != 0:
|
||||
raise RuntimeError(err or out)
|
||||
|
||||
obj = json.loads(out)
|
||||
selector = obj["spec"]["selector"]["matchLabels"]
|
||||
return selector
|
||||
|
||||
|
||||
def get_pod(context: str, namespace: str, selector: Dict[str, str]) -> str:
|
||||
label = ",".join(f"{k}={v}" for k, v in selector.items())
|
||||
|
||||
code, out, err = run_cmd([
|
||||
KUBECTL, "--context", context, "-n", namespace,
|
||||
"get", "pod", "-l", label,
|
||||
"-o", "jsonpath={.items[0].metadata.name}"
|
||||
])
|
||||
|
||||
pod = out.strip()
|
||||
if code != 0 or not pod:
|
||||
raise RuntimeError(f"Pod не найден по селектору: {label}")
|
||||
|
||||
return pod
|
||||
|
||||
|
||||
def get_logs(context: str, namespace: str, pod: str, previous: bool) -> str:
|
||||
cmd = [
|
||||
KUBECTL, "--context", context, "-n", namespace,
|
||||
"logs", pod, "--all-containers"
|
||||
]
|
||||
if previous:
|
||||
cmd.append("--previous")
|
||||
|
||||
code, out, err = run_cmd(cmd)
|
||||
if code != 0:
|
||||
return err or out
|
||||
|
||||
if err:
|
||||
out += "\n[stderr]\n" + err
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# ПАРСИНГ КОМАНДЫ
|
||||
# ---------------------------
|
||||
def parse_args(raw: List[str]):
|
||||
"""
|
||||
/logs ctx ns name
|
||||
/logs ctx ns name -p
|
||||
"""
|
||||
if len(raw) < 3:
|
||||
raise ValueError("нужно: /logs <ctx_alias> <namespace> <name> [-p]")
|
||||
|
||||
ctx_alias = raw[0]
|
||||
namespace = raw[1]
|
||||
name = raw[2]
|
||||
previous = False
|
||||
|
||||
if len(raw) == 4 and raw[3] == "-p":
|
||||
previous = True
|
||||
|
||||
return ctx_alias, namespace, name, previous
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# HANDLERS
|
||||
# ---------------------------
|
||||
async def logs_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
chat_id = update.effective_chat.id
|
||||
msg = update.effective_message.text
|
||||
log.info(f"MSG from {chat_id}: {msg}")
|
||||
|
||||
if ALLOWED_CHATS and chat_id not in ALLOWED_CHATS:
|
||||
log.warning(f"CHAT {chat_id} не в ALLOWED_CHATS")
|
||||
return
|
||||
|
||||
try:
|
||||
ctx_alias, ns, name, previous = parse_args(context.args)
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"Ошибка: {e}")
|
||||
return
|
||||
|
||||
# Контексты
|
||||
try:
|
||||
contexts = load_contexts()
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"Ошибка contexts.json: {e}")
|
||||
return
|
||||
|
||||
if ctx_alias not in contexts:
|
||||
await update.message.reply_text(f"Нет такого контекста: {ctx_alias}")
|
||||
return
|
||||
|
||||
ctx_full = contexts[ctx_alias]
|
||||
|
||||
try:
|
||||
kind = detect_kind(ctx_full, ns, name)
|
||||
selector = get_selector(ctx_full, ns, kind, name)
|
||||
pod = get_pod(ctx_full, ns, selector)
|
||||
logs = get_logs(ctx_full, ns, pod, previous)
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"Ошибка: {e}")
|
||||
return
|
||||
|
||||
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
fname = f"logs_{ctx_alias}_{ns}_{name}_{'prev' if previous else 'curr'}_{ts}.log"
|
||||
|
||||
buf = io.BytesIO(logs.encode("utf-8", errors="ignore"))
|
||||
buf.name = fname
|
||||
|
||||
await update.message.reply_document(
|
||||
InputFile(buf, filename=fname),
|
||||
caption=f"{kind}/{name}\npod={pod}\ncontext={ctx_full}\nprevious={previous}"
|
||||
)
|
||||
|
||||
|
||||
async def start_handler(update, context):
|
||||
await update.message.reply_text(
|
||||
"Использование:\n/logs <ctx> <ns> <name>\n/logs <ctx> <ns> <name> -p"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# MAIN
|
||||
# ---------------------------
|
||||
def main():
|
||||
if not BOT_TOKEN:
|
||||
log.error("TG_BOT_TOKEN не задан")
|
||||
raise SystemExit
|
||||
|
||||
app = ApplicationBuilder().token(BOT_TOKEN).build()
|
||||
app.add_handler(CommandHandler("start", start_handler))
|
||||
app.add_handler(CommandHandler("logs", logs_handler))
|
||||
|
||||
log.info("Бот запущен")
|
||||
app.run_polling()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
298
bot_remote.py
Executable file
298
bot_remote.py
Executable file
@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import shlex
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Tuple, List
|
||||
|
||||
from telegram import Update, InputFile
|
||||
from telegram.ext import ApplicationBuilder, CommandHandler, ContextTypes
|
||||
|
||||
# ---------------------------
|
||||
# ЛОГИ
|
||||
# ---------------------------
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
log = logging.getLogger("logbot")
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# ENV ЗАГРУЗКА
|
||||
# ---------------------------
|
||||
def load_env() -> None:
|
||||
env_file = Path(".env")
|
||||
if not env_file.exists():
|
||||
log.error("Файл .env не найден")
|
||||
return
|
||||
|
||||
with env_file.open() as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, val = line.split("=", 1)
|
||||
os.environ[key.strip()] = val.strip()
|
||||
log.info(f"ENV: {key.strip()} загружено")
|
||||
|
||||
|
||||
load_env()
|
||||
|
||||
BOT_TOKEN = os.getenv("TG_BOT_TOKEN")
|
||||
ALLOWED_CHATS = {
|
||||
int(x.strip()) for x in os.getenv("ALLOWED_CHATS", "").split(",") if x.strip().isdigit()
|
||||
}
|
||||
# Путь к kubectl НА АДМИНБОКСЕ
|
||||
KUBECTL = os.getenv("KUBECTL_BIN", "kubectl")
|
||||
CONTEXTS_FILE = os.getenv("CONTEXTS_FILE", "./contexts.json")
|
||||
|
||||
# SSH-настройки: на какой сервер ходим за логами
|
||||
SSH_HOST = os.getenv("SSH_HOST") # ОБЯЗАТЕЛЬНО
|
||||
SSH_USER = os.getenv("SSH_USER", "root") # опционально
|
||||
SSH_PORT = os.getenv("SSH_PORT", "22") # строка, чтобы проще пихать в команду
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Утилиты
|
||||
# ---------------------------
|
||||
def run_cmd(cmd: List[str]) -> Tuple[int, str, str]:
|
||||
"""
|
||||
Запуск команды на удалённом сервере через SSH.
|
||||
cmd — это массив вида ["kubectl", "--context", ...] и т.п.
|
||||
"""
|
||||
if not SSH_HOST:
|
||||
raise RuntimeError("SSH_HOST не задан в .env")
|
||||
|
||||
# Собираем удалённую команду как одну строку с экранированием
|
||||
remote_cmd = " ".join(shlex.quote(x) for x in cmd)
|
||||
|
||||
ssh_cmd: List[str] = ["ssh", "-o", "BatchMode=yes"]
|
||||
if SSH_PORT:
|
||||
ssh_cmd.extend(["-p", SSH_PORT])
|
||||
|
||||
destination = f"{SSH_USER}@{SSH_HOST}" if SSH_USER else SSH_HOST
|
||||
ssh_cmd.append(destination)
|
||||
ssh_cmd.append(remote_cmd)
|
||||
|
||||
log.info(f"RUN (remote): {' '.join(ssh_cmd)}")
|
||||
|
||||
proc = subprocess.run(
|
||||
ssh_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
|
||||
|
||||
def load_contexts() -> Dict[str, str]:
|
||||
"""
|
||||
Читает локальный contexts.json на бастионе:
|
||||
{ "prod": "k8s-erot-prod-context", "demo": "k8s-erot-demo-context", ... }
|
||||
"""
|
||||
if not Path(CONTEXTS_FILE).exists():
|
||||
raise RuntimeError(f"contexts.json не найден: {CONTEXTS_FILE}")
|
||||
|
||||
with open(CONTEXTS_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError("contexts.json должен быть объектом {alias: context}")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def detect_kind(context: str, namespace: str, name: str) -> str:
|
||||
"""
|
||||
Определяем тип ресурса по имени:
|
||||
- сначала пробуем deployment
|
||||
- если не найден, пробуем statefulset
|
||||
"""
|
||||
# deployment
|
||||
code, _, _ = run_cmd([
|
||||
KUBECTL, "--context", context, "-n", namespace,
|
||||
"get", "deploy", name,
|
||||
])
|
||||
if code == 0:
|
||||
return "deployment"
|
||||
|
||||
# statefulset
|
||||
code, _, _ = run_cmd([
|
||||
KUBECTL, "--context", context, "-n", namespace,
|
||||
"get", "statefulset", name,
|
||||
])
|
||||
if code == 0:
|
||||
return "statefulset"
|
||||
|
||||
raise RuntimeError(f"Не найден deployment/statefulset '{name}' в ns={namespace}, context={context}")
|
||||
|
||||
|
||||
def get_selector(context: str, namespace: str, kind: str, name: str) -> Dict[str, str]:
|
||||
"""
|
||||
Получаем spec.selector.matchLabels у deployment/statefulset.
|
||||
"""
|
||||
code, out, err = run_cmd([
|
||||
KUBECTL, "--context", context, "-n", namespace,
|
||||
"get", kind, name, "-o", "json",
|
||||
])
|
||||
if code != 0:
|
||||
raise RuntimeError(err or out)
|
||||
|
||||
obj = json.loads(out)
|
||||
selector = obj["spec"]["selector"]["matchLabels"]
|
||||
return selector
|
||||
|
||||
|
||||
def get_pod(context: str, namespace: str, selector: Dict[str, str]) -> str:
|
||||
"""
|
||||
По selector-лейблам находим первый pod.
|
||||
"""
|
||||
label = ",".join(f"{k}={v}" for k, v in selector.items())
|
||||
|
||||
code, out, err = run_cmd([
|
||||
KUBECTL, "--context", context, "-n", namespace,
|
||||
"get", "pod", "-l", label,
|
||||
"-o", "jsonpath={.items[0].metadata.name}",
|
||||
])
|
||||
|
||||
pod = out.strip()
|
||||
if code != 0 or not pod:
|
||||
raise RuntimeError(f"Pod не найден по селектору: {label}\n{err or ''}")
|
||||
|
||||
return pod
|
||||
|
||||
|
||||
def get_logs(context: str, namespace: str, pod: str, previous: bool) -> str:
|
||||
"""
|
||||
Забираем логи pod-а через kubectl logs.
|
||||
"""
|
||||
cmd = [
|
||||
KUBECTL, "--context", context, "-n", namespace,
|
||||
"logs", pod, "--all-containers",
|
||||
]
|
||||
if previous:
|
||||
cmd.append("--previous")
|
||||
|
||||
code, out, err = run_cmd(cmd)
|
||||
if code != 0:
|
||||
return err or out
|
||||
|
||||
if err:
|
||||
out += "\n[stderr]\n" + err
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# ПАРСИНГ КОМАНДЫ
|
||||
# ---------------------------
|
||||
def parse_args(raw: List[str]):
|
||||
"""
|
||||
/logs ctx ns name
|
||||
/logs ctx ns name -p
|
||||
"""
|
||||
if len(raw) < 3:
|
||||
raise ValueError("нужно: /logs <ctx_alias> <namespace> <name> [-p]")
|
||||
|
||||
ctx_alias = raw[0]
|
||||
namespace = raw[1]
|
||||
name = raw[2]
|
||||
previous = False
|
||||
|
||||
if len(raw) == 4 and raw[3] == "-p":
|
||||
previous = True
|
||||
|
||||
return ctx_alias, namespace, name, previous
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# HANDLERS
|
||||
# ---------------------------
|
||||
async def logs_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
chat_id = update.effective_chat.id
|
||||
msg = update.effective_message.text
|
||||
log.info(f"MSG from {chat_id}: {msg}")
|
||||
|
||||
if ALLOWED_CHATS and chat_id not in ALLOWED_CHATS:
|
||||
log.warning(f"CHAT {chat_id} не в ALLOWED_CHATS")
|
||||
return
|
||||
|
||||
try:
|
||||
ctx_alias, ns, name, previous = parse_args(context.args)
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"Ошибка: {e}")
|
||||
return
|
||||
|
||||
# Контексты
|
||||
try:
|
||||
contexts = load_contexts()
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"Ошибка contexts.json: {e}")
|
||||
return
|
||||
|
||||
if ctx_alias not in contexts:
|
||||
await update.message.reply_text(f"Нет такого контекста: {ctx_alias}")
|
||||
return
|
||||
|
||||
ctx_full = contexts[ctx_alias]
|
||||
|
||||
try:
|
||||
kind = detect_kind(ctx_full, ns, name)
|
||||
selector = get_selector(ctx_full, ns, kind, name)
|
||||
pod = get_pod(ctx_full, ns, selector)
|
||||
logs = get_logs(ctx_full, ns, pod, previous)
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"Ошибка: {e}")
|
||||
return
|
||||
|
||||
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
fname = f"logs_{ctx_alias}_{ns}_{name}_{'prev' if previous else 'curr'}_{ts}.log"
|
||||
|
||||
buf = io.BytesIO(logs.encode("utf-8", errors="ignore"))
|
||||
buf.name = fname
|
||||
|
||||
await update.message.reply_document(
|
||||
InputFile(buf, filename=fname),
|
||||
caption=f"{kind}/{name}\npod={pod}\ncontext={ctx_full}\nprevious={previous}",
|
||||
)
|
||||
|
||||
|
||||
async def start_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
await update.message.reply_text(
|
||||
"Использование:\n"
|
||||
"/logs <ctx> <ns> <name>\n"
|
||||
"/logs <ctx> <ns> <name> -p"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# MAIN
|
||||
# ---------------------------
|
||||
def main():
|
||||
if not BOT_TOKEN:
|
||||
log.error("TG_BOT_TOKEN не задан")
|
||||
raise SystemExit
|
||||
|
||||
if not SSH_HOST:
|
||||
log.error("SSH_HOST не задан в .env")
|
||||
raise SystemExit
|
||||
|
||||
log.info(f"Старт бота. SSH → {SSH_USER}@{SSH_HOST}:{SSH_PORT}, KUBECTL={KUBECTL}")
|
||||
|
||||
app = ApplicationBuilder().token(BOT_TOKEN).build()
|
||||
app.add_handler(CommandHandler("start", start_handler))
|
||||
app.add_handler(CommandHandler("logs", logs_handler))
|
||||
|
||||
log.info("Бот запущен, ждёт команды")
|
||||
app.run_polling()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
6
contexts.json
Normal file
6
contexts.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"prod": "k8s-prod-context",
|
||||
"demo": "k8s-demo-context",
|
||||
"test": "k8s-test-context"
|
||||
}
|
||||
|
||||
18
run_bot.sh
Executable file
18
run_bot.sh
Executable file
@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Переходим в директорию, где лежит бот
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Подхватываем переменные из файла .env
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
. ./.env
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Активируем venv
|
||||
. venv/bin/activate
|
||||
|
||||
# Запускаем бота
|
||||
exec python bot_remote.py
|
||||
Loading…
x
Reference in New Issue
Block a user