""" 서비스 의존성 자동 매핑 SSH 경유 netstat/ss 분석으로 서비스 간 upstream/downstream 의존성을 자동 탐지. 엔드포인트: POST /api/depmap/discover/{server_id} — 단일 서버 의존성 탐지 POST /api/depmap/discover-all — 전체 서버 의존성 탐지 GET /api/depmap/ — 의존성 맵 조회 GET /api/depmap/impact/{ci_id} — 특정 CI 영향 범위 분석 DELETE /api/depmap/{dep_id} — 의존성 수동 삭제 """ from __future__ import annotations import logging import re from datetime import datetime import paramiko from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from sqlalchemy import select, or_ from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, require_admin_role from core.ssh_exec import _decrypt_password as decrypt_password from database import get_db from models import User, Server, ServiceDependency logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/depmap", tags=["서비스 의존성 맵"]) async def _ssh_run(server: Server, cmd: str) -> str: try: pw = decrypt_password(server.os_pw_enc) ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) ssh.connect(server.ip_addr, username=server.ssh_user, password=pw, timeout=10) _, stdout, _ = ssh.exec_command(cmd, timeout=15) result = stdout.read().decode('utf-8', 'replace').strip() ssh.close() return result except Exception as e: logger.warning(f"SSH 실패 ({server.ip_addr}): {e}") return "" async def _discover_connections(server: Server) -> list[dict]: """netstat/ss로 ESTABLISHED 연결 분석 → 의존성 추출.""" output = await _ssh_run( server, "ss -tnp state established 2>/dev/null | awk 'NR>1{print $4,$5}' || " "netstat -tnp 2>/dev/null | grep ESTABLISHED | awk '{print $4,$5}'" ) connections = [] seen = set() for line in output.splitlines(): parts = line.split() if len(parts) < 2: continue local, remote = parts[0], parts[1] # 외부 IP:포트 추출 m = re.match(r'(\d+\.\d+\.\d+\.\d+):(\d+)$', remote) if not m: continue remote_ip, remote_port = m.group(1), int(m.group(2)) if remote_ip in ("127.0.0.1", "0.0.0.0"): continue key = f"{remote_ip}:{remote_port}" if key in seen: continue seen.add(key) # 포트 → 서비스 유형 추정 service_map = { 3306: "MySQL", 5432: "PostgreSQL", 6379: "Redis", 27017: "MongoDB", 9200: "Elasticsearch", 80: "HTTP", 443: "HTTPS", 8080: "HTTP-Alt", 2181: "Zookeeper", 9092: "Kafka", 5672: "RabbitMQ", } dep_type = service_map.get(remote_port, f"TCP:{remote_port}") connections.append({ "remote_ip": remote_ip, "remote_port": remote_port, "dependency_type": dep_type, "protocol": "TCP", }) return connections async def _do_discover(server_id: int, db: AsyncSession): """단일 서버 의존성 탐지 (백그라운드).""" srv_row = await db.execute(select(Server).where(Server.id == server_id)) server = srv_row.scalar_one_or_none() if not server: return connections = await _discover_connections(server) new_deps = 0 for conn in connections: # 원격 IP가 CMDB에 있는지 확인 remote_srv_row = await db.execute( select(Server).where(Server.ip_addr == conn["remote_ip"]) ) remote_server = remote_srv_row.scalar_one_or_none() if not remote_server: continue # CMDB에 없는 서버는 스킵 # 중복 체크 existing = await db.execute( select(ServiceDependency).where( ServiceDependency.upstream_ci_id == server_id, ServiceDependency.downstream_ci_id == remote_server.id, ServiceDependency.port == conn["remote_port"], ) ) if existing.scalar_one_or_none(): continue dep = ServiceDependency( upstream_ci_id=server_id, downstream_ci_id=remote_server.id, dependency_type=conn["dependency_type"], port=conn["remote_port"], protocol=conn["protocol"], discovered_at=datetime.utcnow(), ) db.add(dep) new_deps += 1 await db.commit() logger.info(f"서버 {server_id} 의존성 {new_deps}개 발견") @router.post("/discover/{server_id}") async def discover_server_deps( server_id: int, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): background_tasks.add_task(_do_discover, server_id, db) return {"ok": True, "server_id": server_id, "queued": True} @router.post("/discover-all") async def discover_all_deps( background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): rows = await db.execute(select(Server).limit(100)) servers = rows.scalars().all() for s in servers: background_tasks.add_task(_do_discover, s.id, db) return {"ok": True, "queued": len(servers)} @router.get("/") async def get_dependency_map( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): rows = await db.execute( select( ServiceDependency, Server.hostname.label("up_name"), Server.ip_addr.label("up_ip"), ).join(Server, ServiceDependency.upstream_ci_id == Server.id) .limit(500) ) deps = rows.all() return [ { "id": d.ServiceDependency.id, "upstream": {"id": d.ServiceDependency.upstream_ci_id, "name": d.up_name, "ip": d.up_ip}, "downstream_id": d.ServiceDependency.downstream_ci_id, "type": d.ServiceDependency.dependency_type, "port": d.ServiceDependency.port, "discovered_at": d.ServiceDependency.discovered_at, } for d in deps ] @router.get("/impact/{ci_id}") async def get_impact_analysis( ci_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """특정 CI 장애 시 영향 범위 분석 (upstream 서비스 = 영향받는 서비스).""" # ci_id에 의존하는 서비스들 (ci_id가 downstream인 경우) rows = await db.execute( select(ServiceDependency, Server.hostname, Server.ip_addr).join( Server, ServiceDependency.upstream_ci_id == Server.id ).where(ServiceDependency.downstream_ci_id == ci_id) ) impacted = rows.all() # ci_id가 의존하는 서비스들 (ci_id가 upstream인 경우) dep_rows = await db.execute( select(ServiceDependency, Server.hostname, Server.ip_addr).join( Server, ServiceDependency.downstream_ci_id == Server.id ).where(ServiceDependency.upstream_ci_id == ci_id) ) dependencies = dep_rows.all() return { "ci_id": ci_id, "impacted_services": [ {"server": r.hostname, "ip": r.ip_addr, "depends_via": r.ServiceDependency.dependency_type} for r in impacted ], "depends_on": [ {"server": r.hostname, "ip": r.ip_addr, "type": r.ServiceDependency.dependency_type} for r in dependencies ], "blast_radius": len(impacted), } @router.delete("/{dep_id}") async def delete_dependency( dep_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): row = await db.execute(select(ServiceDependency).where(ServiceDependency.id == dep_id)) dep = row.scalar_one_or_none() if not dep: raise HTTPException(404) await db.delete(dep) await db.commit() return {"ok": True}