getpgdata: Flask-приложение для доступа к PostgreSQL (Nubes структура)

This commit is contained in:
naeel
2026-08-18 18:12:04 +04:00
commit 5c5e11c5a8
9 changed files with 598 additions and 0 deletions
+211
View File
@@ -0,0 +1,211 @@
"""
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")))