getpgdata: Flask-приложение для доступа к PostgreSQL (Nubes структура)
This commit is contained in:
+211
@@ -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")))
|
||||
Reference in New Issue
Block a user