Compare commits
28 Commits
fcdff4967b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| da6d18f6d5 | |||
| ccbbf64ddb | |||
| 2d30fe258b | |||
| d30b5614f4 | |||
| 214c9495f3 | |||
| f01e3b9040 | |||
| 1b8c03d6c3 | |||
| 818e5cf057 | |||
| 36ab32de50 | |||
| d3f6736a2b | |||
| 1c451016f9 | |||
| 0ae39ecacc | |||
| 65192f373b | |||
| 690ada36f7 | |||
| 00885d9e23 | |||
| e54d845976 | |||
| 7876c30159 | |||
| 4f5daa9e65 | |||
| 3d29e1f84e | |||
| 4fff4bebaf | |||
| f93a09b41f | |||
| 058530e343 | |||
| 71353df4cf | |||
| 09cabede8f | |||
| dc367e2327 | |||
| 0eb46e4255 | |||
| 5712d1b7fa | |||
| fa1d9849dc |
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.log
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
.env
|
||||
.git/
|
||||
.gitignore
|
||||
.idea/
|
||||
.vscode/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
COPY backend/requirements-backend.txt /app/requirements-backend.txt
|
||||
RUN pip install --no-cache-dir -r /app/requirements-backend.txt
|
||||
|
||||
COPY backend/ /app/backend/
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
BIN
backend/__pycache__/auth.cpython-313.pyc
Normal file
BIN
backend/__pycache__/auth.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/db.cpython-313.pyc
Normal file
BIN
backend/__pycache__/db.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/main.cpython-313.pyc
Normal file
BIN
backend/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/models.cpython-313.pyc
Normal file
BIN
backend/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/pipeline_migrate.cpython-313.pyc
Normal file
BIN
backend/__pycache__/pipeline_migrate.cpython-313.pyc
Normal file
Binary file not shown.
85
backend/auth.py
Normal file
85
backend/auth.py
Normal file
@@ -0,0 +1,85 @@
|
||||
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=["argon2", "bcrypt"], deprecated="auto")
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
AUTH_VERSION = "argon2-bcrypt-compat-2026-04-07"
|
||||
|
||||
|
||||
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:
|
||||
try:
|
||||
return pwd_context.hash(password)
|
||||
except Exception as e:
|
||||
orig_len = len(password.encode("utf-8"))
|
||||
raise RuntimeError(
|
||||
f"{str(e)} (orig_bytes_len={orig_len})"
|
||||
) from e
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return pwd_context.verify(password, password_hash)
|
||||
except Exception as e:
|
||||
orig_len = len(password.encode("utf-8"))
|
||||
raise RuntimeError(
|
||||
f"{str(e)} (orig_bytes_len={orig_len})"
|
||||
) from e
|
||||
|
||||
|
||||
def password_needs_update(password_hash: str) -> bool:
|
||||
return pwd_context.needs_update(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
35
backend/db.py
Normal 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", "ADzXo0Gm!")
|
||||
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()
|
||||
|
||||
109
backend/jenkins_client.py
Normal file
109
backend/jenkins_client.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def _env(name: str, default: str) -> str:
|
||||
value = os.getenv(name)
|
||||
return value if value else default
|
||||
|
||||
|
||||
def _normalize_base(url: str) -> str:
|
||||
u = (url or "").strip().rstrip("/")
|
||||
if not u:
|
||||
return ""
|
||||
if not u.startswith(("http://", "https://")):
|
||||
u = f"https://{u}"
|
||||
return u
|
||||
|
||||
|
||||
def _job_url(base: str, job_path: str) -> str:
|
||||
"""https://jenkins/job/a/job/b/job/c"""
|
||||
parts = [p for p in (job_path or "").strip("/").split("/") if p]
|
||||
if not parts:
|
||||
raise ValueError("job path is empty")
|
||||
segs = "/".join(f"job/{quote(p, safe='')}" for p in parts)
|
||||
return f"{_normalize_base(base)}/{segs}"
|
||||
|
||||
|
||||
def trigger_jenkins_job(
|
||||
*,
|
||||
server_url: str,
|
||||
job_path: str,
|
||||
parameters: dict[str, str],
|
||||
username: str | None,
|
||||
token: str | None,
|
||||
verify_ssl: bool = True,
|
||||
) -> tuple[bool, int, str, dict[str, Any]]:
|
||||
"""
|
||||
Запускает сборку через Jenkins REST API: /build или /buildWithParameters.
|
||||
При включённом CSRF получает crumb с /crumbIssuer/api/json.
|
||||
"""
|
||||
base = _normalize_base(server_url)
|
||||
if not base:
|
||||
return False, 0, "server_url is empty", {}
|
||||
|
||||
job_url = _job_url(base, job_path)
|
||||
auth = (username, token) if username and token else None
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
if auth:
|
||||
try:
|
||||
cr = requests.get(
|
||||
f"{base}/crumbIssuer/api/json",
|
||||
auth=auth,
|
||||
timeout=30,
|
||||
verify=verify_ssl,
|
||||
)
|
||||
if cr.ok:
|
||||
cj = cr.json()
|
||||
field = cj.get("crumbRequestField", "Jenkins-Crumb")
|
||||
headers[field] = cj["crumb"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if parameters:
|
||||
target = f"{job_url}/buildWithParameters"
|
||||
resp = requests.post(
|
||||
target,
|
||||
auth=auth,
|
||||
headers=headers,
|
||||
data=parameters,
|
||||
timeout=120,
|
||||
verify=verify_ssl,
|
||||
)
|
||||
else:
|
||||
target = f"{job_url}/build"
|
||||
resp = requests.post(
|
||||
target,
|
||||
auth=auth,
|
||||
headers=headers,
|
||||
timeout=120,
|
||||
verify=verify_ssl,
|
||||
)
|
||||
|
||||
detail: dict[str, Any] = {
|
||||
"http_status": resp.status_code,
|
||||
"location": resp.headers.get("Location"),
|
||||
"jenkins_body_preview": (resp.text or "")[:500],
|
||||
}
|
||||
ok = 200 <= resp.status_code < 300
|
||||
if resp.status_code == 403 and "crumb" in (resp.text or "").lower():
|
||||
return False, resp.status_code, "Jenkins вернул 403 — проверьте crumb/права или отключите CSRF для API.", detail
|
||||
if not ok:
|
||||
return False, resp.status_code, f"Jenkins HTTP {resp.status_code}", detail
|
||||
return True, resp.status_code, "queued", detail
|
||||
|
||||
|
||||
def default_jenkins_credentials() -> tuple[str, str, str, bool]:
|
||||
"""default_url, user, token, verify_ssl"""
|
||||
return (
|
||||
_env("JENKINS_DEFAULT_URL", ""),
|
||||
_env("JENKINS_USER", ""),
|
||||
_env("JENKINS_TOKEN", ""),
|
||||
_env("JENKINS_VERIFY_SSL", "true").lower() in ("1", "true", "yes", "on"),
|
||||
)
|
||||
506
backend/main.py
Normal file
506
backend/main.py
Normal file
@@ -0,0 +1,506 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
import yaml
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
app = FastAPI(title="AI CI/CD Assistant")
|
||||
|
||||
|
||||
def _env(name: str, default: str) -> str:
|
||||
value = os.getenv(name)
|
||||
return value if value else default
|
||||
|
||||
|
||||
# Example: http://host:11434/api/generate — для чата берётся тот же хост + /api/chat
|
||||
OLLAMA_API_URL = _env("OLLAMA_API_URL", "http://host.docker.internal:11434/api/generate")
|
||||
|
||||
|
||||
def _ollama_chat_url() -> str:
|
||||
base = OLLAMA_API_URL.replace("/api/generate", "").rstrip("/")
|
||||
return f"{base}/api/chat"
|
||||
|
||||
|
||||
OLLAMA_CHAT_URL = _env("OLLAMA_CHAT_URL", _ollama_chat_url())
|
||||
REQUIRE_AUTH_FOR_GENERATE = _env("REQUIRE_AUTH_FOR_GENERATE", "true").lower() in ("1", "true", "yes", "on")
|
||||
|
||||
from backend.auth import ( # noqa: E402
|
||||
AUTH_VERSION,
|
||||
create_access_token,
|
||||
get_current_user,
|
||||
hash_password,
|
||||
password_needs_update,
|
||||
verify_password,
|
||||
)
|
||||
from backend.db import engine, get_db # noqa: E402
|
||||
from backend.models import AutonomousPipeline, Base, User, UserChatMessage # noqa: E402
|
||||
from backend.pipeline_migrate import migrate_definition # noqa: E402
|
||||
|
||||
|
||||
class GenerationRequest(BaseModel):
|
||||
description: str
|
||||
platform: str = "GitHub Actions" # or GitLab CI
|
||||
model: str = "codellama"
|
||||
|
||||
|
||||
class ChatMessageIn(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
model: str
|
||||
messages: list[ChatMessageIn]
|
||||
|
||||
|
||||
class JenkinsPhaseConfigIn(BaseModel):
|
||||
job: str = ""
|
||||
server_url: str = ""
|
||||
parameters: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class LowCodePhaseIn(BaseModel):
|
||||
id: str
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
jenkins: JenkinsPhaseConfigIn = Field(default_factory=JenkinsPhaseConfigIn)
|
||||
|
||||
|
||||
class GateCheckIn(BaseModel):
|
||||
"""Условие для перехода конвейера на следующий этап (имя переменной → ожидаемое значение)."""
|
||||
|
||||
variable: str = ""
|
||||
expected: str = ""
|
||||
|
||||
|
||||
class StageIn(BaseModel):
|
||||
"""Этап (например стенд): внутри него выполняются фазы; checks — гейты перед выходом на следующий этап."""
|
||||
|
||||
id: str
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
checks: list[GateCheckIn] = Field(default_factory=list)
|
||||
phases: list[LowCodePhaseIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AutonomousPipelineSave(BaseModel):
|
||||
title: str = ""
|
||||
stages: list[StageIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ValidateGateChecksIn(BaseModel):
|
||||
"""Проверка условий перехода: для каждого элемента checks сравнивается values[variable] с expected."""
|
||||
|
||||
checks: list[GateCheckIn] = Field(default_factory=list)
|
||||
values: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class GateCheckFailure(BaseModel):
|
||||
variable: str
|
||||
reason: str # "missing" | "mismatch"
|
||||
expected: str | None = None
|
||||
actual: str | None = None
|
||||
|
||||
|
||||
class ValidateGateChecksOut(BaseModel):
|
||||
ok: bool
|
||||
failures: list[GateCheckFailure] = Field(default_factory=list)
|
||||
|
||||
|
||||
def evaluate_gate_checks(
|
||||
checks: list[GateCheckIn],
|
||||
values: dict[str, Any],
|
||||
) -> tuple[bool, list[GateCheckFailure]]:
|
||||
"""Сравнивает ожидания с фактическими значениями (строки после strip). Строки с пустым variable игнорируются."""
|
||||
normalized: dict[str, str] = {}
|
||||
for k, v in values.items():
|
||||
key = str(k).strip()
|
||||
if not key:
|
||||
continue
|
||||
if v is None:
|
||||
normalized[key] = ""
|
||||
else:
|
||||
normalized[key] = str(v).strip()
|
||||
|
||||
failures: list[GateCheckFailure] = []
|
||||
for c in checks:
|
||||
var = (c.variable or "").strip()
|
||||
if not var:
|
||||
continue
|
||||
exp = (c.expected or "").strip()
|
||||
if var not in normalized:
|
||||
failures.append(
|
||||
GateCheckFailure(
|
||||
variable=var,
|
||||
reason="missing",
|
||||
expected=exp,
|
||||
actual=None,
|
||||
)
|
||||
)
|
||||
continue
|
||||
act = normalized[var]
|
||||
if act != exp:
|
||||
failures.append(
|
||||
GateCheckFailure(
|
||||
variable=var,
|
||||
reason="mismatch",
|
||||
expected=exp,
|
||||
actual=act,
|
||||
)
|
||||
)
|
||||
ok = len(failures) == 0
|
||||
return ok, failures
|
||||
|
||||
|
||||
class JenkinsTriggerRequest(BaseModel):
|
||||
server_url: str = ""
|
||||
job: str
|
||||
parameters: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
def validate_config(config_text: str):
|
||||
try:
|
||||
yaml.safe_load(config_text)
|
||||
return True, "Success"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
# lightweight DB probe to surface connection issues early
|
||||
db_ok = True
|
||||
db_error = None
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.exec_driver_sql("SELECT 1")
|
||||
except Exception as e:
|
||||
db_ok = False
|
||||
db_error = str(e)
|
||||
return {"status": "ok", "db_ok": db_ok, "db_error": db_error, "auth_version": AUTH_VERSION}
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def _startup():
|
||||
# Ensure tables exist; if DB is unreachable, endpoints will surface the error.
|
||||
try:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
except Exception:
|
||||
# Avoid crashing the service hard; /health will show db_error.
|
||||
pass
|
||||
|
||||
|
||||
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")
|
||||
pw_bytes_len = len(req.password.encode("utf-8"))
|
||||
|
||||
try:
|
||||
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}
|
||||
except HTTPException:
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Internal error: {str(e)} (password_bytes_len={pw_bytes_len})",
|
||||
)
|
||||
|
||||
|
||||
@app.post("/auth/login")
|
||||
def login(req: LoginRequest, db: Session = Depends(get_db)):
|
||||
email = req.email.strip().lower()
|
||||
if not req.password:
|
||||
raise HTTPException(status_code=400, detail="Password is required")
|
||||
try:
|
||||
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")
|
||||
|
||||
if password_needs_update(user.password_hash):
|
||||
user.password_hash = hash_password(req.password)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
token = create_access_token(user_id=str(user.id))
|
||||
return {"access_token": token, "token_type": "bearer"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
|
||||
|
||||
|
||||
@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.get("/pipelines/autonomous")
|
||||
def get_autonomous_pipeline(
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user) if REQUIRE_AUTH_FOR_GENERATE else None,
|
||||
):
|
||||
if user is None:
|
||||
return {"title": "", "stages": [], "updated_at": None}
|
||||
row = db.get(AutonomousPipeline, user.id)
|
||||
if row is None:
|
||||
return {"title": "", "stages": [], "updated_at": None}
|
||||
defn = migrate_definition(row.definition if isinstance(row.definition, dict) else {})
|
||||
stages = defn.get("stages", [])
|
||||
return {
|
||||
"title": row.title,
|
||||
"stages": stages,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@app.put("/pipelines/autonomous")
|
||||
def put_autonomous_pipeline(
|
||||
body: AutonomousPipelineSave,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user) if REQUIRE_AUTH_FOR_GENERATE else None,
|
||||
):
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
try:
|
||||
row = db.get(AutonomousPipeline, user.id)
|
||||
dumped = [s.model_dump() for s in body.stages]
|
||||
new_def = {"schema_version": 2, "stages": dumped}
|
||||
if row is None:
|
||||
row = AutonomousPipeline(
|
||||
user_id=user.id,
|
||||
title=body.title,
|
||||
definition=new_def,
|
||||
)
|
||||
db.add(row)
|
||||
else:
|
||||
row.title = body.title
|
||||
row.definition = new_def
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return {
|
||||
"title": row.title,
|
||||
"stages": row.definition.get("stages", []),
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
except SQLAlchemyError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/pipelines/autonomous/validate-gates", response_model=ValidateGateChecksOut)
|
||||
def validate_autonomous_gates(
|
||||
body: ValidateGateChecksIn,
|
||||
user: User | None = Depends(get_current_user) if REQUIRE_AUTH_FOR_GENERATE else None,
|
||||
):
|
||||
"""Проверяет, что для каждого условия в `checks` в `values` есть совпадающее значение."""
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
ok, failures = evaluate_gate_checks(body.checks, body.values)
|
||||
return ValidateGateChecksOut(ok=ok, failures=failures)
|
||||
|
||||
|
||||
@app.post("/jenkins/trigger")
|
||||
def jenkins_trigger(
|
||||
req: JenkinsTriggerRequest,
|
||||
user: User | None = Depends(get_current_user) if REQUIRE_AUTH_FOR_GENERATE else None,
|
||||
):
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
from backend.jenkins_client import default_jenkins_credentials, trigger_jenkins_job
|
||||
|
||||
default_url, ju, jt, verify_ssl = default_jenkins_credentials()
|
||||
base = (req.server_url or "").strip() or default_url
|
||||
if not base:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Укажите Jenkins URL в фазе или задайте JENKINS_DEFAULT_URL в окружении backend.",
|
||||
)
|
||||
if not ju or not jt:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Задайте JENKINS_USER и JENKINS_TOKEN (API token) в окружении backend.",
|
||||
)
|
||||
job = (req.job or "").strip()
|
||||
if not job:
|
||||
raise HTTPException(status_code=400, detail="Поле job обязательно.")
|
||||
|
||||
ok, _code, msg, detail = trigger_jenkins_job(
|
||||
server_url=base,
|
||||
job_path=job,
|
||||
parameters=req.parameters,
|
||||
username=ju,
|
||||
token=jt,
|
||||
verify_ssl=verify_ssl,
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=502, detail={"message": msg, **detail})
|
||||
return {"ok": True, "message": msg, **detail}
|
||||
|
||||
|
||||
@app.get("/chat/history")
|
||||
def chat_history(
|
||||
limit: int = 500,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user) if REQUIRE_AUTH_FOR_GENERATE else None,
|
||||
):
|
||||
if user is None:
|
||||
return {"messages": []}
|
||||
lim = max(1, min(limit, 2000))
|
||||
rows = (
|
||||
db.execute(
|
||||
select(UserChatMessage)
|
||||
.where(UserChatMessage.user_id == user.id)
|
||||
.order_by(UserChatMessage.created_at.asc())
|
||||
.limit(lim)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"messages": [{"role": r.role, "content": r.content} for r in rows],
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/chat/history")
|
||||
def chat_history_clear(
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user) if REQUIRE_AUTH_FOR_GENERATE else None,
|
||||
):
|
||||
if user is None:
|
||||
return {"ok": True}
|
||||
db.execute(delete(UserChatMessage).where(UserChatMessage.user_id == user.id))
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/chat")
|
||||
def chat_with_ollama(
|
||||
req: ChatRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user) if REQUIRE_AUTH_FOR_GENERATE else None,
|
||||
):
|
||||
if not req.messages:
|
||||
raise HTTPException(status_code=400, detail="messages must not be empty")
|
||||
for m in req.messages:
|
||||
if m.role not in ("user", "assistant", "system"):
|
||||
raise HTTPException(status_code=400, detail=f"invalid role: {m.role}")
|
||||
|
||||
payload = {
|
||||
"model": req.model,
|
||||
"messages": [{"role": m.role, "content": m.content} for m in req.messages],
|
||||
"stream": False,
|
||||
}
|
||||
try:
|
||||
response = requests.post(OLLAMA_CHAT_URL, json=payload, timeout=120)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
msg = data.get("message") or {}
|
||||
content = msg.get("content", "")
|
||||
if not content and isinstance(data.get("response"), str):
|
||||
content = data["response"]
|
||||
|
||||
if user is not None:
|
||||
last = req.messages[-1]
|
||||
if last.role == "user":
|
||||
try:
|
||||
db.add(
|
||||
UserChatMessage(
|
||||
user_id=user.id,
|
||||
role="user",
|
||||
content=last.content,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
UserChatMessage(
|
||||
user_id=user.id,
|
||||
role="assistant",
|
||||
content=content,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
|
||||
return {"role": "assistant", "content": content}
|
||||
except requests.HTTPError as e:
|
||||
detail = e.response.text if e.response is not None else str(e)
|
||||
raise HTTPException(status_code=502, detail=f"Ollama HTTP error: {detail}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Ollama error: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/generate")
|
||||
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."
|
||||
)
|
||||
|
||||
payload = {"model": req.model, "prompt": prompt, "stream": False}
|
||||
|
||||
try:
|
||||
response = requests.post(OLLAMA_API_URL, json=payload, timeout=60)
|
||||
response.raise_for_status()
|
||||
raw_text = response.json().get("response", "")
|
||||
|
||||
clean_code = raw_text.replace("```yaml", "").replace("```", "").strip()
|
||||
is_valid, error_msg = validate_config(clean_code)
|
||||
|
||||
return {
|
||||
"status": "ok" if is_valid else "error",
|
||||
"config": clean_code,
|
||||
"validation_error": error_msg if not is_valid else None,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Ollama error: {str(e)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=int(_env("PORT", "8000")))
|
||||
68
backend/models.py
Normal file
68
backend/models.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, 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)
|
||||
|
||||
|
||||
class UserChatMessage(Base):
|
||||
"""История чата с ИИ (Ollama) по пользователю."""
|
||||
|
||||
__tablename__ = "user_chat_messages"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, nullable=False)
|
||||
|
||||
|
||||
class AutonomousPipeline(Base):
|
||||
"""Low-code конвейер: фазы и вызов Jenkins с параметрами (один черновик на пользователя)."""
|
||||
|
||||
__tablename__ = "autonomous_pipelines"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(500), default="", nullable=False)
|
||||
definition: Mapped[dict] = mapped_column(
|
||||
JSONB,
|
||||
nullable=False,
|
||||
default=lambda: {"schema_version": 1, "phases": []},
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=_now,
|
||||
onupdate=_now,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
29
backend/pipeline_migrate.py
Normal file
29
backend/pipeline_migrate.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Миграция черновика конвейера: плоский список фаз → этапы (стенды) с фазами и проверками."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
|
||||
def migrate_definition(defn: Any) -> dict:
|
||||
"""Приводит definition к schema_version 2 с ключом stages."""
|
||||
if not isinstance(defn, dict):
|
||||
return {"schema_version": 2, "stages": []}
|
||||
if defn.get("stages"):
|
||||
return defn
|
||||
phases = defn.get("phases") or []
|
||||
if not phases:
|
||||
return {"schema_version": 2, "stages": []}
|
||||
return {
|
||||
"schema_version": 2,
|
||||
"stages": [
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": "Этап 1",
|
||||
"description": "Импорт: ранее фазы задавались без этапов (стендов).",
|
||||
"checks": [],
|
||||
"phases": phases,
|
||||
}
|
||||
],
|
||||
}
|
||||
12
backend/requirements-backend.txt
Normal file
12
backend/requirements-backend.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
requests
|
||||
pyyaml
|
||||
sqlalchemy
|
||||
psycopg[binary]
|
||||
passlib[argon2]
|
||||
argon2-cffi
|
||||
passlib[bcrypt]
|
||||
bcrypt
|
||||
python-jose[cryptography]
|
||||
pydantic[email]
|
||||
39
docker-compose.yml
Normal file
39
docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
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"
|
||||
# Jenkins REST API (для POST /jenkins/trigger): URL по умолчанию и API token пользователя
|
||||
JENKINS_DEFAULT_URL: ""
|
||||
JENKINS_USER: ""
|
||||
JENKINS_TOKEN: ""
|
||||
JENKINS_VERIFY_SSL: "true"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: frontend/Dockerfile
|
||||
environment:
|
||||
BACKEND_BASE_URL: "http://backend:8000"
|
||||
ports:
|
||||
- "8501:8501"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
15
frontend/Dockerfile
Normal file
15
frontend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
COPY frontend/requirements-frontend.txt /app/requirements-frontend.txt
|
||||
RUN pip install --no-cache-dir -r /app/requirements-frontend.txt
|
||||
|
||||
COPY frontend/ /app/frontend/
|
||||
|
||||
EXPOSE 8501
|
||||
|
||||
CMD ["python", "-m", "streamlit", "run", "frontend/ui.py", "--server.address=0.0.0.0", "--server.port=8501"]
|
||||
3
frontend/requirements-frontend.txt
Normal file
3
frontend/requirements-frontend.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
streamlit
|
||||
requests
|
||||
pyyaml
|
||||
851
frontend/ui.py
Normal file
851
frontend/ui.py
Normal file
@@ -0,0 +1,851 @@
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import requests
|
||||
import streamlit as st
|
||||
import yaml
|
||||
|
||||
# В духе https://github.com/streamlit/demo-ai-assistant — крупный герой, заголовок в ряд с действием
|
||||
st.set_page_config(
|
||||
page_title="DevOps AI Assistant",
|
||||
page_icon="✨",
|
||||
layout="wide",
|
||||
initial_sidebar_state="expanded",
|
||||
)
|
||||
|
||||
_HERO_HTML = """
|
||||
<style>
|
||||
.portal-hero-wrap { margin: 0 0 0.25rem 0; }
|
||||
.portal-hero { font-size: clamp(2.5rem, 6vw, 3.5rem); line-height: 1; margin: 0; font-weight: 400; }
|
||||
</style>
|
||||
<div class="portal-hero-wrap"><div class="portal-hero">❉</div></div>
|
||||
"""
|
||||
|
||||
|
||||
def _env(name: str, default: str) -> str:
|
||||
value = os.getenv(name)
|
||||
return value if value else default
|
||||
|
||||
|
||||
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("/")
|
||||
|
||||
|
||||
def _title_bar(*, show_logout: bool):
|
||||
st.html(_HERO_HTML)
|
||||
row = st.container(horizontal=True, vertical_alignment="bottom")
|
||||
with row:
|
||||
st.title("DevOps AI Assistant", anchor=False)
|
||||
if show_logout:
|
||||
|
||||
def _logout():
|
||||
st.session_state.token = None
|
||||
st.session_state.chat_messages = []
|
||||
st.session_state.chat_hydrated = False
|
||||
st.session_state.autonomous_pl_loaded = False
|
||||
st.session_state.autonomous_stages = []
|
||||
st.rerun()
|
||||
|
||||
st.button(
|
||||
"Выйти",
|
||||
icon=":material/logout:",
|
||||
on_click=_logout,
|
||||
help="Выйти из портала",
|
||||
)
|
||||
|
||||
|
||||
with st.sidebar:
|
||||
st.markdown("#### Настройки")
|
||||
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"],
|
||||
)
|
||||
platform = st.radio("Платформа", ["PV DOT CI", "PV DOT CD", "DPM CI", "DPM CD"])
|
||||
st.divider()
|
||||
st.caption("Портал АС/ФП · чат и генератор используют выбранную LLM модель.")
|
||||
|
||||
if "history" not in st.session_state:
|
||||
st.session_state.history = []
|
||||
if "token" not in st.session_state:
|
||||
st.session_state.token = None
|
||||
if "asfp_info" not in st.session_state:
|
||||
st.session_state.asfp_info = {
|
||||
"name": "",
|
||||
"owner": "",
|
||||
"contacts": "",
|
||||
"description": "",
|
||||
}
|
||||
if "chat_messages" not in st.session_state:
|
||||
st.session_state.chat_messages = []
|
||||
|
||||
|
||||
def _push_history(*, platform: str, model: str, prompt: str, status: str, config: str, error: str | None):
|
||||
st.session_state.history.insert(
|
||||
0,
|
||||
{
|
||||
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
|
||||
"platform": platform,
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"status": status,
|
||||
"error": error,
|
||||
"config": config,
|
||||
},
|
||||
)
|
||||
st.session_state.history = st.session_state.history[:50]
|
||||
|
||||
|
||||
def _auth_headers():
|
||||
if not st.session_state.token:
|
||||
return {}
|
||||
return {"Authorization": f"Bearer {st.session_state.token}"}
|
||||
|
||||
|
||||
def _detail_or_text(resp: requests.Response) -> str:
|
||||
try:
|
||||
payload = resp.json()
|
||||
if isinstance(payload, dict) and payload.get("detail"):
|
||||
return str(payload["detail"])
|
||||
except Exception:
|
||||
pass
|
||||
return resp.text
|
||||
|
||||
|
||||
def _new_autonomous_phase() -> dict:
|
||||
return {
|
||||
"id": uuid.uuid4().hex,
|
||||
"name": "",
|
||||
"description": "",
|
||||
"jenkins": {"job": "", "server_url": "", "parameters": {}},
|
||||
}
|
||||
|
||||
|
||||
def _new_autonomous_stage() -> dict:
|
||||
return {
|
||||
"id": uuid.uuid4().hex,
|
||||
"name": "",
|
||||
"description": "",
|
||||
"checks": [],
|
||||
"phases": [],
|
||||
}
|
||||
|
||||
|
||||
def _mermaid_stages(stages: list) -> str:
|
||||
if not stages:
|
||||
return "flowchart LR\n E[Нет этапов]"
|
||||
lines = ["flowchart LR"]
|
||||
for i, s in enumerate(stages):
|
||||
sn = (s.get("name") or f"Этап {i + 1}").replace('"', "'").replace("\n", " ")[:40]
|
||||
nph = len(s.get("phases") or [])
|
||||
lines.append(f' S{i}["{sn}\\n({nph} фаз)"]')
|
||||
for i in range(len(stages) - 1):
|
||||
lines.append(f" S{i} --> S{i + 1}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _collect_phases_for_save(phases: list, ver: int, sid: str) -> tuple[list | None, str | None]:
|
||||
out = []
|
||||
for ph in phases:
|
||||
pid = ph["id"]
|
||||
j = ph.get("jenkins") or {}
|
||||
default_pj = json.dumps(j.get("parameters", {}), ensure_ascii=False, indent=2)
|
||||
raw = st.session_state.get(f"ap_pj_{sid}_{pid}_{ver}", default_pj)
|
||||
try:
|
||||
params_obj = json.loads(raw) if str(raw).strip() else {}
|
||||
if not isinstance(params_obj, dict):
|
||||
raise TypeError("нужен JSON-объект { \"ключ\": \"значение\" }")
|
||||
except Exception as e:
|
||||
return None, f"Фаза «{ph.get('name') or pid}»: параметры Jenkins — {e}"
|
||||
out.append(
|
||||
{
|
||||
"id": pid,
|
||||
"name": st.session_state.get(f"ap_pn_{sid}_{pid}_{ver}", ph.get("name", "")),
|
||||
"description": st.session_state.get(f"ap_pd_{sid}_{pid}_{ver}", ph.get("description", "")),
|
||||
"jenkins": {
|
||||
"job": st.session_state.get(f"ap_jj_{sid}_{pid}_{ver}", j.get("job", "")),
|
||||
"server_url": st.session_state.get(f"ap_js_{sid}_{pid}_{ver}", j.get("server_url", "")),
|
||||
"parameters": {str(k): str(v) for k, v in params_obj.items()},
|
||||
},
|
||||
}
|
||||
)
|
||||
return out, None
|
||||
|
||||
|
||||
def _collect_stages_for_save(stages: list, ver: int) -> tuple[list | None, str | None]:
|
||||
out_stages = []
|
||||
for stg in stages:
|
||||
sid = stg["id"]
|
||||
default_ch = json.dumps(stg.get("checks") or [], ensure_ascii=False, indent=2)
|
||||
raw_ch = st.session_state.get(f"st_ch_{sid}_{ver}", default_ch)
|
||||
try:
|
||||
checks_raw = json.loads(raw_ch) if str(raw_ch).strip() else []
|
||||
if not isinstance(checks_raw, list):
|
||||
raise TypeError("нужен JSON-массив")
|
||||
checks = []
|
||||
for c in checks_raw:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
checks.append(
|
||||
{
|
||||
"variable": str(c.get("variable", "")),
|
||||
"expected": str(c.get("expected", "")),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return None, f"Этап «{stg.get('name') or sid}»: проверки перехода — {e}"
|
||||
ph_list, err = _collect_phases_for_save(stg.get("phases") or [], ver, sid)
|
||||
if err:
|
||||
return None, err
|
||||
out_stages.append(
|
||||
{
|
||||
"id": sid,
|
||||
"name": st.session_state.get(f"st_nm_{sid}_{ver}", stg.get("name", "")),
|
||||
"description": st.session_state.get(f"st_dc_{sid}_{ver}", stg.get("description", "")),
|
||||
"checks": checks,
|
||||
"phases": ph_list,
|
||||
}
|
||||
)
|
||||
return out_stages, None
|
||||
|
||||
|
||||
def _parse_gate_checks_session(sid: str, ver: int, stg: dict) -> tuple[list | None, str | None]:
|
||||
default_ch = json.dumps(stg.get("checks") or [], ensure_ascii=False, indent=2)
|
||||
raw_ch = st.session_state.get(f"st_ch_{sid}_{ver}", default_ch)
|
||||
try:
|
||||
checks_raw = json.loads(raw_ch) if str(raw_ch).strip() else []
|
||||
if not isinstance(checks_raw, list):
|
||||
raise TypeError("нужен JSON-массив")
|
||||
checks = []
|
||||
for c in checks_raw:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
checks.append(
|
||||
{
|
||||
"variable": str(c.get("variable", "")),
|
||||
"expected": str(c.get("expected", "")),
|
||||
}
|
||||
)
|
||||
return checks, None
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
|
||||
|
||||
def _collect_flat_jenkins_params_for_stage(stg: dict, ver: int, sid: str) -> dict[str, str]:
|
||||
"""Все параметры JSON из фаз этапа (последняя фаза перекрывает ключи предыдущих при совпадении имён)."""
|
||||
out: dict[str, str] = {}
|
||||
for ph in stg.get("phases") or []:
|
||||
pid = ph["id"]
|
||||
j = ph.get("jenkins") or {}
|
||||
default_pj = json.dumps(j.get("parameters", {}), ensure_ascii=False, indent=2)
|
||||
raw = st.session_state.get(f"ap_pj_{sid}_{pid}_{ver}", default_pj)
|
||||
try:
|
||||
params_obj = json.loads(raw) if str(raw).strip() else {}
|
||||
if isinstance(params_obj, dict):
|
||||
for k, v in params_obj.items():
|
||||
out[str(k)] = str(v)
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _phase_for_jenkins_trigger(ph: dict, ver: int, sid: str) -> tuple[dict | None, str | None]:
|
||||
pid = ph["id"]
|
||||
j = ph.get("jenkins") or {}
|
||||
default_pj = json.dumps(j.get("parameters", {}), ensure_ascii=False, indent=2)
|
||||
raw = st.session_state.get(f"ap_pj_{sid}_{pid}_{ver}", default_pj)
|
||||
try:
|
||||
params_obj = json.loads(raw) if str(raw).strip() else {}
|
||||
if not isinstance(params_obj, dict):
|
||||
raise TypeError("нужен JSON-объект")
|
||||
except Exception as e:
|
||||
return None, f"Параметры Jenkins: {e}"
|
||||
job = st.session_state.get(f"ap_jj_{sid}_{pid}_{ver}", j.get("job", "")).strip()
|
||||
if not job:
|
||||
return None, "Укажите Job / путь."
|
||||
surl = st.session_state.get(f"ap_js_{sid}_{pid}_{ver}", j.get("server_url", "")).strip()
|
||||
return (
|
||||
{
|
||||
"server_url": surl,
|
||||
"job": job,
|
||||
"parameters": {str(k): str(v) for k, v in params_obj.items()},
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _show_account():
|
||||
_title_bar(show_logout=bool(st.session_state.token))
|
||||
st.caption("Войдите или создайте учётную запись, чтобы открыть разделы портала.")
|
||||
|
||||
# Два блока с рамкой — ближе к «карточному» виду демо
|
||||
c1, c2 = st.columns(2, gap="large")
|
||||
with c1:
|
||||
with st.container(border=True):
|
||||
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("Создать аккаунт", key="btn_register", use_container_width=True):
|
||||
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"Ошибка регистрации: {_detail_or_text(r)}")
|
||||
else:
|
||||
st.success("Аккаунт создан. Теперь можно войти.")
|
||||
except Exception as e:
|
||||
st.error(f"Ошибка подключения к бекенду: {e}")
|
||||
|
||||
with c2:
|
||||
with st.container(border=True):
|
||||
st.markdown("**Вход**")
|
||||
login_email = st.text_input("Email ", key="login_email")
|
||||
login_password = st.text_input("Пароль ", type="password", key="login_password")
|
||||
if st.button("Войти", type="primary", key="btn_login", use_container_width=True):
|
||||
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"Ошибка входа: {_detail_or_text(r)}")
|
||||
else:
|
||||
st.session_state.token = r.json().get("access_token")
|
||||
st.session_state.chat_messages = []
|
||||
st.session_state.chat_hydrated = False
|
||||
st.session_state.autonomous_pl_loaded = False
|
||||
st.success("Успешный вход.")
|
||||
st.rerun()
|
||||
except Exception as e:
|
||||
st.error(f"Ошибка подключения к бекенду: {e}")
|
||||
|
||||
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.session_state.token = None
|
||||
st.warning("Сессия истекла или токен неверный. Войдите заново.")
|
||||
st.rerun()
|
||||
else:
|
||||
with st.expander("Профиль", expanded=False):
|
||||
st.json(r.json())
|
||||
except Exception as e:
|
||||
st.error(f"Ошибка подключения к бекенду: {e}")
|
||||
|
||||
|
||||
# Auth wall
|
||||
if not st.session_state.token:
|
||||
_show_account()
|
||||
st.stop()
|
||||
|
||||
# Подтянуть историю чата из Postgres (один раз после входа / смены пользователя)
|
||||
if not st.session_state.get("chat_hydrated"):
|
||||
try:
|
||||
hr = requests.get(
|
||||
f"{backend_base_url}/chat/history",
|
||||
headers=_auth_headers(),
|
||||
params={"limit": 500},
|
||||
timeout=30,
|
||||
)
|
||||
if hr.ok:
|
||||
st.session_state.chat_messages = hr.json().get("messages", [])
|
||||
except Exception:
|
||||
pass
|
||||
st.session_state.chat_hydrated = True
|
||||
|
||||
if not st.session_state.get("autonomous_pl_loaded"):
|
||||
st.session_state.autonomous_stages = []
|
||||
st.session_state.autonomous_pl_title = ""
|
||||
try:
|
||||
ar = requests.get(
|
||||
f"{backend_base_url}/pipelines/autonomous",
|
||||
headers=_auth_headers(),
|
||||
timeout=30,
|
||||
)
|
||||
if ar.ok:
|
||||
data = ar.json()
|
||||
st.session_state.autonomous_pl_title = data.get("title") or ""
|
||||
stages = data.get("stages") or []
|
||||
for stg in stages:
|
||||
if not isinstance(stg, dict):
|
||||
continue
|
||||
stg.setdefault("id", uuid.uuid4().hex)
|
||||
stg.setdefault("name", "")
|
||||
stg.setdefault("description", "")
|
||||
stg.setdefault("checks", [])
|
||||
stg.setdefault("phases", [])
|
||||
for ph in stg.get("phases") or []:
|
||||
if not isinstance(ph, dict):
|
||||
continue
|
||||
ph.setdefault("id", uuid.uuid4().hex)
|
||||
ph.setdefault("jenkins", {})
|
||||
ph["jenkins"].setdefault("job", "")
|
||||
ph["jenkins"].setdefault("server_url", "")
|
||||
ph["jenkins"].setdefault("parameters", {})
|
||||
st.session_state.autonomous_stages = [s for s in stages if isinstance(s, dict)]
|
||||
except Exception:
|
||||
pass
|
||||
st.session_state.autonomous_pl_loaded = True
|
||||
st.session_state.autonomous_pl_version = int(st.session_state.get("autonomous_pl_version", 0)) + 1
|
||||
st.session_state.ap_global_title = st.session_state.get("autonomous_pl_title", "")
|
||||
|
||||
# После входа — тот же заголовок + вкладки
|
||||
_title_bar(show_logout=True)
|
||||
st.caption("Портал с данными и инструментами для АС/ФП.")
|
||||
|
||||
tab_chat, tab_info, tab_autonomous, tab_infra, tab_tools, tab_news = st.tabs(
|
||||
[
|
||||
"AI",
|
||||
"Сведения об АС/ФП",
|
||||
"Автономные внедрения",
|
||||
"Инфраструктура АС/ФП",
|
||||
"Инструменты",
|
||||
"Новости",
|
||||
]
|
||||
)
|
||||
|
||||
with tab_chat:
|
||||
st.subheader("AI")
|
||||
st.caption(
|
||||
f"Диалог через `{backend_base_url}/chat` → Ollama. Модель: **{model}**. "
|
||||
"Переписка сохраняется в базе для вашего пользователя."
|
||||
)
|
||||
_, head_b = st.columns([4, 1])
|
||||
with head_b:
|
||||
|
||||
def _clear_chat():
|
||||
try:
|
||||
requests.delete(
|
||||
f"{backend_base_url}/chat/history",
|
||||
headers=_auth_headers(),
|
||||
timeout=30,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
st.session_state.chat_messages = []
|
||||
st.rerun()
|
||||
|
||||
st.button("Очистить чат", on_click=_clear_chat, use_container_width=True, key="btn_clear_chat")
|
||||
|
||||
# Только лента сообщений внутри рамки; st.chat_input — снаружи, чтобы строка была внизу экрана
|
||||
with st.container(border=True):
|
||||
for m in st.session_state.chat_messages:
|
||||
with st.chat_message(m["role"]):
|
||||
st.markdown(m["content"].replace("$", r"\$"))
|
||||
|
||||
user_text = st.chat_input("Напишите сообщение…", key="chat_input_portal")
|
||||
if user_text:
|
||||
st.session_state.chat_messages.append({"role": "user", "content": user_text})
|
||||
reply = ""
|
||||
with st.spinner("Ollama отвечает…"):
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{backend_base_url}/chat",
|
||||
json={
|
||||
"model": model,
|
||||
"messages": st.session_state.chat_messages,
|
||||
},
|
||||
headers=_auth_headers(),
|
||||
timeout=120,
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
reply = f"**Ошибка:** {_detail_or_text(r)}"
|
||||
else:
|
||||
reply = r.json().get("content") or ""
|
||||
except Exception as e:
|
||||
reply = f"**Ошибка сети:** {e}"
|
||||
st.session_state.chat_messages.append({"role": "assistant", "content": reply})
|
||||
st.rerun()
|
||||
|
||||
with tab_info:
|
||||
st.subheader("Сведения об АС/ФП")
|
||||
st.caption("Черновик формы. Дальше можно привязать к Postgres.")
|
||||
with st.container(border=True):
|
||||
st.session_state.asfp_info["name"] = st.text_input("Название", st.session_state.asfp_info["name"])
|
||||
st.session_state.asfp_info["owner"] = st.text_input("Владелец", st.session_state.asfp_info["owner"])
|
||||
st.session_state.asfp_info["contacts"] = st.text_input("Контакты", st.session_state.asfp_info["contacts"])
|
||||
st.session_state.asfp_info["description"] = st.text_area(
|
||||
"Описание", st.session_state.asfp_info["description"], height=120
|
||||
)
|
||||
with st.expander("JSON", expanded=False):
|
||||
st.json(st.session_state.asfp_info)
|
||||
|
||||
with tab_autonomous:
|
||||
st.subheader("Автономные внедрения")
|
||||
st.caption(
|
||||
"Иерархия: **конвейер → этапы (стенды) → фазы**. На этапе задаются **проверки** (переменная и ожидаемое значение) "
|
||||
"для перехода к следующему этапу; внутри этапа фазы идут по порядку, с вызовом **Jenkins**. "
|
||||
"См. DPM / Orchestra R ([Хабр](https://habr.com/ru/companies/sberbank/articles/855556/), "
|
||||
"[DPM](https://sbertech.ru/documents/platform-v-devops-pipeline-management/4.0.0))."
|
||||
)
|
||||
|
||||
ver = int(st.session_state.get("autonomous_pl_version", 0))
|
||||
stages: list = st.session_state.autonomous_stages
|
||||
|
||||
c_head1, c_head2, c_head3 = st.columns([2, 1, 1])
|
||||
with c_head1:
|
||||
if "ap_global_title" not in st.session_state:
|
||||
st.session_state.ap_global_title = st.session_state.get("autonomous_pl_title", "")
|
||||
st.text_input(
|
||||
"Название конвейера",
|
||||
key="ap_global_title",
|
||||
help="Один черновик на пользователя в Postgres.",
|
||||
)
|
||||
with c_head2:
|
||||
if st.button("Добавить этап (стенд)", use_container_width=True, key="ap_add_stage"):
|
||||
stages.append(_new_autonomous_stage())
|
||||
st.session_state.autonomous_stages = stages
|
||||
st.session_state.autonomous_pl_version = ver + 1
|
||||
st.rerun()
|
||||
with c_head3:
|
||||
if st.button("Перезагрузить с сервера", use_container_width=True, key="ap_reload"):
|
||||
st.session_state.autonomous_pl_loaded = False
|
||||
st.rerun()
|
||||
|
||||
with st.expander("Схема этапов (Mermaid)", expanded=False):
|
||||
st.markdown(f"```mermaid\n{_mermaid_stages(stages)}\n```")
|
||||
|
||||
for sidx, stg in enumerate(stages):
|
||||
sid = stg["id"]
|
||||
stg.setdefault("phases", [])
|
||||
stg.setdefault("checks", [])
|
||||
st.session_state.setdefault(f"st_nm_{sid}_{ver}", stg.get("name", ""))
|
||||
st.session_state.setdefault(f"st_dc_{sid}_{ver}", stg.get("description", ""))
|
||||
st.session_state.setdefault(
|
||||
f"st_ch_{sid}_{ver}",
|
||||
json.dumps(stg.get("checks") or [], ensure_ascii=False, indent=2),
|
||||
)
|
||||
|
||||
st_title = st.session_state.get(f"st_nm_{sid}_{ver}", "") or f"Этап {sidx + 1}"
|
||||
with st.expander(f"▣ {st_title} (стенд / этап)", expanded=(sidx == len(stages) - 1)):
|
||||
st.markdown("**Этап** — логический стенд; здесь же задаются **условия перехода** на следующий этап.")
|
||||
st.text_input("Наименование этапа / стенда", key=f"st_nm_{sid}_{ver}")
|
||||
st.text_area("Описание этапа", key=f"st_dc_{sid}_{ver}", height=56)
|
||||
st.markdown("**Проверки для перехода на следующий этап**")
|
||||
st.caption(
|
||||
"JSON-массив `variable` / `expected`. Ниже задаются фактические значения; они объединяются с параметрами Jenkins фаз этого этапа (ручной JSON перекрывает параметры)."
|
||||
)
|
||||
st.text_area(
|
||||
"Проверки (JSON)",
|
||||
key=f"st_ch_{sid}_{ver}",
|
||||
height=100,
|
||||
help='Пример: [{"variable":"DEPLOY_STATUS","expected":"OK"},{"variable":"ENV","expected":"dev"}]',
|
||||
)
|
||||
st.session_state.setdefault(f"st_val_{sid}_{ver}", "{}")
|
||||
st.markdown("**Фактические значения для сравнения**")
|
||||
st.text_area(
|
||||
"Значения переменных (JSON-объект)",
|
||||
key=f"st_val_{sid}_{ver}",
|
||||
height=88,
|
||||
help='Например: {"DEPLOY_STATUS":"OK","ENV":"dev"}. Пустые строки можно добить шаблоном.',
|
||||
)
|
||||
gv1, gv2 = st.columns(2)
|
||||
with gv1:
|
||||
if st.button("Шаблон ключей из проверок", key=f"st_tpl_{sid}_{ver}", use_container_width=True):
|
||||
chk, e2 = _parse_gate_checks_session(sid, ver, stg)
|
||||
if e2:
|
||||
st.error(f"Проверки: {e2}")
|
||||
else:
|
||||
tpl = {
|
||||
str(c.get("variable", "")).strip(): ""
|
||||
for c in (chk or [])
|
||||
if str(c.get("variable", "")).strip()
|
||||
}
|
||||
st.session_state[f"st_val_{sid}_{ver}"] = json.dumps(
|
||||
tpl, ensure_ascii=False, indent=2
|
||||
)
|
||||
st.rerun()
|
||||
with gv2:
|
||||
if st.button(
|
||||
"Проверить условия перехода",
|
||||
key=f"st_vg_{sid}_{ver}",
|
||||
type="primary",
|
||||
use_container_width=True,
|
||||
):
|
||||
chk, e2 = _parse_gate_checks_session(sid, ver, stg)
|
||||
if e2:
|
||||
st.error(f"Проверки: {e2}")
|
||||
elif not chk or not any(str(c.get("variable", "")).strip() for c in chk):
|
||||
st.info("Проверки не заданы (или без имён переменных) — условия перехода не блокируются.")
|
||||
else:
|
||||
raw_val = st.session_state.get(f"st_val_{sid}_{ver}", "{}")
|
||||
try:
|
||||
user_vals = json.loads(raw_val) if str(raw_val).strip() else {}
|
||||
if not isinstance(user_vals, dict):
|
||||
raise TypeError("нужен JSON-объект")
|
||||
except Exception as ex:
|
||||
st.error(f"Значения переменных: {ex}")
|
||||
user_vals = None
|
||||
if user_vals is not None:
|
||||
jenk = _collect_flat_jenkins_params_for_stage(stg, ver, sid)
|
||||
merged = {**jenk, **{str(k): v for k, v in user_vals.items()}}
|
||||
try:
|
||||
vr = requests.post(
|
||||
f"{backend_base_url}/pipelines/autonomous/validate-gates",
|
||||
json={"checks": chk, "values": merged},
|
||||
headers=_auth_headers(),
|
||||
timeout=30,
|
||||
)
|
||||
if vr.status_code >= 400:
|
||||
st.error(_detail_or_text(vr))
|
||||
else:
|
||||
data = vr.json()
|
||||
if data.get("ok"):
|
||||
nxt = "следующий этап" if sidx < len(stages) - 1 else "завершение конвейера"
|
||||
st.success(f"Все проверки пройдены. Можно переходить к: {nxt}.")
|
||||
else:
|
||||
st.error("Проверки не пройдены.")
|
||||
for f in data.get("failures") or []:
|
||||
r = f.get("reason")
|
||||
var = f.get("variable", "")
|
||||
if r == "missing":
|
||||
st.warning(f"• `{var}`: переменная отсутствует среди значений.")
|
||||
elif r == "mismatch":
|
||||
st.warning(
|
||||
f"• `{var}`: ожидалось `{f.get('expected')}`, "
|
||||
f"фактически `{f.get('actual')}`."
|
||||
)
|
||||
else:
|
||||
st.warning(f"• {f}")
|
||||
except Exception as ex:
|
||||
st.error(str(ex))
|
||||
if st.button("Добавить фазу на этом этапе", key=f"ap_add_ph_{sid}_{ver}", use_container_width=True):
|
||||
stg.setdefault("phases", []).append(_new_autonomous_phase())
|
||||
st.session_state.autonomous_stages = stages
|
||||
st.session_state.autonomous_pl_version = ver + 1
|
||||
st.rerun()
|
||||
|
||||
phases = stg.get("phases") or []
|
||||
for pidx, ph in enumerate(phases):
|
||||
pid = ph["id"]
|
||||
j = ph.setdefault("jenkins", {})
|
||||
j.setdefault("parameters", {})
|
||||
st.session_state.setdefault(f"ap_pn_{sid}_{pid}_{ver}", ph.get("name", ""))
|
||||
st.session_state.setdefault(f"ap_pd_{sid}_{pid}_{ver}", ph.get("description", ""))
|
||||
st.session_state.setdefault(f"ap_jj_{sid}_{pid}_{ver}", j.get("job", ""))
|
||||
st.session_state.setdefault(f"ap_js_{sid}_{pid}_{ver}", j.get("server_url", ""))
|
||||
st.session_state.setdefault(
|
||||
f"ap_pj_{sid}_{pid}_{ver}",
|
||||
json.dumps(j.get("parameters", {}), ensure_ascii=False, indent=2),
|
||||
)
|
||||
|
||||
title_ph = st.session_state.get(f"ap_pn_{sid}_{pid}_{ver}", "") or f"Фаза {pidx + 1}"
|
||||
ph_expanded = sidx == len(stages) - 1 and pidx == len(phases) - 1
|
||||
with st.expander(f" ◆ Фаза: {title_ph}", expanded=ph_expanded):
|
||||
st.markdown("**Фаза (low-code)**")
|
||||
st.text_input("Название", key=f"ap_pn_{sid}_{pid}_{ver}")
|
||||
st.text_area("Описание", key=f"ap_pd_{sid}_{pid}_{ver}", height=68)
|
||||
st.divider()
|
||||
st.markdown("**Jenkins**")
|
||||
st.text_input("Job / путь", key=f"ap_jj_{sid}_{pid}_{ver}", placeholder="folder/subfolder/job-name")
|
||||
st.text_input("Jenkins URL (если не дефолтный)", key=f"ap_js_{sid}_{pid}_{ver}")
|
||||
st.text_area("Параметры (JSON)", key=f"ap_pj_{sid}_{pid}_{ver}", height=100)
|
||||
b1, b2, b3 = st.columns(3)
|
||||
with b1:
|
||||
if st.button("Фаза вверх", key=f"ap_up_{sid}_{pid}_{ver}", disabled=pidx == 0):
|
||||
phases[pidx - 1], phases[pidx] = phases[pidx], phases[pidx - 1]
|
||||
st.session_state.autonomous_stages = stages
|
||||
st.session_state.autonomous_pl_version = ver + 1
|
||||
st.rerun()
|
||||
with b2:
|
||||
if st.button(
|
||||
"Фаза вниз",
|
||||
key=f"ap_dn_{sid}_{pid}_{ver}",
|
||||
disabled=pidx >= len(phases) - 1,
|
||||
):
|
||||
phases[pidx + 1], phases[pidx] = phases[pidx], phases[pidx + 1]
|
||||
st.session_state.autonomous_stages = stages
|
||||
st.session_state.autonomous_pl_version = ver + 1
|
||||
st.rerun()
|
||||
with b3:
|
||||
if st.button("Удалить фазу", key=f"ap_rm_{sid}_{pid}_{ver}"):
|
||||
stg["phases"] = [p for p in phases if p["id"] != pid]
|
||||
st.session_state.autonomous_stages = stages
|
||||
st.session_state.autonomous_pl_version = ver + 1
|
||||
st.rerun()
|
||||
if st.button(
|
||||
"Запустить Jenkins",
|
||||
key=f"ap_run_{sid}_{pid}_{ver}",
|
||||
use_container_width=True,
|
||||
):
|
||||
pj, jerr = _phase_for_jenkins_trigger(ph, ver, sid)
|
||||
if jerr:
|
||||
st.error(jerr)
|
||||
else:
|
||||
with st.spinner("Запрос к Jenkins API…"):
|
||||
try:
|
||||
jr = requests.post(
|
||||
f"{backend_base_url}/jenkins/trigger",
|
||||
json=pj,
|
||||
headers=_auth_headers(),
|
||||
timeout=120,
|
||||
)
|
||||
if jr.status_code >= 400:
|
||||
st.error(_detail_or_text(jr))
|
||||
else:
|
||||
info = jr.json()
|
||||
loc = info.get("location") or ""
|
||||
st.success(
|
||||
f"Сборка в очереди. {('Queue: ' + loc) if loc else ''}"
|
||||
)
|
||||
except Exception as ex:
|
||||
st.error(str(ex))
|
||||
|
||||
sb1, sb2, sb3 = st.columns(3)
|
||||
with sb1:
|
||||
if st.button("Этап вверх", key=f"st_up_{sid}_{ver}", disabled=sidx == 0):
|
||||
stages[sidx - 1], stages[sidx] = stages[sidx], stages[sidx - 1]
|
||||
st.session_state.autonomous_stages = stages
|
||||
st.session_state.autonomous_pl_version = ver + 1
|
||||
st.rerun()
|
||||
with sb2:
|
||||
if st.button(
|
||||
"Этап вниз",
|
||||
key=f"st_dn_{sid}_{ver}",
|
||||
disabled=sidx >= len(stages) - 1,
|
||||
):
|
||||
stages[sidx + 1], stages[sidx] = stages[sidx], stages[sidx + 1]
|
||||
st.session_state.autonomous_stages = stages
|
||||
st.session_state.autonomous_pl_version = ver + 1
|
||||
st.rerun()
|
||||
with sb3:
|
||||
if st.button("Удалить этап", key=f"st_rm_{sid}_{ver}"):
|
||||
st.session_state.autonomous_stages = [s for s in stages if s["id"] != sid]
|
||||
st.session_state.autonomous_pl_version = ver + 1
|
||||
st.rerun()
|
||||
|
||||
save_col1, save_col2 = st.columns([1, 2])
|
||||
with save_col1:
|
||||
if st.button("Сохранить в базу", type="primary", use_container_width=True, key="ap_save_db"):
|
||||
to_save, err = _collect_stages_for_save(stages, ver)
|
||||
if err:
|
||||
st.error(err)
|
||||
else:
|
||||
try:
|
||||
body = {
|
||||
"title": st.session_state.get("ap_global_title", ""),
|
||||
"stages": to_save,
|
||||
}
|
||||
r = requests.put(
|
||||
f"{backend_base_url}/pipelines/autonomous",
|
||||
json=body,
|
||||
headers=_auth_headers(),
|
||||
timeout=60,
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
st.error(_detail_or_text(r))
|
||||
else:
|
||||
data = r.json()
|
||||
st.session_state.autonomous_pl_title = data.get("title", "")
|
||||
st.session_state.autonomous_stages = data.get("stages", [])
|
||||
if "ap_global_title" in st.session_state:
|
||||
del st.session_state["ap_global_title"]
|
||||
st.session_state.autonomous_pl_version = ver + 1
|
||||
st.success("Сохранено.")
|
||||
st.rerun()
|
||||
except Exception as e:
|
||||
st.error(str(e))
|
||||
with save_col2:
|
||||
st.caption(
|
||||
"`PUT /pipelines/autonomous` — сохранение. Проверки: `POST /pipelines/autonomous/validate-gates`. "
|
||||
"Jenkins: `POST /jenkins/trigger` + env credentials."
|
||||
)
|
||||
|
||||
with st.expander("Экспорт YAML (черновик)", expanded=False):
|
||||
snap, err = _collect_stages_for_save(stages, ver)
|
||||
if err:
|
||||
st.warning(err)
|
||||
else:
|
||||
doc = {"title": st.session_state.get("ap_global_title", ""), "stages": snap}
|
||||
st.code(yaml.safe_dump(doc, allow_unicode=True, sort_keys=False), language="yaml")
|
||||
|
||||
with tab_infra:
|
||||
st.subheader("Инфраструктура АС/ФП")
|
||||
with st.container(border=True):
|
||||
st.info(
|
||||
"Сюда можно добавить окружения, кластера, сервисы, ресурсы, сетевые зависимости и т.д."
|
||||
)
|
||||
|
||||
with tab_tools:
|
||||
st.subheader("Инструменты")
|
||||
st.caption(f"Backend: `{backend_base_url}`")
|
||||
|
||||
with st.container(border=True):
|
||||
st.markdown("#### Pipeline generator")
|
||||
user_input = st.text_area(
|
||||
"Опишите пайплайн (например: 'Python app with pytest and docker build')",
|
||||
height=150,
|
||||
key="generator_prompt",
|
||||
)
|
||||
|
||||
col_a, col_b = st.columns([1, 3])
|
||||
with col_a:
|
||||
generate_clicked = st.button("Сгенерировать", type="primary", use_container_width=True)
|
||||
with col_b:
|
||||
st.caption("Чем конкретнее требования, тем стабильнее YAML.")
|
||||
|
||||
if generate_clicked:
|
||||
if user_input.strip():
|
||||
with st.spinner("LLM думает..."):
|
||||
try:
|
||||
res = requests.post(
|
||||
f"{backend_base_url}/generate",
|
||||
json={"description": user_input, "platform": platform, "model": model},
|
||||
headers=_auth_headers(),
|
||||
timeout=90,
|
||||
)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
|
||||
status = data.get("status", "error")
|
||||
config = data.get("config", "")
|
||||
err = data.get("validation_error")
|
||||
|
||||
_push_history(
|
||||
platform=platform,
|
||||
model=model,
|
||||
prompt=user_input,
|
||||
status=status,
|
||||
config=config,
|
||||
error=err,
|
||||
)
|
||||
|
||||
if status == "ok":
|
||||
st.success("Конфигурация валидна.")
|
||||
else:
|
||||
st.error(f"Ошибка валидации: {err}")
|
||||
|
||||
st.code(config, language="yaml")
|
||||
st.download_button("Скачать .yml", config, file_name="pipeline.yml")
|
||||
except Exception as e:
|
||||
st.error(f"Ошибка подключения к бекенду: {e}")
|
||||
else:
|
||||
st.warning("Пожалуйста, введите описание.")
|
||||
|
||||
with tab_news:
|
||||
st.subheader("Новости")
|
||||
with st.container(border=True):
|
||||
st.info(
|
||||
"Релизы, инциденты, регламентные работы, обновления инструментов — сюда можно вывести ленту позже."
|
||||
)
|
||||
|
||||
st.divider()
|
||||
st.button(
|
||||
" :small[:gray[:material/balance: О портале]]",
|
||||
type="tertiary",
|
||||
help="DevOps AI Assistant — внутренний портал.",
|
||||
)
|
||||
53
main.py
53
main.py
@@ -1,53 +0,0 @@
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
app = FastAPI(title="AI CI/CD Assistant")
|
||||
|
||||
# ЗАМЕНИТЕ на IP вашего контейнера с Ollama
|
||||
OLLAMA_API_URL = "http://10.10.10.136:11434/api/generate"
|
||||
|
||||
class GenerationRequest(BaseModel):
|
||||
description: str
|
||||
platform: str = "GitHub Actions" # или GitLab CI
|
||||
model: str = "codellama"
|
||||
|
||||
def validate_config(config_text: str):
|
||||
"""Простая валидация YAML синтаксиса"""
|
||||
try:
|
||||
yaml.safe_load(config_text)
|
||||
return True, "Success"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
@app.post("/generate")
|
||||
async def generate_pipeline(req: GenerationRequest):
|
||||
prompt = f"Write only a valid {req.platform} YAML configuration for: {req.description}. No explanations, just code."
|
||||
|
||||
payload = {
|
||||
"model": req.model,
|
||||
"prompt": prompt,
|
||||
"stream": False
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(OLLAMA_API_URL, json=payload, timeout=60)
|
||||
raw_text = response.json().get("response", "")
|
||||
|
||||
# Очистка от Markdown-оберток (```yaml ... ```)
|
||||
clean_code = raw_text.replace("```yaml", "").replace("```", "").strip()
|
||||
|
||||
is_valid, error_msg = validate_config(clean_code)
|
||||
|
||||
return {
|
||||
"status": "ok" if is_valid else "error",
|
||||
"config": clean_code,
|
||||
"validation_error": error_msg if not is_valid else None
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Ollama error: {str(e)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
29
readme.md
29
readme.md
@@ -1 +1,28 @@
|
||||
1
|
||||
## DevOps Portal (prototype)
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
### URLs
|
||||
|
||||
- **Backend health**: `http://localhost:8000/health`
|
||||
- **Frontend (Streamlit)**: `http://localhost:8501`
|
||||
|
||||
### Configuration
|
||||
|
||||
- **Ollama**: backend uses `OLLAMA_API_URL` (set in `docker-compose.yml`)
|
||||
- **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)
|
||||
|
||||
20
run.sh
Normal file → Executable file
20
run.sh
Normal file → Executable file
@@ -1,19 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 1. Снимаем блокировку Git (на случай, если что-то изменилось локально)
|
||||
git reset --hard HEAD
|
||||
git pull origin master
|
||||
set -euo pipefail
|
||||
|
||||
# 2. Убиваем старые процессы, чтобы освободить порты 8000 и 8501
|
||||
fuser -k 8000/tcp
|
||||
fuser -k 8501/tcp
|
||||
echo "Starting services with Docker Compose..."
|
||||
|
||||
# 3. Запуск Бэкенда (указываем правильный файл main.py)
|
||||
echo "Starting Backend..."
|
||||
nohup python3 main.py > backend.log 2>&1 &
|
||||
docker compose build --no-cache backend frontend
|
||||
docker compose up -d --force-recreate backend frontend
|
||||
|
||||
# 4. Запуск Фронтенда
|
||||
echo "Starting Frontend..."
|
||||
nohup streamlit run ui.py --server.port 8501 > frontend.log 2>&1 &
|
||||
|
||||
echo "Services started!"
|
||||
echo "Backend: http://localhost:8000/health"
|
||||
echo "Frontend: http://localhost:8501"
|
||||
38
ui.py
38
ui.py
@@ -1,38 +0,0 @@
|
||||
import streamlit as st
|
||||
import requests
|
||||
|
||||
st.set_page_config(page_title="DevOps AI Assistant", page_icon="🚀")
|
||||
|
||||
st.title("🤖 AI CI/CD Pipeline Generator")
|
||||
st.markdown("Прототип сервиса для генерации и валидации конфигураций")
|
||||
|
||||
with st.sidebar:
|
||||
st.header("Настройки")
|
||||
api_url = st.text_input("Backend URL", "http://localhost:8000/generate")
|
||||
model = st.selectbox("LLM Модель", ["deepseek-r1:1.5b", "qwen2.5-coder:1.5b", "llama3.1:8b", "Arena Model"])
|
||||
platform = st.radio("Платформа", ["GitHub Actions", "GitLab CI"])
|
||||
|
||||
user_input = st.text_area("Опишите пайплайн (например: 'Python app with pytest and docker build')", height=150)
|
||||
|
||||
if st.button("Сгенерировать конфигурацию", type="primary"):
|
||||
if user_input:
|
||||
with st.spinner("LLM думает..."):
|
||||
try:
|
||||
res = requests.post(api_url, json={
|
||||
"description": user_input,
|
||||
"platform": platform,
|
||||
"model": model
|
||||
})
|
||||
data = res.json()
|
||||
|
||||
if data["status"] == "ok":
|
||||
st.success("✅ Конфигурация валидна!")
|
||||
st.code(data["config"], language="yaml")
|
||||
st.download_button("Скачать .yml", data["config"], file_name="pipeline.yml")
|
||||
else:
|
||||
st.error(f"❌ Ошибка валидации: {data['validation_error']}")
|
||||
st.code(data["config"], language="yaml")
|
||||
except Exception as e:
|
||||
st.error(f"Ошибка подключения к бекенду: {e}")
|
||||
else:
|
||||
st.warning("Пожалуйста, введите описание.")
|
||||
Reference in New Issue
Block a user