From 3d29e1f84eab54f17ffb4e179d4bf040f9940f96 Mon Sep 17 00:00:00 2001 From: Damir Date: Tue, 7 Apr 2026 23:48:47 +0300 Subject: [PATCH] add postgres --- backend/auth.py | 67 ++++++++++++++++++++ backend/db.py | 35 +++++++++++ backend/main.py | 72 ++++++++++++++++++++- backend/models.py | 29 +++++++++ backend/requirements-backend.txt | 9 +++ docker-compose.yml | 9 ++- frontend/ui.py | 104 ++++++++++++++++++++++++++++--- readme.md | 13 +++- 8 files changed, 326 insertions(+), 12 deletions(-) create mode 100644 backend/auth.py create mode 100644 backend/db.py create mode 100644 backend/models.py diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..825e67c --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import os +from datetime import datetime, timedelta, timezone + +from fastapi import Depends, HTTPException +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlalchemy.orm import Session + +from backend.db import get_db +from backend.models import User + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +bearer = HTTPBearer(auto_error=False) + + +def _env(name: str, default: str) -> str: + value = os.getenv(name) + return value if value else default + + +JWT_SECRET = _env("JWT_SECRET", "change-me") +JWT_ALG = _env("JWT_ALG", "HS256") +JWT_EXPIRES_MINUTES = int(_env("JWT_EXPIRES_MINUTES", "480")) # 8h + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + return pwd_context.verify(password, password_hash) + + +def create_access_token(*, user_id: str) -> str: + now = datetime.now(timezone.utc) + payload = { + "sub": user_id, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(minutes=JWT_EXPIRES_MINUTES)).timestamp()), + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG) + + +def get_current_user( + creds: HTTPAuthorizationCredentials | None = Depends(bearer), + db: Session = Depends(get_db), +) -> User: + if creds is None or not creds.credentials: + raise HTTPException(status_code=401, detail="Not authenticated") + token = creds.credentials + + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG]) + user_id = payload.get("sub") + if not user_id: + raise HTTPException(status_code=401, detail="Invalid token") + except JWTError: + raise HTTPException(status_code=401, detail="Invalid token") + + user = db.get(User, user_id) + if not user or not user.is_active: + raise HTTPException(status_code=401, detail="User inactive or not found") + return user + diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..bcdb741 --- /dev/null +++ b/backend/db.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import os + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + + +def _env(name: str, default: str) -> str: + value = os.getenv(name) + return value if value else default + + +def build_database_url() -> str: + host = _env("POSTGRES_HOST", "10.10.10.167") + port = _env("POSTGRES_PORT", "5432") + db = _env("POSTGRES_DB", "devops_portal") + user = _env("POSTGRES_USER", "devops_portal") + password = _env("POSTGRES_PASSWORD", "change-me") + return f"postgresql+psycopg://{user}:{password}@{host}:{port}/{db}" + + +DATABASE_URL = _env("DATABASE_URL", build_database_url()) + +engine = create_engine(DATABASE_URL, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + diff --git a/backend/main.py b/backend/main.py index 698ea93..6cbc811 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,8 +4,10 @@ import os import requests import yaml -from fastapi import FastAPI, HTTPException +from fastapi import Depends, FastAPI, HTTPException from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.orm import Session app = FastAPI(title="AI CI/CD Assistant") @@ -17,6 +19,11 @@ def _env(name: str, default: str) -> str: # Example for local Ollama: http://localhost:11434/api/generate OLLAMA_API_URL = _env("OLLAMA_API_URL", "http://host.docker.internal:11434/api/generate") +REQUIRE_AUTH_FOR_GENERATE = _env("REQUIRE_AUTH_FOR_GENERATE", "true").lower() in ("1", "true", "yes", "on") + +from backend.auth import create_access_token, get_current_user, hash_password, verify_password # noqa: E402 +from backend.db import engine, get_db # noqa: E402 +from backend.models import Base, User # noqa: E402 class GenerationRequest(BaseModel): @@ -38,8 +45,69 @@ async def health(): return {"status": "ok"} +@app.on_event("startup") +def _startup(): + Base.metadata.create_all(bind=engine) + + +class RegisterRequest(BaseModel): + email: str + password: str + full_name: str | None = None + + +class LoginRequest(BaseModel): + email: str + password: str + + +@app.post("/auth/register") +def register(req: RegisterRequest, db: Session = Depends(get_db)): + email = req.email.strip().lower() + if not email or not req.password: + raise HTTPException(status_code=400, detail="Email and password are required") + + exists = db.execute(select(User).where(User.email == email)).scalar_one_or_none() + if exists: + raise HTTPException(status_code=409, detail="Email already registered") + + user = User( + email=email, + full_name=req.full_name.strip() if req.full_name else None, + password_hash=hash_password(req.password), + is_active=True, + is_admin=False, + ) + db.add(user) + db.commit() + db.refresh(user) + + return {"id": str(user.id), "email": user.email, "full_name": user.full_name, "is_admin": user.is_admin} + + +@app.post("/auth/login") +def login(req: LoginRequest, db: Session = Depends(get_db)): + email = req.email.strip().lower() + user = db.execute(select(User).where(User.email == email)).scalar_one_or_none() + if not user or not verify_password(req.password, user.password_hash): + raise HTTPException(status_code=401, detail="Invalid credentials") + if not user.is_active: + raise HTTPException(status_code=403, detail="User is inactive") + + token = create_access_token(user_id=str(user.id)) + return {"access_token": token, "token_type": "bearer"} + + +@app.get("/auth/me") +def me(user: User = Depends(get_current_user)): + return {"id": str(user.id), "email": user.email, "full_name": user.full_name, "is_admin": user.is_admin} + + @app.post("/generate") -async def generate_pipeline(req: GenerationRequest): +async def generate_pipeline( + req: GenerationRequest, + user: User | None = Depends(get_current_user) if REQUIRE_AUTH_FOR_GENERATE else None, +): prompt = ( f"Write only a valid {req.platform} YAML configuration for: {req.description}. " "No explanations, just code." diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..3e99b4b --- /dev/null +++ b/backend/models.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +def _now(): + return datetime.now(timezone.utc) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email: Mapped[str] = mapped_column(String(320), unique=True, index=True, nullable=False) + full_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, nullable=False) + diff --git a/backend/requirements-backend.txt b/backend/requirements-backend.txt index 667a987..499f24e 100644 --- a/backend/requirements-backend.txt +++ b/backend/requirements-backend.txt @@ -2,3 +2,12 @@ fastapi uvicorn[standard] requests pyyaml +sqlalchemy +psycopg[binary] +passlib[bcrypt] +python-jose[cryptography] +pydantic[email] +fastapi +uvicorn[standard] +requests +pyyaml diff --git a/docker-compose.yml b/docker-compose.yml index 381e625..656a468 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,13 @@ services: environment: OLLAMA_API_URL: "http://10.10.10.136:11434/api/generate" PORT: "8000" + POSTGRES_HOST: "10.10.10.167" + POSTGRES_PORT: "5432" + POSTGRES_DB: "devops_portal" + POSTGRES_USER: "devops_portal" + POSTGRES_PASSWORD: "ADzXo0Gm!" + JWT_SECRET: "z84ylghZVxFQam7kjMRN7BwhQpatvulE2N4/e9Nj7udwixGAcCqCyscdWWgMdFAUXpqy8O1jh+TlfZPPjmFeNeJjhkmHuQoHathKhvMev0DJo4sx8ybDwgIi/6+d75mgJqZRftqttXDtHcrNpfSUV2twD5FGc+TxqHmOaGLK8yg/RVS4RxrRzbKKJtB0577hbhfIiy/FLldfEWBcqM70NaIzNNceLO0x33Q==" + REQUIRE_AUTH_FOR_GENERATE: "true" ports: - "8000:8000" healthcheck: @@ -19,7 +26,7 @@ services: context: . dockerfile: frontend/Dockerfile environment: - BACKEND_URL: "http://backend:8000/generate" + BACKEND_BASE_URL: "http://backend:8000" ports: - "8501:8501" depends_on: diff --git a/frontend/ui.py b/frontend/ui.py index 5e8489c..58be9b2 100644 --- a/frontend/ui.py +++ b/frontend/ui.py @@ -16,11 +16,18 @@ def _env(name: str, default: str) -> str: return value if value else default -default_backend_url = _env("BACKEND_URL", "http://backend:8000/generate") +default_backend_base_url = _env("BACKEND_BASE_URL", "http://backend:8000") + + +def _normalize_base_url(value: str) -> str: + v = (value or "").strip().rstrip("/") + if v.endswith("/generate"): + v = v[: -len("/generate")] + return v.rstrip("/") with st.sidebar: st.header("Настройки") - api_url = st.text_input("Backend URL", default_backend_url) + backend_base_url = _normalize_base_url(st.text_input("Backend URL", default_backend_base_url)) model = st.selectbox( "LLM Модель", ["deepseek-r1:1.5b", "qwen2.5-coder:1.5b", "llama3.1:8b", "Arena Model"], @@ -29,6 +36,8 @@ with st.sidebar: if "history" not in st.session_state: st.session_state.history = [] +if "token" not in st.session_state: + st.session_state.token = None def _push_history(*, platform: str, model: str, prompt: str, status: str, config: str, error: str | None): @@ -47,8 +56,8 @@ def _push_history(*, platform: str, model: str, prompt: str, status: str, config st.session_state.history = st.session_state.history[:50] -tab_overview, tab_generator, tab_validator, tab_history = st.tabs( - ["Обзор", "Pipeline generator", "Проверка YAML", "История"] +tab_overview, tab_account, tab_generator, tab_validator, tab_history = st.tabs( + ["Обзор", "Аккаунт", "Pipeline generator", "Проверка YAML", "История"] ) with tab_overview: @@ -59,7 +68,78 @@ with tab_overview: "- **История**: хранит последние результаты в рамках сессии браузера." ) st.divider() - st.caption(f"Backend URL: {api_url}") + st.caption(f"Backend URL: {backend_base_url}") + if st.session_state.token: + st.success("Вы вошли в систему.") + else: + st.info("Вы не авторизованы.") + + +def _auth_headers(): + if not st.session_state.token: + return {} + return {"Authorization": f"Bearer {st.session_state.token}"} + + +with tab_account: + st.subheader("Аутентификация") + + c1, c2 = st.columns(2) + with c1: + st.markdown("**Регистрация**") + reg_email = st.text_input("Email", key="reg_email") + reg_full_name = st.text_input("Имя (опционально)", key="reg_full_name") + reg_password = st.text_input("Пароль", type="password", key="reg_password") + if st.button("Создать аккаунт"): + try: + r = requests.post( + f"{backend_base_url}/auth/register", + json={"email": reg_email, "password": reg_password, "full_name": reg_full_name or None}, + timeout=30, + ) + if r.status_code >= 400: + st.error(f"Ошибка регистрации: {r.text}") + else: + st.success("Аккаунт создан. Теперь можно войти.") + except Exception as e: + st.error(f"Ошибка подключения к бекенду: {e}") + + with c2: + st.markdown("**Вход**") + login_email = st.text_input("Email ", key="login_email") + login_password = st.text_input("Пароль ", type="password", key="login_password") + login_col_a, login_col_b = st.columns([1, 1]) + with login_col_a: + if st.button("Войти", type="primary"): + try: + r = requests.post( + f"{backend_base_url}/auth/login", + json={"email": login_email, "password": login_password}, + timeout=30, + ) + if r.status_code >= 400: + st.error(f"Ошибка входа: {r.text}") + else: + st.session_state.token = r.json().get("access_token") + st.success("Успешный вход.") + st.rerun() + except Exception as e: + st.error(f"Ошибка подключения к бекенду: {e}") + with login_col_b: + if st.button("Выйти", disabled=not bool(st.session_state.token)): + st.session_state.token = None + st.rerun() + + st.divider() + if st.session_state.token: + try: + r = requests.get(f"{backend_base_url}/auth/me", headers=_auth_headers(), timeout=20) + if r.status_code >= 400: + st.error(f"Не удалось получить профиль: {r.text}") + else: + st.json(r.json()) + except Exception as e: + st.error(f"Ошибка подключения к бекенду: {e}") with tab_generator: st.subheader("AI CI/CD Pipeline Generator") @@ -71,17 +151,25 @@ with tab_generator: col_a, col_b = st.columns([1, 3]) with col_a: - generate_clicked = st.button("Сгенерировать конфигурацию", type="primary") + generate_clicked = st.button( + "Сгенерировать конфигурацию", + type="primary", + disabled=not bool(st.session_state.token), + ) with col_b: - st.caption("Подсказка: чем конкретнее требования, тем валиднее YAML.") + if st.session_state.token: + st.caption("Подсказка: чем конкретнее требования, тем валиднее YAML.") + else: + st.warning("Нужно войти во вкладке «Аккаунт», чтобы генерировать.") if generate_clicked: if user_input.strip(): with st.spinner("LLM думает..."): try: res = requests.post( - api_url, + f"{backend_base_url}/generate", json={"description": user_input, "platform": platform, "model": model}, + headers=_auth_headers(), timeout=90, ) res.raise_for_status() diff --git a/readme.md b/readme.md index 4779d9f..c3dc5b4 100644 --- a/readme.md +++ b/readme.md @@ -14,4 +14,15 @@ docker compose up --build ### Configuration - **Ollama**: backend uses `OLLAMA_API_URL` (set in `docker-compose.yml`) -- **Frontend → backend**: frontend uses `BACKEND_URL` (defaults to `http://backend:8000/generate`) \ No newline at end of file +- **Frontend → backend**: frontend uses `BACKEND_BASE_URL` (defaults to `http://backend:8000`) + +### Auth + Postgres + +Backend uses Postgres at `POSTGRES_HOST` (currently set to `10.10.10.167`) and creates tables automatically on startup. + +Set these env vars in `docker-compose.yml` (or override via `.env`): + +- `POSTGRES_DB`: `devops_portal` +- `POSTGRES_USER`: `devops_portal` +- `POSTGRES_PASSWORD`: set your real password +- `JWT_SECRET`: set a long random secret (required for token security)