zioinfo-mail/workspace/guardia-itsm/routers/cmdb.py
DESKTOP-TKLFCPR\ython cfe2901a55 refactor(structure): consolidate all projects under workspace/
- itsm/    -> workspace/guardia-itsm/
- manager/ -> workspace/guardia-manager/
- app/     -> workspace/guardia-messenger/
- manual/  -> workspace/guardia-docs/

workspace/zioinfo-web/ unchanged.
git mv preserves full commit history.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:50:56 +09:00

625 lines
22 KiB
Python

"""
CMDB: Institution + Server 관리 + C-1 CI 형상 관리 확장
엔드포인트:
기존: /api/cmdb/institutions, /api/cmdb/servers
C-1:
POST /api/cmdb/ci — CI 생성
GET /api/cmdb/ci — CI 목록
GET /api/cmdb/ci/{ci_id} — CI 상세 + 관계 + 변경 이력
PATCH /api/cmdb/ci/{ci_id} — CI 수정
DELETE /api/cmdb/ci/{ci_id} — CI 폐기(RETIRED 처리)
POST /api/cmdb/ci/relations — 관계 추가
DELETE /api/cmdb/ci/relations/{id} — 관계 삭제
GET /api/cmdb/ci/{ci_id}/history — 변경 이력
GET /api/cmdb/ci/stats — CI 통계
POST /api/cmdb/ci/import-servers — 기존 서버 → CI 일괄 등록
"""
from datetime import datetime, date
from typing import Dict, List, Optional
import json
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, desc, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from middleware.license_guard import check_server_limit
from models import (
Institution, InstitutionOut,
Server, ServerOut, ServerUpdate,
User, UserRole,
ConfigItem, ConfigItemOut, ConfigItemCreate, ConfigItemUpdate,
CIRelation, CIRelationOut, CIRelationCreate,
CIChangeLog, CIChangeLogOut,
CIChangeType,
)
router = APIRouter(prefix="/api/cmdb", tags=["cmdb"])
@router.get("/institutions", response_model=List[InstitutionOut])
async def list_institutions(db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user)):
result = await db.execute(select(Institution).order_by(Institution.inst_name))
return result.scalars().all()
@router.get("/institutions/{inst_code}/servers", response_model=List[ServerOut])
async def list_servers(inst_code: str, db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user)):
r = await db.execute(
select(Institution).where(Institution.inst_code == inst_code)
)
inst = r.scalars().first()
if not inst:
raise HTTPException(404, detail="기관을 찾을 수 없습니다.")
result = await db.execute(
select(Server).where(Server.inst_id == inst.id, Server.is_active == True)
.order_by(Server.server_role, Server.server_name)
)
return result.scalars().all()
@router.get("/servers", response_model=List[ServerOut])
async def list_all_servers(
role: Optional[str] = Query(None),
inst_code: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
q = select(Server).where(Server.is_active == True)
if role:
q = q.where(Server.server_role == role)
if inst_code:
r = await db.execute(select(Institution).where(Institution.inst_code == inst_code))
inst = r.scalars().first()
if inst:
q = q.where(Server.inst_id == inst.id)
q = q.order_by(Server.server_role, Server.server_name)
result = await db.execute(q)
return result.scalars().all()
@router.get("/servers/{server_id}", response_model=ServerOut)
async def get_server(
server_id: int,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
r = await db.execute(select(Server).where(Server.id == server_id))
server = r.scalars().first()
if not server:
raise HTTPException(404, "서버를 찾을 수 없습니다.")
return server
@router.patch("/servers/{server_id}", response_model=ServerOut)
async def update_server(
server_id: int,
payload: ServerUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER):
raise HTTPException(403, "권한이 없습니다.")
r = await db.execute(select(Server).where(Server.id == server_id))
server = r.scalars().first()
if not server:
raise HTTPException(404, "서버를 찾을 수 없습니다.")
for k, v in payload.model_dump(exclude_unset=True).items():
setattr(server, k, v)
server.updated_at = datetime.now()
await db.commit()
await db.refresh(server)
return server
@router.post("/servers", response_model=ServerOut, status_code=201)
async def create_server(
payload: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
_: None = Depends(check_server_limit),
):
"""새 서버 자산 등록."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "ADMIN 또는 PM 권한이 필요합니다.")
# inst_code → inst_id 변환
inst_code = payload.pop("inst_code", None)
inst_id = None
if inst_code:
r = await db.execute(
select(Institution).where(Institution.inst_code == inst_code)
)
inst = r.scalars().first()
if inst:
inst_id = inst.id
# sensitive fields: if ip_addr/ssh_user/os_pw_enc provided, store them (encryption handled elsewhere)
server = Server(inst_id=inst_id, **{k: v for k, v in payload.items()
if hasattr(Server, k)})
db.add(server)
await db.commit()
await db.refresh(server)
return server
# ════════════════════════════════════════════════════════════════════════════════
# C-1: CI 형상 관리 (Configuration Item Management)
# ════════════════════════════════════════════════════════════════════════════════
async def _next_ci_id(db: AsyncSession) -> str:
"""CI-YYYYMMDD-NNNN 형식 CI ID 생성."""
today = datetime.utcnow().strftime("%Y%m%d")
prefix = f"CI-{today}-"
last = (await db.execute(
select(ConfigItem.ci_id)
.where(ConfigItem.ci_id.like(f"{prefix}%"))
.order_by(desc(ConfigItem.ci_id))
.limit(1)
)).scalar()
seq = 1
if last:
try:
seq = int(last.split("-")[-1]) + 1
except ValueError:
seq = 1
return f"{prefix}{seq:04d}"
async def _log_ci_change(
db: AsyncSession,
ci: ConfigItem,
change_type: str,
changed_by: str,
field_name: Optional[str] = None,
old_value: Optional[str] = None,
new_value: Optional[str] = None,
sr_id: Optional[str] = None,
note: Optional[str] = None,
):
"""CI 변경 이력 기록."""
log = CIChangeLog(
ci_id_fk = ci.id,
ci_id_str = ci.ci_id,
change_type = change_type,
field_name = field_name,
old_value = old_value,
new_value = new_value,
changed_by = changed_by,
sr_id = sr_id,
note = note,
)
db.add(log)
# ── CI CRUD ───────────────────────────────────────────────────────────────────
@router.post("/ci", response_model=ConfigItemOut, status_code=201)
async def create_ci(
body: ConfigItemCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""새 형상 항목(CI) 등록."""
ci_id = await _next_ci_id(db)
ci = ConfigItem(
ci_id = ci_id,
name = body.name,
ci_type = body.ci_type.upper(),
category = body.category,
status = body.status.upper(),
version = body.version,
owner = body.owner,
location = body.location,
vendor = body.vendor,
model = body.model,
serial_number = body.serial_number,
install_date = body.install_date,
retire_date = body.retire_date,
linked_server_id = body.linked_server_id,
inst_id = body.inst_id,
sr_id = body.sr_id,
attributes_json = json.dumps(body.attributes, ensure_ascii=False) if body.attributes else None,
description = body.description,
created_by = current_user.username,
)
db.add(ci)
await db.flush()
await _log_ci_change(
db, ci,
change_type = CIChangeType.CREATE.value,
changed_by = current_user.username,
new_value = body.name,
note = "CI 신규 등록",
)
await db.commit()
await db.refresh(ci)
return ci
@router.get("/ci", response_model=List[ConfigItemOut])
async def list_ci(
ci_type: Optional[str] = Query(None),
status: Optional[str] = Query(None),
inst_id: Optional[int] = Query(None),
keyword: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""CI 목록 조회."""
conditions = []
if ci_type:
conditions.append(ConfigItem.ci_type == ci_type.upper())
if status:
conditions.append(ConfigItem.status == status.upper())
if inst_id:
conditions.append(ConfigItem.inst_id == inst_id)
if keyword:
conditions.append(ConfigItem.name.ilike(f"%{keyword}%"))
q = select(ConfigItem)
if conditions:
q = q.where(and_(*conditions))
q = q.order_by(desc(ConfigItem.created_at)).limit(limit).offset(offset)
rows = (await db.execute(q)).scalars().all()
return rows
@router.get("/ci/stats")
async def ci_stats(
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""CI 현황 통계."""
total = (await db.execute(
select(func.count()).select_from(ConfigItem)
)).scalar() or 0
by_type = {}
type_rows = (await db.execute(
select(ConfigItem.ci_type, func.count())
.group_by(ConfigItem.ci_type)
)).all()
for ci_type, cnt in type_rows:
by_type[ci_type] = cnt
by_status = {}
status_rows = (await db.execute(
select(ConfigItem.status, func.count())
.group_by(ConfigItem.status)
)).all()
for st, cnt in status_rows:
by_status[st] = cnt
relation_count = (await db.execute(
select(func.count()).select_from(CIRelation)
)).scalar() or 0
change_count = (await db.execute(
select(func.count()).select_from(CIChangeLog)
)).scalar() or 0
return {
"total_ci": total,
"by_type": by_type,
"by_status": by_status,
"total_relations": relation_count,
"total_changes": change_count,
}
@router.get("/ci/{ci_id}", response_model=ConfigItemOut)
async def get_ci(
ci_id: str,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""CI 상세 조회."""
ci = (await db.execute(
select(ConfigItem).where(ConfigItem.ci_id == ci_id)
)).scalars().first()
if not ci:
raise HTTPException(404, f"CI {ci_id}를 찾을 수 없습니다.")
return ci
@router.patch("/ci/{ci_id}", response_model=ConfigItemOut)
async def update_ci(
ci_id: str,
body: ConfigItemUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""CI 수정 — 변경된 필드마다 이력 기록."""
ci = (await db.execute(
select(ConfigItem).where(ConfigItem.ci_id == ci_id)
)).scalars().first()
if not ci:
raise HTTPException(404, f"CI {ci_id}를 찾을 수 없습니다.")
update_data = body.model_dump(exclude_unset=True, exclude_none=True)
sr_id_for_log = update_data.pop("sr_id", None)
for field, new_val in update_data.items():
if field == "attributes":
old_v = ci.attributes_json
ci.attributes_json = json.dumps(new_val, ensure_ascii=False) if new_val else None
new_v = ci.attributes_json
else:
old_v = str(getattr(ci, field, "")) if getattr(ci, field, None) is not None else None
setattr(ci, field, new_val)
new_v = str(new_val)
# 상태 변경은 별도 change_type 사용
ct = CIChangeType.STATUS_CHANGE.value if field == "status" else CIChangeType.UPDATE.value
await _log_ci_change(
db, ci, ct,
changed_by = current_user.username,
field_name = field,
old_value = old_v,
new_value = new_v,
sr_id = sr_id_for_log,
)
await db.commit()
await db.refresh(ci)
return ci
@router.delete("/ci/{ci_id}", status_code=204)
async def retire_ci(
ci_id: str,
note: Optional[str] = Query(None, description="폐기 사유"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""CI 폐기 처리 (RETIRED 상태 전환 — 데이터 보존)."""
ci = (await db.execute(
select(ConfigItem).where(ConfigItem.ci_id == ci_id)
)).scalars().first()
if not ci:
raise HTTPException(404, f"CI {ci_id}를 찾을 수 없습니다.")
if ci.status == "RETIRED":
raise HTTPException(400, "이미 폐기된 CI입니다.")
old_status = ci.status
ci.status = "RETIRED"
ci.retire_date = datetime.utcnow().date()
await _log_ci_change(
db, ci,
change_type = CIChangeType.RETIRE.value,
changed_by = current_user.username,
field_name = "status",
old_value = old_status,
new_value = "RETIRED",
note = note or "폐기 처리",
)
await db.commit()
# ── CI 관계 관리 ─────────────────────────────────────────────────────────────
@router.post("/ci/relations", response_model=CIRelationOut, status_code=201)
async def add_ci_relation(
body: CIRelationCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""CI 간 관계 추가."""
from_ci = (await db.execute(select(ConfigItem).where(ConfigItem.id == body.from_ci_id))).scalars().first()
to_ci = (await db.execute(select(ConfigItem).where(ConfigItem.id == body.to_ci_id))).scalars().first()
if not from_ci:
raise HTTPException(404, f"출발 CI id={body.from_ci_id} 없음")
if not to_ci:
raise HTTPException(404, f"도착 CI id={body.to_ci_id} 없음")
# 중복 검사
existing = (await db.execute(
select(CIRelation).where(
and_(
CIRelation.from_ci_id == body.from_ci_id,
CIRelation.to_ci_id == body.to_ci_id,
CIRelation.relation_type == body.relation_type.upper(),
)
)
)).scalars().first()
if existing:
raise HTTPException(409, "동일 관계가 이미 존재합니다.")
relation = CIRelation(
from_ci_id = body.from_ci_id,
to_ci_id = body.to_ci_id,
relation_type = body.relation_type.upper(),
description = body.description,
created_by = current_user.username,
)
db.add(relation)
await db.flush()
# 양쪽 CI에 이력 기록
for ci, note in [
(from_ci, f"{body.relation_type} → CI#{body.to_ci_id}({to_ci.name}) 관계 추가"),
(to_ci, f"CI#{body.from_ci_id}({from_ci.name}) ← {body.relation_type} 관계 추가"),
]:
await _log_ci_change(
db, ci,
change_type = CIChangeType.RELATION_ADD.value,
changed_by = current_user.username,
note = note,
)
await db.commit()
await db.refresh(relation)
return relation
@router.delete("/ci/relations/{relation_id}", status_code=204)
async def remove_ci_relation(
relation_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""CI 관계 삭제."""
rel = (await db.execute(select(CIRelation).where(CIRelation.id == relation_id))).scalars().first()
if not rel:
raise HTTPException(404, "관계를 찾을 수 없습니다.")
from_ci = (await db.execute(select(ConfigItem).where(ConfigItem.id == rel.from_ci_id))).scalars().first()
to_ci = (await db.execute(select(ConfigItem).where(ConfigItem.id == rel.to_ci_id))).scalars().first()
for ci in [c for c in [from_ci, to_ci] if c]:
await _log_ci_change(
db, ci,
change_type = CIChangeType.RELATION_DEL.value,
changed_by = current_user.username,
note = f"관계(id={relation_id}, type={rel.relation_type}) 삭제",
)
await db.delete(rel)
await db.commit()
@router.get("/ci/{ci_id}/relations")
async def get_ci_relations(
ci_id: str,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""CI의 모든 관계 조회 (출발+도착 모두)."""
ci = (await db.execute(select(ConfigItem).where(ConfigItem.ci_id == ci_id))).scalars().first()
if not ci:
raise HTTPException(404, f"CI {ci_id} 없음")
# 출발 관계
out_rels = (await db.execute(
select(CIRelation).where(CIRelation.from_ci_id == ci.id)
)).scalars().all()
# 도착 관계
in_rels = (await db.execute(
select(CIRelation).where(CIRelation.to_ci_id == ci.id)
)).scalars().all()
# 관련 CI 이름 보강
async def enrich(rel: CIRelation, direction: str):
other_id = rel.to_ci_id if direction == "outbound" else rel.from_ci_id
other_ci = (await db.execute(select(ConfigItem).where(ConfigItem.id == other_id))).scalars().first()
return {
"id": rel.id,
"direction": direction,
"relation_type": rel.relation_type,
"from_ci_id": rel.from_ci_id,
"to_ci_id": rel.to_ci_id,
"other_ci_id": other_ci.ci_id if other_ci else None,
"other_ci_name": other_ci.name if other_ci else None,
"description": rel.description,
"created_at": rel.created_at.isoformat(),
}
result = []
for r in out_rels:
result.append(await enrich(r, "outbound"))
for r in in_rels:
result.append(await enrich(r, "inbound"))
return {"ci_id": ci_id, "relations": result}
# ── CI 변경 이력 ─────────────────────────────────────────────────────────────
@router.get("/ci/{ci_id}/history", response_model=List[CIChangeLogOut])
async def get_ci_history(
ci_id: str,
limit: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""CI 변경 이력 조회."""
ci = (await db.execute(select(ConfigItem).where(ConfigItem.ci_id == ci_id))).scalars().first()
if not ci:
raise HTTPException(404, f"CI {ci_id} 없음")
logs = (await db.execute(
select(CIChangeLog)
.where(CIChangeLog.ci_id_fk == ci.id)
.order_by(desc(CIChangeLog.changed_at))
.limit(limit)
)).scalars().all()
return logs
# ── 서버 → CI 일괄 등록 ───────────────────────────────────────────────────────
@router.post("/ci/import-servers")
async def import_servers_as_ci(
inst_id: Optional[int] = Query(None, description="특정 기관만 가져오기"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
기존 등록된 서버를 CI로 일괄 등록 (이미 CI가 있는 서버는 스킵).
"""
if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER):
raise HTTPException(403, "권한 부족")
q = select(Server).where(Server.is_active == True)
if inst_id:
q = q.where(Server.inst_id == inst_id)
servers = (await db.execute(q)).scalars().all()
created, skipped = 0, 0
for srv in servers:
# 이미 linked_server_id로 CI가 있으면 스킵
existing = (await db.execute(
select(ConfigItem).where(ConfigItem.linked_server_id == srv.id)
)).scalars().first()
if existing:
skipped += 1
continue
ci_id = await _next_ci_id(db)
# server_role → ci_type 매핑
type_map = {
"WEB": "SERVER", "WAS": "SERVER", "DB": "DATABASE",
"ESB": "MIDDLEWARE", "BATCH": "SERVER",
}
srv_role = getattr(srv, "server_role", "") or ""
ci_type = type_map.get(srv_role.upper(), "SERVER")
ci = ConfigItem(
ci_id = ci_id,
name = srv.server_name,
ci_type = ci_type,
category = srv_role or "SERVER",
status = "ACTIVE" if srv.is_active else "INACTIVE",
owner = srv.maintenance_contact,
install_date = srv.install_date,
retire_date = srv.eol_date,
linked_server_id = srv.id,
inst_id = srv.inst_id,
description = f"서버 자동 등록: {srv.server_name} ({srv.os_type or ''})",
created_by = current_user.username,
)
db.add(ci)
await db.flush()
await _log_ci_change(
db, ci,
change_type = CIChangeType.CREATE.value,
changed_by = current_user.username,
note = f"서버 일괄 등록 (server_id={srv.id})",
)
created += 1
await db.commit()
return {
"created": created,
"skipped": skipped,
"total_servers": len(servers),
}