guardia-itsm/routers/config_inventory.py
2026-06-02 18:48:18 +09:00

208 lines
8.0 KiB
Python

"""
서버 구성 인벤토리 자동 수집 — 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