getpgdata: Flask-приложение для доступа к PostgreSQL (Nubes структура)
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
@@ -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-проб и диагностики).
|
||||
@@ -0,0 +1,3 @@
|
||||
Flask>=3.0
|
||||
gunicorn>=21.2
|
||||
psycopg2-binary>=2.9
|
||||
+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")))
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user