""" 서버 구성 인벤토리 자동 수집 — SSH 에이전트리스 서버에 설치된 소프트웨어·버전·설정 파일을 SSH로 자동 수집하여 CMDB를 보완. 엔드포인트: POST /api/inventory/collect/{server_id} — 단일 서버 인벤토리 수집 POST /api/inventory/collect-all — 전체 서버 일괄 수집 GET /api/inventory/{server_id} — 서버 인벤토리 조회 GET /api/inventory/diff/{server_id} — 이전 수집과 변경사항 비교 GET /api/inventory/software — 소프트웨어 설치 현황 (전체) """ from __future__ import annotations import json import logging from datetime import datetime from typing import Optional import paramiko from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from sqlalchemy import select, desc 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, ServerInventory logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/inventory", tags=["서버 인벤토리"]) INVENTORY_COMMANDS = { "os_info": "cat /etc/os-release 2>/dev/null | head -5", "kernel": "uname -r", "hostname": "hostname -f 2>/dev/null || hostname", "uptime": "uptime -p 2>/dev/null || uptime", "cpu_model": "grep 'model name' /proc/cpuinfo 2>/dev/null | head -1 | cut -d: -f2 | xargs", "cpu_cores": "nproc", "memory_total": "free -m | awk '/^Mem:/{print $2}'", "disk_total": "df -BG --total 2>/dev/null | tail -1 | awk '{print $2}'", "disk_used": "df -BG --total 2>/dev/null | tail -1 | awk '{print $3}'", "open_ports": "ss -tlnp 2>/dev/null | awk 'NR>1{print $4}' | grep -oE '[0-9]+$' | sort -n | uniq | tr '\n' ','", "running_svcs": "systemctl list-units --type=service --state=running 2>/dev/null | grep '.service' | awk '{print $1}' | tr '\n' ','", "java_ver": "java -version 2>&1 | head -1", "python_ver": "python3 --version 2>/dev/null", "node_ver": "node --version 2>/dev/null || echo none", "nginx_ver": "nginx -v 2>&1 || echo none", "tomcat_ver": "find /opt /usr /home -name 'catalina.sh' 2>/dev/null | head -1 | xargs -I{} sh -c 'sh {} version 2>&1 | grep -i version | head -1' || echo none", "mysql_ver": "mysql --version 2>/dev/null || echo none", "postgres_ver": "psql --version 2>/dev/null || echo none", "installed_pkgs":"dpkg -l 2>/dev/null | grep '^ii' | awk '{print $2\"=\"$3}' | head -50 || rpm -qa --queryformat '%{NAME}=%{VERSION}\n' 2>/dev/null | head -50", "crontabs": "crontab -l 2>/dev/null | grep -v '^#' | grep -v '^$' | head -10", "users": "getent passwd 2>/dev/null | awk -F: '$3>=1000 && $3<65534{print $1}' | tr '\n' ','", "sudoers": "grep -r ALL /etc/sudoers /etc/sudoers.d/ 2>/dev/null | grep -v '^#' | head -5", "last_updates": "tail -5 /var/log/dpkg.log 2>/dev/null || tail -5 /var/log/yum.log 2>/dev/null || echo none", } async def _collect_inventory(server: Server) -> dict: 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) results = {} for key, cmd in INVENTORY_COMMANDS.items(): try: _, stdout, _ = ssh.exec_command(cmd, timeout=8) results[key] = stdout.read().decode('utf-8', 'replace').strip()[:500] except Exception: results[key] = "" ssh.close() return results except Exception as e: logger.warning(f"인벤토리 수집 실패 ({server.ip_addr}): {e}") return {} async def _do_collect(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 data = await _collect_inventory(server) if not data: return inv = ServerInventory( server_id=server_id, data=data, collected_at=datetime.utcnow(), ) db.add(inv) # 서버 정보 업데이트 if data.get("cpu_cores"): try: server.cpu_cores = int(data["cpu_cores"]) except ValueError: pass if data.get("memory_total"): try: server.memory_mb = int(data["memory_total"]) except ValueError: pass await db.commit() @router.post("/collect/{server_id}") async def collect_server_inventory( server_id: int, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): background_tasks.add_task(_do_collect, server_id, db) return {"ok": True, "server_id": server_id, "queued": True} @router.post("/collect-all") async def collect_all_inventory( background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): rows = await db.execute(select(Server).limit(200)) servers = rows.scalars().all() for s in servers: background_tasks.add_task(_do_collect, s.id, db) return {"ok": True, "queued": len(servers)} @router.get("/{server_id}") async def get_inventory( server_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): row = await db.execute( select(ServerInventory).where(ServerInventory.server_id == server_id) .order_by(desc(ServerInventory.collected_at)).limit(1) ) inv = row.scalar_one_or_none() if not inv: raise HTTPException(404, "인벤토리 없음 — 먼저 수집하세요") return {"server_id": server_id, "data": inv.data, "collected_at": inv.collected_at} @router.get("/diff/{server_id}") async def get_inventory_diff( server_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """최근 2개 인벤토리 비교 — 변경사항 반환.""" rows = await db.execute( select(ServerInventory).where(ServerInventory.server_id == server_id) .order_by(desc(ServerInventory.collected_at)).limit(2) ) invs = rows.scalars().all() if len(invs) < 2: return {"server_id": server_id, "diff": {}, "message": "비교할 이전 데이터 없음"} current = invs[0].data or {} previous = invs[1].data or {} diff = {} for key in set(list(current.keys()) + list(previous.keys())): c_val = current.get(key, "") p_val = previous.get(key, "") if c_val != p_val: diff[key] = {"before": p_val[:200], "after": c_val[:200]} return { "server_id": server_id, "current_at": invs[0].collected_at, "previous_at": invs[1].collected_at, "changed_items": len(diff), "diff": diff, } @router.get("/software") async def software_inventory( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """전체 서버 소프트웨어 현황 — Java/Python/Nginx 버전 집계.""" rows = await db.execute( select(ServerInventory, Server.hostname).join( Server, ServerInventory.server_id == Server.id ).order_by(desc(ServerInventory.collected_at)) ) results = [] seen_servers = set() for row in rows.all(): if row.ServerInventory.server_id in seen_servers: continue seen_servers.add(row.ServerInventory.server_id) data = row.ServerInventory.data or {} results.append({ "server": row.hostname, "java": data.get("java_ver", "none"), "python": data.get("python_ver", "none"), "nginx": data.get("nginx_ver", "none"), "node": data.get("node_ver", "none"), "mysql": data.get("mysql_ver", "none"), "postgres": data.get("postgres_ver", "none"), "collected_at": row.ServerInventory.collected_at, }) return results