212 lines
6.9 KiB
Python
212 lines
6.9 KiB
Python
"""
|
||
Flask-приложение для доступа к PostgreSQL (managed k8s, internal-only).
|
||
|
||
Структура соответствует требованиям платформы Nubes:
|
||
repo/
|
||
└── site/
|
||
├── app.py
|
||
├── static/
|
||
└── templates/
|
||
|
||
Подключение к БД — через переменные окружения:
|
||
DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_SSLMODE
|
||
Если БД недоступна — приложение стартует и показывает ошибку на странице,
|
||
а не падает (важно для k8s health-проб и диагностики).
|
||
|
||
Запуск в dev:
|
||
python site/app.py
|
||
"""
|
||
|
||
import os
|
||
|
||
from flask import Flask, abort, redirect, render_template, request, url_for
|
||
|
||
import psycopg2
|
||
import psycopg2.extras
|
||
|
||
|
||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||
|
||
|
||
def db_config():
|
||
"""Возвращает параметры подключения к PostgreSQL из окружения."""
|
||
return {
|
||
"host": os.getenv("DB_HOST", "postgresqlk8s-master.60bdf3e3-5087-41ff-b760-fe6ea544a80e.svc.cluster.local"),
|
||
"port": int(os.getenv("DB_PORT", "5432")),
|
||
"dbname": os.getenv("DB_NAME", "postgres"),
|
||
"user": os.getenv("DB_USER", "super"),
|
||
"password": os.getenv("DB_PASS", ""),
|
||
"sslmode": os.getenv("DB_SSLMODE", "disable"),
|
||
"connect_timeout": int(os.getenv("DB_CONNECT_TIMEOUT", "5")),
|
||
}
|
||
|
||
|
||
def connect():
|
||
"""Открывает соединение с PostgreSQL."""
|
||
try:
|
||
return psycopg2.connect(**db_config())
|
||
except psycopg2.Error as exc:
|
||
raise ConnectionError(f"Нет соединения с PostgreSQL: {exc}") from exc
|
||
|
||
|
||
@app.route("/healthz")
|
||
def healthz():
|
||
"""Liveness-проба для k8s."""
|
||
try:
|
||
with connect() as conn:
|
||
with conn.cursor() as cur:
|
||
cur.execute("SELECT 1")
|
||
cur.fetchone()
|
||
return {"status": "ok", "db": "connected"}, 200
|
||
except Exception:
|
||
return {"status": "degraded", "db": "unreachable"}, 503
|
||
|
||
|
||
@app.route("/")
|
||
def index():
|
||
"""Главная: состояние подключения + список таблиц."""
|
||
cfg = db_config()
|
||
show_password = os.getenv("SHOW_DB_PASSWORD", "").lower() in ("1", "true", "yes")
|
||
|
||
tables = None
|
||
error = None
|
||
|
||
try:
|
||
with connect() as conn:
|
||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT schemaname, tablename
|
||
FROM pg_tables
|
||
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
|
||
ORDER BY schemaname, tablename
|
||
"""
|
||
)
|
||
tables = cur.fetchall()
|
||
except ConnectionError as exc:
|
||
error = str(exc)
|
||
except psycopg2.Error as exc:
|
||
error = f"Ошибка базы данных: {exc}"
|
||
|
||
if show_password:
|
||
cfg["password"] = "***"
|
||
|
||
return render_template(
|
||
"index.html",
|
||
cfg=cfg,
|
||
tables=tables,
|
||
error=error,
|
||
env=os.getenv("APP_ENV", "production"),
|
||
)
|
||
|
||
|
||
@app.route("/table")
|
||
def table():
|
||
"""Просмотр содержимого таблицы с пагинацией."""
|
||
schema = request.args.get("schema", "public")
|
||
name = request.args.get("name", "")
|
||
page = max(request.args.get("page", 1, type=int), 1)
|
||
per_page = min(max(request.args.get("per_page", 50, type=int), 1), 500)
|
||
|
||
if not name:
|
||
return redirect(url_for("index"))
|
||
|
||
full_name = f'"{schema}"."{name}"'
|
||
rows = None
|
||
columns = None
|
||
count = None
|
||
error = None
|
||
|
||
try:
|
||
with connect() as conn:
|
||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||
# Проверяем, что таблица существует
|
||
cur.execute(
|
||
"""
|
||
SELECT to_regclass(%s) AS reg
|
||
""",
|
||
(full_name,),
|
||
)
|
||
if cur.fetchone()["reg"] is None:
|
||
abort(404, f"Таблица {full_name} не найдена")
|
||
|
||
cur.execute(f'SELECT COUNT(*) AS n FROM {full_name}')
|
||
count = cur.fetchone()["n"]
|
||
|
||
offset = (page - 1) * per_page
|
||
cur.execute(
|
||
f'SELECT * FROM {full_name} ORDER BY 1 LIMIT %s OFFSET %s',
|
||
(per_page, offset),
|
||
)
|
||
rows = cur.fetchall()
|
||
if rows:
|
||
columns = list(rows[0].keys())
|
||
except ConnectionError as exc:
|
||
error = str(exc)
|
||
except psycopg2.Error as exc:
|
||
error = f"Ошибка базы данных: {exc}"
|
||
|
||
return render_template(
|
||
"table.html",
|
||
schema=schema,
|
||
name=name,
|
||
columns=columns,
|
||
rows=rows,
|
||
count=count,
|
||
page=page,
|
||
per_page=per_page,
|
||
error=error,
|
||
)
|
||
|
||
|
||
@app.route("/query", methods=["GET", "POST"])
|
||
def query():
|
||
"""Простейшая консоль SQL (read-only SELECT)."""
|
||
sql = request.form.get("sql", "") if request.method == "POST" else request.args.get("sql", "")
|
||
columns = None
|
||
rows = None
|
||
error = None
|
||
elapsed = None
|
||
|
||
if sql:
|
||
sql_clean = sql.strip().rstrip(";")
|
||
if not sql_clean.lower().lstrip().startswith("select"):
|
||
error = "Разрешены только SELECT-запросы (read-only режим)"
|
||
else:
|
||
import time
|
||
start = time.time()
|
||
try:
|
||
with connect() as conn:
|
||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||
cur.execute(sql_clean)
|
||
if cur.description:
|
||
max_rows = int(os.getenv("QUERY_MAX_ROWS", "200"))
|
||
rows = cur.fetchmany(max_rows)
|
||
columns = list(rows[0].keys()) if rows else [d.name for d in cur.description]
|
||
except ConnectionError as exc:
|
||
error = str(exc)
|
||
except psycopg2.Error as exc:
|
||
error = f"Ошибка базы данных: {exc}"
|
||
elapsed = round(time.time() - start, 3)
|
||
|
||
return render_template(
|
||
"query.html",
|
||
sql=sql,
|
||
columns=columns,
|
||
rows=rows,
|
||
error=error,
|
||
elapsed=elapsed,
|
||
QUERY_MAX_ROWS=int(os.getenv("QUERY_MAX_ROWS", "200")),
|
||
)
|
||
|
||
|
||
@app.errorhandler(404)
|
||
def not_found(exc):
|
||
return render_template("error.html", code=404, message=exc.description or "Страница не найдена"), 404
|
||
|
||
|
||
if __name__ == "__main__":
|
||
app.run(debug=os.getenv("FLASK_DEBUG", "true").lower() in ("1", "true", "yes"),
|
||
host="0.0.0.0",
|
||
port=int(os.getenv("PORT", "5000")))
|