Compare commits

...

28 Commits

Author SHA1 Message Date
da6d18f6d5 update 2026-04-10 01:46:19 +03:00
ccbbf64ddb update low and add jenkins api 2026-04-10 01:32:57 +03:00
2d30fe258b add low-code 2026-04-10 00:47:53 +03:00
d30b5614f4 update 2026-04-10 00:13:29 +03:00
214c9495f3 update 2026-04-10 00:06:55 +03:00
f01e3b9040 update 2026-04-10 00:01:42 +03:00
1b8c03d6c3 update 2026-04-08 01:06:12 +03:00
818e5cf057 update 2026-04-08 00:56:35 +03:00
36ab32de50 update 2026-04-08 00:52:57 +03:00
d3f6736a2b argon 2026-04-08 00:44:43 +03:00
1c451016f9 update 2026-04-08 00:41:53 +03:00
0ae39ecacc update 2026-04-08 00:36:51 +03:00
65192f373b update 2026-04-08 00:34:12 +03:00
690ada36f7 update 2026-04-08 00:29:49 +03:00
00885d9e23 update 2026-04-08 00:26:12 +03:00
e54d845976 update 2026-04-08 00:22:48 +03:00
7876c30159 update 2026-04-08 00:17:14 +03:00
4f5daa9e65 update 2026-04-08 00:14:00 +03:00
3d29e1f84e add postgres 2026-04-07 23:48:47 +03:00
4fff4bebaf update 2026-04-07 22:48:31 +03:00
f93a09b41f update front 2026-04-07 22:45:45 +03:00
058530e343 update 2026-04-07 22:41:25 +03:00
71353df4cf update 2026-04-07 22:35:56 +03:00
09cabede8f update 2026-04-07 22:31:24 +03:00
dc367e2327 add docker 2026-04-07 22:06:27 +03:00
0eb46e4255 Refactor: improve run.sh startup logic and ensure proper permissions 2026-04-07 22:00:04 +03:00
5712d1b7fa Merge branch 'master' of https://git.pirantelx.ru/pirantelx/devops-portal 2026-04-07 21:54:04 +03:00
fa1d9849dc Fix: set executable bit for run.sh and update startup logic 2026-04-07 21:53:52 +03:00
22 changed files with 1818 additions and 106 deletions

17
.dockerignore Normal file
View 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
View 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"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

85
backend/auth.py Normal file
View 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
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", "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
View 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
View 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
View 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,
)

View 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,
}
],
}

View 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
View 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
View 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"]

View File

@@ -0,0 +1,3 @@
streamlit
requests
pyyaml

851
frontend/ui.py Normal file
View 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(
"&nbsp;:small[:gray[:material/balance: О портале]]",
type="tertiary",
help="DevOps AI Assistant — внутренний портал.",
)

53
main.py
View File

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

View File

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

@@ -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("Пожалуйста, введите описание.")