"""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"}