commit 5c5e11c5a8e035af7ca85e6df714f4701ad90b1c Author: naeel Date: Tue Aug 18 18:12:04 2026 +0400 getpgdata: Flask-приложение для доступа к PostgreSQL (Nubes структура) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8acdcaa --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..2703cc8 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# getpgdata + +Flask-приложение для доступа к PostgreSQL (managed k8s, internal-only). + +Развёртывается на платформе Nubes как Python managed service (структура `site/app.py`). + +## Возможности + +- Главная — состояние подключения и список таблиц (не системные схемы) +- `GET /table?schema=public&name=` — просмотр содержимого таблицы с пагинацией +- `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": "", + "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= DB_USER= DB_PASS= python site/app.py +``` + +Если БД недоступна — приложение стартует и показывает ошибку на странице, +а не падает (важно для k8s health-проб и диагностики). diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..247ea33 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask>=3.0 +gunicorn>=21.2 +psycopg2-binary>=2.9 diff --git a/site/app.py b/site/app.py new file mode 100644 index 0000000..ff37835 --- /dev/null +++ b/site/app.py @@ -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"))) diff --git a/site/static/style.css b/site/static/style.css new file mode 100644 index 0000000..b834a92 --- /dev/null +++ b/site/static/style.css @@ -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; } diff --git a/site/templates/error.html b/site/templates/error.html new file mode 100644 index 0000000..d05faf9 --- /dev/null +++ b/site/templates/error.html @@ -0,0 +1,16 @@ + + + + + + Ошибка {{ code }} + + + +

Ошибка {{ code }}

+
+

{{ message }}

+

← На главную

+
+ + diff --git a/site/templates/index.html b/site/templates/index.html new file mode 100644 index 0000000..40c3e15 --- /dev/null +++ b/site/templates/index.html @@ -0,0 +1,62 @@ + + + + + + PostgreSQL Access + + + +
+

PostgreSQL Access

+ + {% if env == 'test' %}TEST{% endif %} +
+ +
+
+

Подключение

+
+ + + + +
Host{{ cfg.host }}
Port{{ cfg.port }}
Database{{ cfg.dbname }}
User{{ cfg.user }}
+ {% if error %} +

{{ error }}

+ {% else %} +

✓ Подключение активно

+ {% endif %} + + + {% if tables is not none and not error %} +
+

Таблицы ({{ tables|length }})

+ {% if tables %} + + + + + + {% for t in tables %} + + + + + + {% endfor %} + +
СхемаТаблица
{{ t.schemaname }}{{ t.tablename }} + открыть +
+ {% else %} +

Доступных таблиц нет.

+ {% endif %} +
+ {% endif %} + + + diff --git a/site/templates/query.html b/site/templates/query.html new file mode 100644 index 0000000..98cce9e --- /dev/null +++ b/site/templates/query.html @@ -0,0 +1,63 @@ + + + + + + SQL-консоль + + + +
+

SQL-консоль (read-only)

+ +
+ +
+
+
+ + +

Только SELECT. Обрезается до {{ (QUERY_MAX_ROWS or 200)|int }} строк.

+
+
+ + {% if error %} +

{{ error }}

+ {% endif %} + + {% if columns is not none and not error %} +
+

Результат {% if elapsed is not none %}({{ elapsed }} c){% endif %}

+ {% if columns %} +
+ + + {% for c in columns %}{% endfor %} + + + {% for r in rows %} + + {% for c in columns %} + + {% endfor %} + + {% endfor %} + +
{{ c }}
+ {% if r[c] is none %}NULL + {% elif r[c] is boolean %}{{ 'true' if r[c] else 'false' }} + {% else %}{{ r[c] }}{% endif %} +
+
+

Строк: {{ rows|length }}

+ {% else %} +

Запрос выполнен без набора строк.

+ {% endif %} +
+ {% endif %} +
+ + diff --git a/site/templates/table.html b/site/templates/table.html new file mode 100644 index 0000000..6ff17e2 --- /dev/null +++ b/site/templates/table.html @@ -0,0 +1,62 @@ + + + + + + Таблица — {{ schema }}.{{ name }} + + + +
+

Таблица: {{ schema }}.{{ name }}

+ +
+ +
+ {% if error %} +

{{ error }}

+ {% else %} +

Строк всего: {{ count }}. Показано: {{ rows|length }} (стр. {{ page }}).

+ + {% if columns %} +
+ + + + {% for c in columns %}{% endfor %} + + + + {% for r in rows %} + + {% for c in columns %} + + {% endfor %} + + {% endfor %} + +
{{ c }}
+ {% if r[c] is none %}NULL + {% elif r[c] is boolean %}{{ 'true' if r[c] else 'false' }} + {% else %}{{ r[c] }}{% endif %} +
+
+ + + {% else %} +

Таблица пуста.

+ {% endif %} + {% endif %} +
+ +