""" 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")))