add postgres

This commit is contained in:
2026-04-07 23:48:47 +03:00
parent 4fff4bebaf
commit 3d29e1f84e
8 changed files with 326 additions and 12 deletions

67
backend/auth.py Normal file
View File

@@ -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

35
backend/db.py Normal file
View File

@@ -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()

View File

@@ -4,8 +4,10 @@ import os
import requests import requests
import yaml import yaml
from fastapi import FastAPI, HTTPException from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
app = FastAPI(title="AI CI/CD Assistant") 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 # Example for local Ollama: http://localhost:11434/api/generate
OLLAMA_API_URL = _env("OLLAMA_API_URL", "http://host.docker.internal: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): class GenerationRequest(BaseModel):
@@ -38,8 +45,69 @@ async def health():
return {"status": "ok"} 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") @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 = ( prompt = (
f"Write only a valid {req.platform} YAML configuration for: {req.description}. " f"Write only a valid {req.platform} YAML configuration for: {req.description}. "
"No explanations, just code." "No explanations, just code."

29
backend/models.py Normal file
View File

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

View File

@@ -2,3 +2,12 @@ fastapi
uvicorn[standard] uvicorn[standard]
requests requests
pyyaml pyyaml
sqlalchemy
psycopg[binary]
passlib[bcrypt]
python-jose[cryptography]
pydantic[email]
fastapi
uvicorn[standard]
requests
pyyaml

View File

@@ -6,6 +6,13 @@ services:
environment: environment:
OLLAMA_API_URL: "http://10.10.10.136:11434/api/generate" OLLAMA_API_URL: "http://10.10.10.136:11434/api/generate"
PORT: "8000" 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: ports:
- "8000:8000" - "8000:8000"
healthcheck: healthcheck:
@@ -19,7 +26,7 @@ services:
context: . context: .
dockerfile: frontend/Dockerfile dockerfile: frontend/Dockerfile
environment: environment:
BACKEND_URL: "http://backend:8000/generate" BACKEND_BASE_URL: "http://backend:8000"
ports: ports:
- "8501:8501" - "8501:8501"
depends_on: depends_on:

View File

@@ -16,11 +16,18 @@ def _env(name: str, default: str) -> str:
return value if value else default 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: with st.sidebar:
st.header("Настройки") 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( model = st.selectbox(
"LLM Модель", "LLM Модель",
["deepseek-r1:1.5b", "qwen2.5-coder:1.5b", "llama3.1:8b", "Arena Model"], ["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: if "history" not in st.session_state:
st.session_state.history = [] 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): 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] st.session_state.history = st.session_state.history[:50]
tab_overview, tab_generator, tab_validator, tab_history = st.tabs( tab_overview, tab_account, tab_generator, tab_validator, tab_history = st.tabs(
["Обзор", "Pipeline generator", "Проверка YAML", "История"] ["Обзор", "Аккаунт", "Pipeline generator", "Проверка YAML", "История"]
) )
with tab_overview: with tab_overview:
@@ -59,7 +68,78 @@ with tab_overview:
"- **История**: хранит последние результаты в рамках сессии браузера." "- **История**: хранит последние результаты в рамках сессии браузера."
) )
st.divider() 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: with tab_generator:
st.subheader("AI CI/CD Pipeline Generator") st.subheader("AI CI/CD Pipeline Generator")
@@ -71,17 +151,25 @@ with tab_generator:
col_a, col_b = st.columns([1, 3]) col_a, col_b = st.columns([1, 3])
with col_a: 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: with col_b:
if st.session_state.token:
st.caption("Подсказка: чем конкретнее требования, тем валиднее YAML.") st.caption("Подсказка: чем конкретнее требования, тем валиднее YAML.")
else:
st.warning("Нужно войти во вкладке «Аккаунт», чтобы генерировать.")
if generate_clicked: if generate_clicked:
if user_input.strip(): if user_input.strip():
with st.spinner("LLM думает..."): with st.spinner("LLM думает..."):
try: try:
res = requests.post( res = requests.post(
api_url, f"{backend_base_url}/generate",
json={"description": user_input, "platform": platform, "model": model}, json={"description": user_input, "platform": platform, "model": model},
headers=_auth_headers(),
timeout=90, timeout=90,
) )
res.raise_for_status() res.raise_for_status()

View File

@@ -14,4 +14,15 @@ docker compose up --build
### Configuration ### Configuration
- **Ollama**: backend uses `OLLAMA_API_URL` (set in `docker-compose.yml`) - **Ollama**: backend uses `OLLAMA_API_URL` (set in `docker-compose.yml`)
- **Frontend → backend**: frontend uses `BACKEND_URL` (defaults to `http://backend:8000/generate`) - **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)