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
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.env
+58
View File
@@ -0,0 +1,58 @@
# getpgdata
Flask-приложение для доступа к PostgreSQL (managed k8s, internal-only).
Развёртывается на платформе Nubes как Python managed service (структура `site/app.py`).
## Возможности
- Главная — состояние подключения и список таблиц (не системные схемы)
- `GET /table?schema=public&name=<table>` — просмотр содержимого таблицы с пагинацией
- `POST /query` — SQL-консоль (read-only, только `SELECT`)
- `GET /healthz` — liveness-проба для k8s
## Структура
```
getpgdata/
├── requirements.txt
└── site/
├── app.py
├── static/
│ └── style.css
└── templates/
├── index.html
├── table.html
├── query.html
└── error.html
```
## Переменные окружения (jsonEnv)
```json
{
"DB_HOST": "postgresqlk8s-master.60bdf3e3-5087-41ff-b760-fe6ea544a80e.svc.cluster.local",
"DB_PORT": "5432",
"DB_NAME": "postgres",
"DB_USER": "super",
"DB_PASS": "<secret>",
"DB_SSLMODE": "disable",
"DB_CONNECT_TIMEOUT": "5",
"QUERY_MAX_ROWS": "200",
"APP_ENV": "production",
"FLASK_DEBUG": "false"
}
```
БД доступна только изнутри k8s (internal `.svc.cluster.local`), поэтому приложение
должно работать внутри кластера Nubes.
## Локальный запуск
```bash
pip install -r requirements.txt
DB_HOST=<host> DB_USER=<user> DB_PASS=<pass> python site/app.py
```
Если БД недоступна — приложение стартует и показывает ошибку на странице,
а не падает (важно для k8s health-проб и диагностики).
+3
View File
@@ -0,0 +1,3 @@
Flask>=3.0
gunicorn>=21.2
psycopg2-binary>=2.9
+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")))
+117
View File
@@ -0,0 +1,117 @@
:root {
--bg: #f4f5f7;
--card: #ffffff;
--border: #d9dee3;
--text: #1f2328;
--muted: #6b7280;
--accent: #0969da;
--error-bg: #fff0f0;
--error-text: #b42318;
--ok: #1a7f37;
--null: #c0c4c9;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
header {
background: var(--card);
border-bottom: 1px solid var(--border);
padding: 12px 24px;
display: flex;
align-items: center;
gap: 24px;
}
header h1 { font-size: 18px; margin: 0; }
nav { display: flex; gap: 16px; }
nav a { color: var(--accent); text-decoration: none; }
nav a:hover { text-decoration: underline; }
.badge {
margin-left: auto;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.badge.test { background: #fff3cd; color: #7a4f01; }
main { padding: 24px; max-width: 1100px; margin: 0 auto; }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 20px;
}
.card h2 { margin-top: 0; font-size: 16px; }
p.error {
background: var(--error-bg);
color: var(--error-text);
border: 1px solid var(--error-text);
border-radius: 6px;
padding: 10px 12px;
white-space: pre-wrap;
word-break: break-word;
}
p.ok { color: var(--ok); font-weight: 600; }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td {
border: 1px solid var(--border);
padding: 6px 10px;
text-align: left;
vertical-align: top;
max-width: 320px;
overflow-wrap: anywhere;
}
th { background: #f6f8fa; font-weight: 600; }
.table-wrap { overflow-x: auto; }
.null { color: var(--null); font-style: italic; }
.kv { width: auto; min-width: 340px; }
.kv th { text-align: right; width: 120px; background: transparent; }
.kv td { border: none; }
textarea {
width: 100%;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
padding: 10px;
border: 1px solid var(--border);
border-radius: 6px;
}
button {
margin-top: 10px;
padding: 8px 16px;
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
button:hover { opacity: 0.9; }
.hint { color: var(--muted); font-size: 12px; }
.pager { display: flex; gap: 16px; margin-top: 14px; }
.pager a { color: var(--accent); text-decoration: none; }
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ошибка {{ code }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header><h1>Ошибка {{ code }}</h1></header>
<main>
<p class="error">{{ message }}</p>
<p><a href="{{ url_for('index') }}">← На главную</a></p>
</main>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PostgreSQL Access</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header>
<h1>PostgreSQL Access</h1>
<nav>
<a href="{{ url_for('index') }}">Главная</a>
<a href="{{ url_for('query') }}">SQL-консоль</a>
</nav>
{% if env == 'test' %}<span class="badge test">TEST</span>{% endif %}
</header>
<main>
<section class="card">
<h2>Подключение</h2>
<table class="kv">
<tr><th>Host</th><td>{{ cfg.host }}</td></tr>
<tr><th>Port</th><td>{{ cfg.port }}</td></tr>
<tr><th>Database</th><td>{{ cfg.dbname }}</td></tr>
<tr><th>User</th><td>{{ cfg.user }}</td></tr>
</table>
{% if error %}
<p class="error">{{ error }}</p>
{% else %}
<p class="ok">✓ Подключение активно</p>
{% endif %}
</section>
{% if tables is not none and not error %}
<section class="card">
<h2>Таблицы ({{ tables|length }})</h2>
{% if tables %}
<table>
<thead>
<tr><th>Схема</th><th>Таблица</th><th></th></tr>
</thead>
<tbody>
{% for t in tables %}
<tr>
<td>{{ t.schemaname }}</td>
<td>{{ t.tablename }}</td>
<td>
<a href="{{ url_for('table', schema=t.schemaname, name=t.tablename) }}">открыть</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>Доступных таблиц нет.</p>
{% endif %}
</section>
{% endif %}
</main>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SQL-консоль</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header>
<h1>SQL-консоль (read-only)</h1>
<nav>
<a href="{{ url_for('index') }}">Главная</a>
<a href="{{ url_for('query') }}">SQL-консоль</a>
</nav>
</header>
<main>
<section class="card">
<form method="post" action="{{ url_for('query') }}">
<textarea name="sql" rows="8" placeholder="SELECT * FROM public.contracts LIMIT 100;">{{ sql }}</textarea>
<button type="submit">Выполнить</button>
<p class="hint">Только SELECT. Обрезается до {{ (QUERY_MAX_ROWS or 200)|int }} строк.</p>
</form>
</section>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
{% if columns is not none and not error %}
<section class="card">
<h2>Результат {% if elapsed is not none %}({{ elapsed }} c){% endif %}</h2>
{% if columns %}
<div class="table-wrap">
<table>
<thead>
<tr>{% for c in columns %}<th>{{ c }}</th>{% endfor %}</tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
{% for c in columns %}
<td>
{% if r[c] is none %}<span class="null">NULL</span>
{% elif r[c] is boolean %}{{ 'true' if r[c] else 'false' }}
{% else %}{{ r[c] }}{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="hint">Строк: {{ rows|length }}</p>
{% else %}
<p>Запрос выполнен без набора строк.</p>
{% endif %}
</section>
{% endif %}
</main>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Таблица — {{ schema }}.{{ name }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header>
<h1>Таблица: {{ schema }}.{{ name }}</h1>
<nav>
<a href="{{ url_for('index') }}">Главная</a>
<a href="{{ url_for('query') }}">SQL-консоль</a>
</nav>
</header>
<main>
{% if error %}
<p class="error">{{ error }}</p>
{% else %}
<p>Строк всего: <strong>{{ count }}</strong>. Показано: {{ rows|length }} (стр. {{ page }}).</p>
{% if columns %}
<div class="table-wrap">
<table>
<thead>
<tr>
{% for c in columns %}<th>{{ c }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
{% for c in columns %}
<td>
{% if r[c] is none %}<span class="null">NULL</span>
{% elif r[c] is boolean %}{{ 'true' if r[c] else 'false' }}
{% else %}{{ r[c] }}{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<nav class="pager">
{% if page > 1 %}
<a href="{{ url_for('table', schema=schema, name=name, page=page-1, per_page=per_page) }}">← Назад</a>
{% endif %}
{% if page * per_page < count %}
<a href="{{ url_for('table', schema=schema, name=name, page=page+1, per_page=per_page) }}">Вперёд →</a>
{% endif %}
</nav>
{% else %}
<p>Таблица пуста.</p>
{% endif %}
{% endif %}
</main>
</body>
</html>