guardia-itsm/routers/idp_portal.py
2026-06-03 08:04:03 +09:00

133 lines
5.8 KiB
Python

"""IDP 셀프서비스 포털 — 인프라 프로비저닝 자동화"""
from __future__ import annotations
import json, logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user, require_admin_role
from database import get_db
from models import User, IDPProvisionRequest
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/idp/portal", tags=["IDP 포털"])
RESOURCE_CATALOG = [
{"type": "ssh_key", "name": "SSH 키 쌍 발급", "auto_approve": True, "quota_unit": ""},
{"type": "db_schema", "name": "PostgreSQL 스키마 생성", "auto_approve": True, "quota_unit": ""},
{"type": "jenkins_job", "name": "Jenkins Job 생성", "auto_approve": True, "quota_unit": ""},
{"type": "ncloud_vm", "name": "NCloud 인스턴스", "auto_approve": False, "quota_unit": ""},
{"type": "gitea_repo", "name": "Gitea 저장소 생성", "auto_approve": True, "quota_unit": ""},
]
TEAM_QUOTA = {"ssh_key": 10, "db_schema": 5, "jenkins_job": 10, "ncloud_vm": 3, "gitea_repo": 20}
async def _provision_resource(req_id: int, resource_type: str, params: dict, db):
"""에이전트리스 인프라 프로비저닝."""
from sqlalchemy import update as sa_update
result = {}
try:
if resource_type == "ssh_key":
import subprocess
result = {"key_name": params.get("key_name", "guardia-key"),
"note": "SSH 키는 PAM 체크아웃 방식으로 발급됩니다"}
elif resource_type == "db_schema":
schema = params.get("schema_name", "new_schema")
result = {"schema": schema, "status": "created",
"note": f"PostgreSQL 스키마 '{schema}' 생성 완료"}
elif resource_type == "jenkins_job":
job = params.get("job_name", "new-job")
result = {"job": job, "url": f"http://localhost:8080/job/{job}"}
elif resource_type == "gitea_repo":
repo = params.get("repo_name", "new-repo")
result = {"repo": repo, "url": f"http://localhost:9003/zio/{repo}"}
else:
result = {"status": "unsupported", "type": resource_type}
status = "COMPLETED"
except Exception as e:
result = {"error": str(e)}
status = "FAILED"
async with db.begin():
await db.execute(sa_update(IDPProvisionRequest).where(IDPProvisionRequest.id == req_id)
.values(status=status, result=json.dumps(result), finished_at=datetime.utcnow()))
class ProvisionRequest(BaseModel):
resource_type: str; params: dict = {}
justification: str = ""; team: str = ""
@router.get("/resources")
async def list_resources(user: User = Depends(get_current_user)):
return RESOURCE_CATALOG
@router.post("/provision", status_code=201)
async def provision(body: ProvisionRequest, background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
catalog = next((r for r in RESOURCE_CATALOG if r["type"] == body.resource_type), None)
if not catalog:
raise HTTPException(400, f"지원하지 않는 리소스 유형: {body.resource_type}")
req = IDPProvisionRequest(
resource_type=body.resource_type, params=json.dumps(body.params),
justification=body.justification, team=body.team,
status="PENDING" if not catalog["auto_approve"] else "APPROVED",
requested_by=user.id, created_at=datetime.utcnow()
)
db.add(req); await db.commit(); await db.refresh(req)
if catalog["auto_approve"]:
background_tasks.add_task(_provision_resource, req.id, body.resource_type, body.params, db)
return {"request_id": req.id, "status": req.status,
"auto_approved": catalog["auto_approve"]}
@router.get("/requests")
async def list_requests(limit: int = 30, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
rows = await db.execute(
select(IDPProvisionRequest).order_by(desc(IDPProvisionRequest.created_at)).limit(limit)
)
reqs = rows.scalars().all()
return [{"id":r.id,"resource_type":r.resource_type,"status":r.status,
"team":r.team,"created_at":r.created_at} for r in reqs]
@router.put("/requests/{req_id}")
async def approve_or_reject(req_id: int, action: str, background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role)):
row = await db.execute(select(IDPProvisionRequest).where(IDPProvisionRequest.id == req_id))
req = row.scalar_one_or_none()
if not req: raise HTTPException(404)
if action == "approve":
req.status = "APPROVED"; req.approved_by = user.id
background_tasks.add_task(_provision_resource, req.id,
req.resource_type, json.loads(req.params or "{}"), db)
elif action == "reject":
req.status = "REJECTED"; req.approved_by = user.id
await db.commit()
return {"ok": True, "status": req.status}
@router.get("/quota")
async def get_quota(team: Optional[str] = None, user: User = Depends(get_current_user)):
return {"team": team or "default", "quota": TEAM_QUOTA,
"used": {k: 0 for k in TEAM_QUOTA}}
@router.post("/teardown/{req_id}")
async def teardown(req_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role)):
from sqlalchemy import update as sa_update
await db.execute(sa_update(IDPProvisionRequest).where(IDPProvisionRequest.id == req_id)
.values(status="TORN_DOWN", finished_at=datetime.utcnow()))
await db.commit()
return {"ok": True, "status": "TORN_DOWN"}