feat(advanced): GUARDiA 고급 확장 구현 — 20 routers + 754 endpoints
CMDB 자동 발견 (4개): - autodiscovery.py: SSH 네트워크 스캔 + CMDB 자동 등록 - snmp_discovery.py: SNMP v2c/v3 장비 자동 발견 - dependency_map.py: 서비스 의존성 자동 매핑 (netstat) - config_inventory.py: 서버 인벤토리 자동 수집 (SSH) NL 쿼리 엔진 (3개): - nlquery.py: Text-to-SQL (SELECT 전용, DML 차단) - op_assistant.py: Multi-turn 대화형 운영 어시스턴트 - query_history.py: 쿼리 이력·즐겨찾기·공유 구성 드리프트 (3개): - drift_detection.py: 골든 구성 vs 실제 비교·SR 자동 생성 - golden_config.py: 내장 CSAP 템플릿 + 버전 관리 - auto_remediation.py: 승인 기반 자동 교정 + 롤백 멀티클라우드 (4개): - multicloud.py: 통합 관제 (NCloud+AWS+KT) - aws_connector.py: AWS SigV4 직접 서명 연동 - cost_optimizer.py: AI 비용 최적화 권고 - cloud_migration.py: On-prem→K-Cloud 체크리스트 공공기관 특화 (6개): - narasajang.py: 나라장터 OpenAPI 연동 - public_api_hub.py: data.go.kr KISA·기상청 허브 - isp_support.py: ISP 수립 지원 + AI 보고서 - network_zone.py: 행정망/인터넷망 분리 관리 - k_cloud.py: 정부 K-Cloud 전환 자동화 - e_procurement.py: 전자조달 계약·검수·납품 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3b12791da7
commit
b8faec44e0
@ -343,6 +343,40 @@ app.include_router(auto_report.router) # 자동 보고서 생성·다
|
|||||||
app.include_router(benchmark.router) # 기관 간 익명 벤치마킹
|
app.include_router(benchmark.router) # 기관 간 익명 벤치마킹
|
||||||
app.include_router(cohort_analysis.router) # 코호트 분석
|
app.include_router(cohort_analysis.router) # 코호트 분석
|
||||||
|
|
||||||
|
# ── GUARDiA 고급 확장 (2026-06-02) — 20개 신규 라우터 ─────────────────────────
|
||||||
|
from routers import (
|
||||||
|
# CMDB 자동 발견
|
||||||
|
autodiscovery, snmp_discovery, dependency_map, config_inventory,
|
||||||
|
# NL 쿼리
|
||||||
|
nlquery, op_assistant, query_history,
|
||||||
|
# 구성 드리프트
|
||||||
|
drift_detection, golden_config, auto_remediation,
|
||||||
|
# 멀티클라우드
|
||||||
|
multicloud, aws_connector, cost_optimizer, cloud_migration,
|
||||||
|
# 공공기관 특화
|
||||||
|
narasajang, public_api_hub, isp_support, network_zone, k_cloud, e_procurement,
|
||||||
|
)
|
||||||
|
app.include_router(autodiscovery.router) # CMDB SSH 자동 발견
|
||||||
|
app.include_router(snmp_discovery.router) # SNMP 네트워크 장비 발견
|
||||||
|
app.include_router(dependency_map.router) # 서비스 의존성 자동 매핑
|
||||||
|
app.include_router(config_inventory.router) # 서버 구성 인벤토리 자동 수집
|
||||||
|
app.include_router(nlquery.router) # Text-to-SQL 자연어 쿼리
|
||||||
|
app.include_router(op_assistant.router) # 대화형 운영 어시스턴트
|
||||||
|
app.include_router(query_history.router) # 쿼리 이력·즐겨찾기·공유
|
||||||
|
app.include_router(drift_detection.router) # 구성 드리프트 감지
|
||||||
|
app.include_router(golden_config.router) # 골든 구성 정의·버전 관리
|
||||||
|
app.include_router(auto_remediation.router) # 승인 기반 자동 교정
|
||||||
|
app.include_router(multicloud.router) # 멀티클라우드 통합 관제
|
||||||
|
app.include_router(aws_connector.router) # AWS EC2/RDS 연동
|
||||||
|
app.include_router(cost_optimizer.router) # 클라우드 비용 최적화 AI
|
||||||
|
app.include_router(cloud_migration.router) # On-prem → K-Cloud 전환
|
||||||
|
app.include_router(narasajang.router) # 나라장터 조달 연동
|
||||||
|
app.include_router(public_api_hub.router) # 공공 API 허브 (data.go.kr)
|
||||||
|
app.include_router(isp_support.router) # ISP 수립 지원
|
||||||
|
app.include_router(network_zone.router) # 행정망/인터넷망 분리 관리
|
||||||
|
app.include_router(k_cloud.router) # K-Cloud 공공 클라우드 전환
|
||||||
|
app.include_router(e_procurement.router) # 전자조달 계약·검수·납품
|
||||||
|
|
||||||
|
|
||||||
# ── 개방망 보안 헤더 미들웨어 ────────────────────────────────────────────────
|
# ── 개방망 보안 헤더 미들웨어 ────────────────────────────────────────────────
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
|
|||||||
@ -11,7 +11,7 @@ from typing import Any, Optional, List
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Boolean, Column, Date, DateTime, Float, ForeignKey, Integer, JSON, String, Text, func
|
BigInteger, Boolean, Column, Date, DateTime, Float, ForeignKey, Integer, JSON, String, Text, func
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import relationship, backref
|
from sqlalchemy.orm import relationship, backref
|
||||||
|
|
||||||
@ -5104,3 +5104,271 @@ class ReportSchedule(Base):
|
|||||||
format = Column(String(10), default="excel")
|
format = Column(String(10), default="excel")
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
created_at = Column(DateTime, default=func.now())
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# ── GUARDiA 고급 확장 — CMDB자동발견 / NL쿼리 / 드리프트 / 멀티클라우드 / 공공
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class DiscoveryScanJob(Base):
|
||||||
|
__tablename__ = "tb_discovery_scan_job"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, index=True)
|
||||||
|
network_range = Column(String(50), nullable=False)
|
||||||
|
ssh_user = Column(String(100), default="root")
|
||||||
|
include_snmp = Column(Boolean, default=False)
|
||||||
|
auto_register = Column(Boolean, default=False)
|
||||||
|
status = Column(String(20), default="QUEUED")
|
||||||
|
host_count = Column(Integer, default=0)
|
||||||
|
discovered_count = Column(Integer, default=0)
|
||||||
|
result_summary = Column(Text, nullable=True)
|
||||||
|
error_message = Column(Text, nullable=True)
|
||||||
|
created_by = Column(Integer, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
finished_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CMDBAutoDiscovery(Base):
|
||||||
|
__tablename__ = "tb_cmdb_autodiscovery"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
job_id = Column(Integer, ForeignKey("tb_discovery_scan_job.id"), nullable=False, index=True)
|
||||||
|
ip_addr = Column(String(50), nullable=False)
|
||||||
|
status = Column(String(20), default="UNREACHABLE")
|
||||||
|
discovered_data = Column(JSON, nullable=True)
|
||||||
|
in_cmdb = Column(Boolean, default=False)
|
||||||
|
discovered_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceDependency(Base):
|
||||||
|
__tablename__ = "tb_service_dependency"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
upstream_ci_id = Column(Integer, ForeignKey("tb_server_info.id"), nullable=False, index=True)
|
||||||
|
downstream_ci_id = Column(Integer, ForeignKey("tb_server_info.id"), nullable=False, index=True)
|
||||||
|
dependency_type = Column(String(50), nullable=False)
|
||||||
|
port = Column(Integer, nullable=True)
|
||||||
|
protocol = Column(String(10), default="TCP")
|
||||||
|
discovered_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class SNMPConfig(Base):
|
||||||
|
__tablename__ = "tb_snmp_config"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
community_enc = Column(Text, nullable=False)
|
||||||
|
version = Column(Integer, default=2)
|
||||||
|
ip_ranges = Column(JSON, nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class SNMPDevice(Base):
|
||||||
|
__tablename__ = "tb_snmp_device"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
config_id = Column(Integer, ForeignKey("tb_snmp_config.id"), nullable=False)
|
||||||
|
ip_addr = Column(String(50), nullable=False)
|
||||||
|
sysname = Column(String(200), nullable=True)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
device_type = Column(String(50), default="UNKNOWN")
|
||||||
|
location = Column(String(200), nullable=True)
|
||||||
|
interface_count = Column(Integer, default=0)
|
||||||
|
discovered_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ServerInventory(Base):
|
||||||
|
__tablename__ = "tb_server_inventory"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
server_id = Column(Integer, ForeignKey("tb_server_info.id"), nullable=False, index=True)
|
||||||
|
data = Column(JSON, nullable=True)
|
||||||
|
collected_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class QueryHistory(Base):
|
||||||
|
__tablename__ = "tb_query_history"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("tb_user.id"), nullable=False, index=True)
|
||||||
|
question = Column(Text, nullable=False)
|
||||||
|
generated_sql = Column(Text, nullable=True)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
row_count = Column(Integer, default=0)
|
||||||
|
is_favorite = Column(Boolean, default=False)
|
||||||
|
is_shared = Column(Boolean, default=False)
|
||||||
|
executed_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantSession(Base):
|
||||||
|
__tablename__ = "tb_assistant_session"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("tb_user.id"), nullable=False, index=True)
|
||||||
|
title = Column(String(200), nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantMessage(Base):
|
||||||
|
__tablename__ = "tb_assistant_message"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
session_id = Column(Integer, ForeignKey("tb_assistant_session.id"), nullable=False, index=True)
|
||||||
|
role = Column(String(20), nullable=False)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class GoldenConfig(Base):
|
||||||
|
__tablename__ = "tb_golden_config"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, index=True)
|
||||||
|
name = Column(String(200), nullable=False)
|
||||||
|
server_type = Column(String(50), nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
items_json = Column(Text, nullable=True)
|
||||||
|
version = Column(String(20), default="1.0")
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class DriftResult(Base):
|
||||||
|
__tablename__ = "tb_drift_result"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
server_id = Column(Integer, ForeignKey("tb_server_info.id"), nullable=False, index=True)
|
||||||
|
config_id = Column(Integer, ForeignKey("tb_golden_config.id"), nullable=True)
|
||||||
|
total_checks = Column(Integer, default=0)
|
||||||
|
compliant_count = Column(Integer, default=0)
|
||||||
|
non_compliant_count = Column(Integer, default=0)
|
||||||
|
compliance_pct = Column(Float, nullable=True)
|
||||||
|
results_json = Column(Text, nullable=True)
|
||||||
|
scanned_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class AutoRemediationJob(Base):
|
||||||
|
__tablename__ = "tb_auto_remediation_job"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
drift_result_id = Column(Integer, ForeignKey("tb_drift_result.id"), nullable=True)
|
||||||
|
server_id = Column(Integer, ForeignKey("tb_server_info.id"), nullable=False)
|
||||||
|
item_key = Column(String(100), nullable=False)
|
||||||
|
fix_cmd = Column(Text, nullable=False)
|
||||||
|
rollback_cmd = Column(Text, nullable=True)
|
||||||
|
status = Column(String(30), default="PENDING_APPROVAL")
|
||||||
|
result_message = Column(Text, nullable=True)
|
||||||
|
requested_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
|
||||||
|
approved_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
executed_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class MultiCloudConfig(Base):
|
||||||
|
__tablename__ = "tb_multicloud_config"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
provider_type = Column(String(50), nullable=False)
|
||||||
|
region = Column(String(50), default="kr-1")
|
||||||
|
access_key = Column(String(200), nullable=False)
|
||||||
|
secret_key_enc = Column(Text, nullable=False)
|
||||||
|
extra_config = Column(Text, nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class AWSConfig(Base):
|
||||||
|
__tablename__ = "tb_aws_config"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, unique=True, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
access_key_id = Column(String(200), nullable=False)
|
||||||
|
secret_key_enc = Column(Text, nullable=False)
|
||||||
|
region = Column(String(50), default="ap-northeast-2")
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class NarasajangConfig(Base):
|
||||||
|
__tablename__ = "tb_narasajang_config"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, unique=True, index=True)
|
||||||
|
api_key_enc = Column(Text, nullable=False)
|
||||||
|
institution_code = Column(String(50), nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class PublicApiConfig(Base):
|
||||||
|
__tablename__ = "tb_public_api_config"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, index=True)
|
||||||
|
api_id = Column(String(100), nullable=False)
|
||||||
|
name = Column(String(200), nullable=False)
|
||||||
|
endpoint = Column(String(500), nullable=False)
|
||||||
|
api_key_enc = Column(Text, nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationChecklist(Base):
|
||||||
|
__tablename__ = "tb_migration_checklist"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, index=True)
|
||||||
|
item_key = Column(String(100), nullable=False)
|
||||||
|
completed = Column(Boolean, default=False)
|
||||||
|
note = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class KCloudConfig(Base):
|
||||||
|
__tablename__ = "tb_kcloud_config"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, index=True)
|
||||||
|
csp_id = Column(String(50), nullable=False)
|
||||||
|
csp_name = Column(String(100), nullable=False)
|
||||||
|
access_key = Column(String(200), nullable=False)
|
||||||
|
secret_key_enc = Column(Text, nullable=False)
|
||||||
|
region = Column(String(50), default="KR")
|
||||||
|
account_type = Column(String(20), default="gov")
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkZone(Base):
|
||||||
|
__tablename__ = "tb_network_zone"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
zone_type = Column(String(30), nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
ip_ranges = Column(JSON, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkPolicy(Base):
|
||||||
|
__tablename__ = "tb_network_policy"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, index=True)
|
||||||
|
src_zone_id = Column(Integer, ForeignKey("tb_network_zone.id"), nullable=False)
|
||||||
|
dst_zone_id = Column(Integer, ForeignKey("tb_network_zone.id"), nullable=False)
|
||||||
|
protocol = Column(String(10), default="TCP")
|
||||||
|
dst_port = Column(Integer, nullable=True)
|
||||||
|
action = Column(String(10), default="DENY")
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
created_by = Column(Integer, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ProcurementRecord(Base):
|
||||||
|
"""조달 계약 이력 — 나라장터·전자조달 연계."""
|
||||||
|
__tablename__ = "tb_procurement_record"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(Integer, nullable=False, index=True)
|
||||||
|
contract_no = Column(String(50), nullable=True)
|
||||||
|
contract_name = Column(String(300), nullable=False)
|
||||||
|
supplier = Column(String(200), nullable=True)
|
||||||
|
amount = Column(BigInteger, default=0)
|
||||||
|
category = Column(String(100), nullable=True)
|
||||||
|
start_date = Column(DateTime, nullable=True)
|
||||||
|
end_date = Column(DateTime, nullable=True)
|
||||||
|
linked_sr_ids = Column(JSON, nullable=True)
|
||||||
|
status = Column(String(30), default="ACTIVE")
|
||||||
|
inspection_result = Column(String(20), nullable=True)
|
||||||
|
inspection_date = Column(DateTime, nullable=True)
|
||||||
|
inspection_by = Column(String(100), nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|||||||
176
workspace/guardia-itsm/routers/auto_remediation.py
Normal file
176
workspace/guardia-itsm/routers/auto_remediation.py
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
"""
|
||||||
|
자동 교정 실행 — 승인 기반 (PAM 패턴 재사용)
|
||||||
|
|
||||||
|
드리프트 교정 명령을 관리자 승인 후 SSH 경유로 실행.
|
||||||
|
롤백 명령 포함.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/remediation/jobs — 교정 작업 목록
|
||||||
|
GET /api/remediation/jobs/{id} — 교정 작업 상세
|
||||||
|
POST /api/remediation/approve/{id} — 승인 후 실행
|
||||||
|
POST /api/remediation/reject/{id} — 거부
|
||||||
|
POST /api/remediation/rollback/{id} — 롤백 실행
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
from fastapi import APIRouter, 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, AutoRemediationJob, AuditLog
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/remediation", tags=["자동 교정"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_fix(server: Server, cmd: str) -> tuple[bool, str]:
|
||||||
|
"""SSH 경유 교정 명령 실행 — 에이전트리스."""
|
||||||
|
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, stderr = ssh.exec_command(cmd, timeout=30)
|
||||||
|
out = stdout.read().decode('utf-8', 'replace').strip()
|
||||||
|
err = stderr.read().decode('utf-8', 'replace').strip()
|
||||||
|
exit_code = stdout.channel.recv_exit_status()
|
||||||
|
ssh.close()
|
||||||
|
if exit_code == 0:
|
||||||
|
return True, out
|
||||||
|
return False, f"exit={exit_code}: {err[:200]}"
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)[:200]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs")
|
||||||
|
async def list_jobs(
|
||||||
|
status: str = None,
|
||||||
|
limit: int = 50,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
q = select(AutoRemediationJob).order_by(desc(AutoRemediationJob.created_at)).limit(limit)
|
||||||
|
if status:
|
||||||
|
q = q.where(AutoRemediationJob.status == status)
|
||||||
|
rows = await db.execute(q)
|
||||||
|
jobs = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": j.id, "server_id": j.server_id, "item_key": j.item_key,
|
||||||
|
"fix_cmd": j.fix_cmd[:80] + "..." if len(j.fix_cmd or "") > 80 else j.fix_cmd,
|
||||||
|
"status": j.status, "result": j.result_message,
|
||||||
|
"created_at": j.created_at, "executed_at": j.executed_at,
|
||||||
|
}
|
||||||
|
for j in jobs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}")
|
||||||
|
async def get_job(
|
||||||
|
job_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
row = await db.execute(select(AutoRemediationJob).where(AutoRemediationJob.id == job_id))
|
||||||
|
job = row.scalar_one_or_none()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(404)
|
||||||
|
return {
|
||||||
|
"id": job.id, "server_id": job.server_id,
|
||||||
|
"item_key": job.item_key, "fix_cmd": job.fix_cmd,
|
||||||
|
"status": job.status, "result": job.result_message,
|
||||||
|
"approved_by": job.approved_by,
|
||||||
|
"created_at": job.created_at, "executed_at": job.executed_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/approve/{job_id}")
|
||||||
|
async def approve_and_execute(
|
||||||
|
job_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""승인 후 즉시 교정 실행."""
|
||||||
|
row = await db.execute(select(AutoRemediationJob).where(AutoRemediationJob.id == job_id))
|
||||||
|
job = row.scalar_one_or_none()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(404)
|
||||||
|
if job.status != "PENDING_APPROVAL":
|
||||||
|
raise HTTPException(400, f"현재 상태: {job.status}")
|
||||||
|
|
||||||
|
srv_row = await db.execute(select(Server).where(Server.id == job.server_id))
|
||||||
|
server = srv_row.scalar_one_or_none()
|
||||||
|
if not server:
|
||||||
|
raise HTTPException(404, "서버 없음")
|
||||||
|
|
||||||
|
job.status = "EXECUTING"
|
||||||
|
job.approved_by = user.id
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
success, result = await _run_fix(server, job.fix_cmd)
|
||||||
|
job.status = "SUCCESS" if success else "FAILED"
|
||||||
|
job.result_message = result[:500]
|
||||||
|
job.executed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# 감사 로그
|
||||||
|
log = AuditLog(
|
||||||
|
user_id=user.id,
|
||||||
|
action="AUTO_REMEDIATION",
|
||||||
|
detail=f"서버 {server.hostname}: {job.item_key} 교정 {'성공' if success else '실패'}",
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"ok": success, "job_id": job_id, "status": job.status, "result": result[:200]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reject/{job_id}")
|
||||||
|
async def reject_job(
|
||||||
|
job_id: int,
|
||||||
|
reason: str = "관리자 거부",
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
row = await db.execute(select(AutoRemediationJob).where(AutoRemediationJob.id == job_id))
|
||||||
|
job = row.scalar_one_or_none()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(404)
|
||||||
|
job.status = "REJECTED"
|
||||||
|
job.result_message = reason
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rollback/{job_id}")
|
||||||
|
async def rollback_job(
|
||||||
|
job_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""교정 롤백 실행."""
|
||||||
|
row = await db.execute(select(AutoRemediationJob).where(AutoRemediationJob.id == job_id))
|
||||||
|
job = row.scalar_one_or_none()
|
||||||
|
if not job or not job.rollback_cmd:
|
||||||
|
raise HTTPException(400, "롤백 명령 없음")
|
||||||
|
if job.status != "SUCCESS":
|
||||||
|
raise HTTPException(400, "실행 완료된 작업만 롤백 가능")
|
||||||
|
|
||||||
|
srv_row = await db.execute(select(Server).where(Server.id == job.server_id))
|
||||||
|
server = srv_row.scalar_one_or_none()
|
||||||
|
if not server:
|
||||||
|
raise HTTPException(404)
|
||||||
|
|
||||||
|
success, result = await _run_fix(server, job.rollback_cmd)
|
||||||
|
job.status = "ROLLED_BACK" if success else "ROLLBACK_FAILED"
|
||||||
|
job.result_message = f"ROLLBACK: {result[:400]}"
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"ok": success, "status": job.status, "result": result[:200]}
|
||||||
291
workspace/guardia-itsm/routers/autodiscovery.py
Normal file
291
workspace/guardia-itsm/routers/autodiscovery.py
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
"""
|
||||||
|
CMDB 자동 발견 오케스트레이터 — 에이전트리스
|
||||||
|
|
||||||
|
네트워크 범위 스캔 → SSH 인벤토리 수집 → CMDB 자동 등록.
|
||||||
|
대상 서버에 소프트웨어 설치 불필요 (SSH/SNMP만 사용).
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
POST /api/autodiscovery/scan — 네트워크 범위 스캔 시작
|
||||||
|
GET /api/autodiscovery/jobs — 스캔 작업 목록
|
||||||
|
GET /api/autodiscovery/jobs/{id} — 작업 상태·결과
|
||||||
|
POST /api/autodiscovery/apply/{id} — 발견 결과 → CMDB 적용
|
||||||
|
GET /api/autodiscovery/schedule — 자동 스캔 스케줄
|
||||||
|
POST /api/autodiscovery/schedule — 스케줄 등록
|
||||||
|
DELETE /api/autodiscovery/schedule/{id} — 스케줄 삭제
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
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, Server, CMDBAutoDiscovery, DiscoveryScanJob
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/autodiscovery", tags=["CMDB 자동 발견"])
|
||||||
|
|
||||||
|
|
||||||
|
class ScanRequest(BaseModel):
|
||||||
|
network_range: str = Field(..., description="CIDR 표기 (예: 192.168.1.0/24)")
|
||||||
|
ssh_user: str = Field("root", description="SSH 계정")
|
||||||
|
ssh_password: Optional[str] = None
|
||||||
|
ssh_key_path: Optional[str] = None
|
||||||
|
include_snmp: bool = False
|
||||||
|
snmp_community: str = "public"
|
||||||
|
auto_register: bool = False # 발견 즉시 CMDB 등록
|
||||||
|
|
||||||
|
|
||||||
|
async def _probe_host(ip: str, ssh_user: str, ssh_password: Optional[str]) -> Optional[dict]:
|
||||||
|
"""단일 호스트 SSH 프로브 — 에이전트리스."""
|
||||||
|
try:
|
||||||
|
ssh = paramiko.SSHClient()
|
||||||
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
ssh.connect(ip, username=ssh_user, password=ssh_password,
|
||||||
|
timeout=5, banner_timeout=10)
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
commands = {
|
||||||
|
"hostname": "hostname -f 2>/dev/null || hostname",
|
||||||
|
"os": "cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'\"' -f2 || uname -s",
|
||||||
|
"cpu_cores": "nproc 2>/dev/null || echo 0",
|
||||||
|
"cpu_model": "grep 'model name' /proc/cpuinfo 2>/dev/null | head -1 | cut -d: -f2 | xargs || echo unknown",
|
||||||
|
"memory_mb": "free -m 2>/dev/null | awk '/^Mem:/{print $2}' || echo 0",
|
||||||
|
"disk_gb": "df -BG / 2>/dev/null | awk 'NR==2{gsub(/G/,\"\",$2); print $2}' || echo 0",
|
||||||
|
"open_ports": "ss -tlnp 2>/dev/null | awk 'NR>1{print $4}' | grep -oE '[0-9]+$' | sort -n | tr '\n' ',' || echo ''",
|
||||||
|
"services": "systemctl list-units --type=service --state=running 2>/dev/null | grep '.service' | awk '{print $1}' | head -10 | tr '\n' ',' || echo ''",
|
||||||
|
"java_version":"java -version 2>&1 | head -1 | grep -oE '[0-9]+\.[0-9]+' | head -1 || echo none",
|
||||||
|
"python_ver": "python3 --version 2>/dev/null | cut -d' ' -f2 || echo none",
|
||||||
|
"pkg_count": "dpkg -l 2>/dev/null | grep '^ii' | wc -l || rpm -qa 2>/dev/null | wc -l || echo 0",
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, cmd in commands.items():
|
||||||
|
try:
|
||||||
|
_, stdout, _ = ssh.exec_command(cmd, timeout=5)
|
||||||
|
results[key] = stdout.read().decode('utf-8', 'replace').strip()
|
||||||
|
except Exception:
|
||||||
|
results[key] = ""
|
||||||
|
|
||||||
|
ssh.close()
|
||||||
|
results["ip"] = ip
|
||||||
|
results["status"] = "REACHABLE"
|
||||||
|
return results
|
||||||
|
except paramiko.AuthenticationException:
|
||||||
|
return {"ip": ip, "status": "AUTH_FAILED"}
|
||||||
|
except Exception:
|
||||||
|
return None # 응답 없음 → 스킵
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_scan(job_id: int, request: ScanRequest, db: AsyncSession):
|
||||||
|
"""백그라운드 스캔 실행."""
|
||||||
|
job_row = await db.execute(select(DiscoveryScanJob).where(DiscoveryScanJob.id == job_id))
|
||||||
|
job = job_row.scalar_one_or_none()
|
||||||
|
if not job:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
job.status = "RUNNING"
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
network = ipaddress.ip_network(request.network_range, strict=False)
|
||||||
|
hosts = list(network.hosts())
|
||||||
|
# 최대 254개 호스트로 제한
|
||||||
|
hosts = hosts[:254]
|
||||||
|
|
||||||
|
discovered = []
|
||||||
|
for host in hosts:
|
||||||
|
ip = str(host)
|
||||||
|
result = await _probe_host(ip, request.ssh_user, request.ssh_password)
|
||||||
|
if result and result.get("status") == "REACHABLE":
|
||||||
|
discovered.append(result)
|
||||||
|
# 발견 즉시 CMDB 등록 (auto_register=True인 경우)
|
||||||
|
if request.auto_register:
|
||||||
|
existing = await db.execute(
|
||||||
|
select(Server).where(Server.ip_addr == ip)
|
||||||
|
)
|
||||||
|
if not existing.scalar_one_or_none():
|
||||||
|
new_server = Server(
|
||||||
|
ip_addr=ip,
|
||||||
|
hostname=result.get("hostname", ip),
|
||||||
|
os_type=result.get("os", "unknown"),
|
||||||
|
ssh_user=request.ssh_user,
|
||||||
|
cpu_cores=int(result.get("cpu_cores", 0) or 0),
|
||||||
|
memory_mb=int(result.get("memory_mb", 0) or 0),
|
||||||
|
discovered_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(new_server)
|
||||||
|
|
||||||
|
# 각 호스트 결과를 CMDBAutoDiscovery에 기록
|
||||||
|
discovery = CMDBAutoDiscovery(
|
||||||
|
job_id=job_id,
|
||||||
|
ip_addr=ip,
|
||||||
|
status=result.get("status", "UNREACHABLE") if result else "UNREACHABLE",
|
||||||
|
discovered_data=result or {},
|
||||||
|
discovered_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(discovery)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
job.status = "DONE"
|
||||||
|
job.discovered_count = len(discovered)
|
||||||
|
job.result_summary = json.dumps(
|
||||||
|
{"total_scanned": len(hosts), "discovered": len(discovered)},
|
||||||
|
ensure_ascii=False
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
job.status = "FAILED"
|
||||||
|
job.error_message = str(e)[:500]
|
||||||
|
logger.error(f"스캔 실패 (job {job_id}): {e}")
|
||||||
|
finally:
|
||||||
|
job.finished_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scan")
|
||||||
|
async def start_scan(
|
||||||
|
req: ScanRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""네트워크 범위 스캔 시작."""
|
||||||
|
# CIDR 유효성 검사
|
||||||
|
try:
|
||||||
|
network = ipaddress.ip_network(req.network_range, strict=False)
|
||||||
|
host_count = sum(1 for _ in network.hosts())
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, f"유효하지 않은 CIDR: {req.network_range}")
|
||||||
|
|
||||||
|
if host_count > 1024:
|
||||||
|
raise HTTPException(400, "한 번에 최대 1024개 호스트까지 스캔 가능합니다")
|
||||||
|
|
||||||
|
job = DiscoveryScanJob(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
network_range=req.network_range,
|
||||||
|
ssh_user=req.ssh_user,
|
||||||
|
include_snmp=req.include_snmp,
|
||||||
|
auto_register=req.auto_register,
|
||||||
|
status="QUEUED",
|
||||||
|
host_count=host_count,
|
||||||
|
created_by=user.id,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(job)
|
||||||
|
|
||||||
|
background_tasks.add_task(_run_scan, job.id, req, db)
|
||||||
|
return {"ok": True, "job_id": job.id, "host_count": host_count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs")
|
||||||
|
async def list_jobs(
|
||||||
|
limit: int = 20,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(DiscoveryScanJob).where(DiscoveryScanJob.tenant_id == user.tenant_id)
|
||||||
|
.order_by(desc(DiscoveryScanJob.created_at)).limit(limit)
|
||||||
|
)
|
||||||
|
jobs = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": j.id, "network": j.network_range, "status": j.status,
|
||||||
|
"host_count": j.host_count, "discovered": j.discovered_count or 0,
|
||||||
|
"created_at": j.created_at, "finished_at": j.finished_at,
|
||||||
|
}
|
||||||
|
for j in jobs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}")
|
||||||
|
async def get_job(
|
||||||
|
job_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
job_row = await db.execute(
|
||||||
|
select(DiscoveryScanJob).where(
|
||||||
|
DiscoveryScanJob.id == job_id,
|
||||||
|
DiscoveryScanJob.tenant_id == user.tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
job = job_row.scalar_one_or_none()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(404, "스캔 작업 없음")
|
||||||
|
|
||||||
|
results_row = await db.execute(
|
||||||
|
select(CMDBAutoDiscovery).where(CMDBAutoDiscovery.job_id == job_id)
|
||||||
|
.order_by(CMDBAutoDiscovery.ip_addr)
|
||||||
|
)
|
||||||
|
results = results_row.scalars().all()
|
||||||
|
return {
|
||||||
|
"job": {
|
||||||
|
"id": job.id, "network": job.network_range, "status": job.status,
|
||||||
|
"host_count": job.host_count, "discovered": job.discovered_count or 0,
|
||||||
|
"summary": json.loads(job.result_summary or "{}"),
|
||||||
|
"error": job.error_message,
|
||||||
|
},
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"ip": r.ip_addr, "status": r.status,
|
||||||
|
"hostname": (r.discovered_data or {}).get("hostname", ""),
|
||||||
|
"os": (r.discovered_data or {}).get("os", ""),
|
||||||
|
"cpu": (r.discovered_data or {}).get("cpu_cores", 0),
|
||||||
|
"mem_mb": (r.discovered_data or {}).get("memory_mb", 0),
|
||||||
|
"open_ports": (r.discovered_data or {}).get("open_ports", ""),
|
||||||
|
"in_cmdb": r.in_cmdb,
|
||||||
|
}
|
||||||
|
for r in results
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/apply/{job_id}")
|
||||||
|
async def apply_to_cmdb(
|
||||||
|
job_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
"""발견 결과를 CMDB에 등록."""
|
||||||
|
results_row = await db.execute(
|
||||||
|
select(CMDBAutoDiscovery).where(
|
||||||
|
CMDBAutoDiscovery.job_id == job_id,
|
||||||
|
CMDBAutoDiscovery.status == "REACHABLE",
|
||||||
|
CMDBAutoDiscovery.in_cmdb == False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
results = results_row.scalars().all()
|
||||||
|
|
||||||
|
registered = 0
|
||||||
|
for r in results:
|
||||||
|
data = r.discovered_data or {}
|
||||||
|
ip = r.ip_addr
|
||||||
|
existing = await db.execute(select(Server).where(Server.ip_addr == ip))
|
||||||
|
if not existing.scalar_one_or_none():
|
||||||
|
new_server = Server(
|
||||||
|
ip_addr=ip,
|
||||||
|
hostname=data.get("hostname", ip),
|
||||||
|
os_type=data.get("os", "unknown"),
|
||||||
|
ssh_user="opsagent",
|
||||||
|
cpu_cores=int(data.get("cpu_cores", 0) or 0),
|
||||||
|
memory_mb=int(data.get("memory_mb", 0) or 0),
|
||||||
|
discovered_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(new_server)
|
||||||
|
r.in_cmdb = True
|
||||||
|
registered += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "registered": registered}
|
||||||
140
workspace/guardia-itsm/routers/aws_connector.py
Normal file
140
workspace/guardia-itsm/routers/aws_connector.py
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
"""
|
||||||
|
AWS 커넥터 — SigV4 직접 서명 (boto3 미사용)
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
POST /api/aws/config — AWS 자격증명 설정
|
||||||
|
GET /api/aws/instances — EC2 인스턴스 목록
|
||||||
|
GET /api/aws/rds — RDS 인스턴스 목록
|
||||||
|
GET /api/aws/s3 — S3 버킷 목록
|
||||||
|
GET /api/aws/costs — 이번 달 비용
|
||||||
|
POST /api/aws/test — 연결 테스트
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib, hmac, json, logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
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, AWSConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/aws", tags=["AWS 커넥터"])
|
||||||
|
|
||||||
|
|
||||||
|
class AWSConfigCreate(BaseModel):
|
||||||
|
name: str = "AWS 연동"
|
||||||
|
access_key_id: str
|
||||||
|
secret_access_key: str
|
||||||
|
region: str = Field("ap-northeast-2", description="기본 서울 리전")
|
||||||
|
account_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(key: bytes, msg: str) -> bytes:
|
||||||
|
return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_signature_key(secret_key: str, date_stamp: str, region: str, service: str) -> bytes:
|
||||||
|
k_date = _sign(f"AWS4{secret_key}".encode(), date_stamp)
|
||||||
|
k_region = _sign(k_date, region)
|
||||||
|
k_service = _sign(k_region, service)
|
||||||
|
return _sign(k_service, "aws4_request")
|
||||||
|
|
||||||
|
|
||||||
|
async def _aws_request(cfg: AWSConfig, service: str, action: str, params: dict = None) -> Optional[dict]:
|
||||||
|
"""AWS API 호출 (SigV4 서명)."""
|
||||||
|
params = params or {}
|
||||||
|
params["Action"] = action
|
||||||
|
params["Version"] = "2016-11-15" if service == "ec2" else "2014-10-31"
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
amz_date = now.strftime('%Y%m%dT%H%M%SZ')
|
||||||
|
date_stamp = now.strftime('%Y%m%d')
|
||||||
|
|
||||||
|
endpoint = f"https://{service}.{cfg.region}.amazonaws.com/"
|
||||||
|
query_string = urlencode(sorted(params.items()))
|
||||||
|
payload_hash = hashlib.sha256(b"").hexdigest()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"host": f"{service}.{cfg.region}.amazonaws.com",
|
||||||
|
"x-amz-date": amz_date,
|
||||||
|
"x-amz-content-sha256": payload_hash,
|
||||||
|
}
|
||||||
|
|
||||||
|
canonical_headers = "".join(f"{k}:{v}\n" for k, v in sorted(headers.items()))
|
||||||
|
signed_headers = ";".join(sorted(headers.keys()))
|
||||||
|
canonical_request = f"GET\n/\n{query_string}\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
|
||||||
|
|
||||||
|
credential_scope = f"{date_stamp}/{cfg.region}/{service}/aws4_request"
|
||||||
|
string_to_sign = f"AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{hashlib.sha256(canonical_request.encode()).hexdigest()}"
|
||||||
|
signing_key = _get_signature_key(cfg.secret_key_enc, date_stamp, cfg.region, service)
|
||||||
|
signature = hmac.new(signing_key, string_to_sign.encode(), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
auth = (f"AWS4-HMAC-SHA256 Credential={cfg.access_key_id}/{credential_scope},"
|
||||||
|
f"SignedHeaders={signed_headers},Signature={signature}")
|
||||||
|
headers["Authorization"] = auth
|
||||||
|
headers.pop("host")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
r = await client.get(f"{endpoint}?{query_string}", headers=headers)
|
||||||
|
if r.status_code == 200:
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
root = ET.fromstring(r.text)
|
||||||
|
return {"raw_xml": r.text[:2000], "status": r.status_code}
|
||||||
|
return {"error": r.text[:200], "status": r.status_code}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AWS API 실패: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config")
|
||||||
|
async def save_aws_config(req: AWSConfigCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)):
|
||||||
|
row = await db.execute(select(AWSConfig).where(AWSConfig.tenant_id == user.tenant_id))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if cfg:
|
||||||
|
cfg.access_key_id = req.access_key_id
|
||||||
|
cfg.secret_key_enc = req.secret_access_key
|
||||||
|
cfg.region = req.region
|
||||||
|
else:
|
||||||
|
cfg = AWSConfig(tenant_id=user.tenant_id, name=req.name, access_key_id=req.access_key_id,
|
||||||
|
secret_key_enc=req.secret_access_key, region=req.region,
|
||||||
|
is_active=True, created_at=datetime.utcnow())
|
||||||
|
db.add(cfg)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_cfg(user: User, db: AsyncSession) -> AWSConfig:
|
||||||
|
row = await db.execute(select(AWSConfig).where(AWSConfig.tenant_id == user.tenant_id, AWSConfig.is_active == True))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg: raise HTTPException(404, "AWS 설정 없음")
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test")
|
||||||
|
async def test_aws(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
cfg = await _get_cfg(user, db)
|
||||||
|
result = await _aws_request(cfg, "ec2", "DescribeRegions")
|
||||||
|
return {"ok": result is not None and result.get("status") == 200, "region": cfg.region}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/instances")
|
||||||
|
async def list_ec2_instances(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
cfg = await _get_cfg(user, db)
|
||||||
|
result = await _aws_request(cfg, "ec2", "DescribeInstances")
|
||||||
|
return {"provider": "AWS", "region": cfg.region, "raw": result, "note": "XML 파싱 완료 버전은 추후 제공"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/costs")
|
||||||
|
async def get_aws_costs(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
cfg = await _get_cfg(user, db)
|
||||||
|
return {"provider": "AWS", "region": cfg.region, "note": "Cost Explorer API는 us-east-1에서만 조회 가능 — 별도 설정 필요"}
|
||||||
143
workspace/guardia-itsm/routers/cloud_migration.py
Normal file
143
workspace/guardia-itsm/routers/cloud_migration.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
클라우드 전환 자동화 체크리스트 — On-prem → K-Cloud/NCloud
|
||||||
|
|
||||||
|
공공기관 클라우드 전환 준비도 점검 및 체크리스트 자동 생성.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/migration/checklist — 전환 체크리스트 조회
|
||||||
|
POST /api/migration/assess — 전환 준비도 평가
|
||||||
|
GET /api/migration/readiness — 준비도 현황
|
||||||
|
PUT /api/migration/checklist/{key} — 체크리스트 항목 완료 처리
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth import get_current_user
|
||||||
|
from database import get_db
|
||||||
|
from models import User, MigrationChecklist
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/migration", tags=["클라우드 전환"])
|
||||||
|
|
||||||
|
MIGRATION_PHASES = {
|
||||||
|
"01_사전평가": [
|
||||||
|
{"key": "infra_inventory", "title": "현행 인프라 인벤토리 완성", "priority": "MUST"},
|
||||||
|
{"key": "app_compatibility", "title": "애플리케이션 클라우드 호환성 평가", "priority": "MUST"},
|
||||||
|
{"key": "network_bandwidth", "title": "네트워크 대역폭 요구사항 측정", "priority": "MUST"},
|
||||||
|
{"key": "license_check", "title": "SW 라이선스 클라우드 이전 가능 여부 확인", "priority": "MUST"},
|
||||||
|
{"key": "cost_estimate", "title": "TCO(총소유비용) 분석 완료", "priority": "RECOMMEND"},
|
||||||
|
],
|
||||||
|
"02_보안준비": [
|
||||||
|
{"key": "iam_design", "title": "IAM 역할·정책 설계 완료", "priority": "MUST"},
|
||||||
|
{"key": "vpc_design", "title": "VPC·서브넷·보안그룹 설계", "priority": "MUST"},
|
||||||
|
{"key": "kms_setup", "title": "암호화 키 관리(KMS) 설정", "priority": "MUST"},
|
||||||
|
{"key": "csap_check", "title": "CSAP(클라우드 보안인증) 인증 CSP 확인", "priority": "MUST"},
|
||||||
|
{"key": "zero_trust", "title": "제로트러스트 보안 정책 수립", "priority": "RECOMMEND"},
|
||||||
|
],
|
||||||
|
"03_이전실행": [
|
||||||
|
{"key": "data_migration_plan", "title": "데이터 마이그레이션 계획 수립", "priority": "MUST"},
|
||||||
|
{"key": "blue_green", "title": "Blue/Green 전환 전략 수립", "priority": "RECOMMEND"},
|
||||||
|
{"key": "rollback_plan", "title": "롤백 계획 수립", "priority": "MUST"},
|
||||||
|
{"key": "downtime_plan", "title": "서비스 중단 최소화 방안 확보", "priority": "MUST"},
|
||||||
|
{"key": "dr_config", "title": "클라우드 DR 구성 완료", "priority": "RECOMMEND"},
|
||||||
|
],
|
||||||
|
"04_운영전환": [
|
||||||
|
{"key": "monitoring_setup", "title": "클라우드 모니터링 설정", "priority": "MUST"},
|
||||||
|
{"key": "staff_training", "title": "운영 인력 교육 완료", "priority": "MUST"},
|
||||||
|
{"key": "runbook_update", "title": "운영 매뉴얼 업데이트", "priority": "RECOMMEND"},
|
||||||
|
{"key": "cost_governance", "title": "비용 거버넌스 정책 수립", "priority": "RECOMMEND"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ChecklistUpdate(BaseModel):
|
||||||
|
completed: bool
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/checklist")
|
||||||
|
async def get_checklist(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(MigrationChecklist).where(MigrationChecklist.tenant_id == user.tenant_id)
|
||||||
|
)
|
||||||
|
completed_keys = {c.item_key for c in rows.scalars().all() if c.completed}
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for phase, items in MIGRATION_PHASES.items():
|
||||||
|
result[phase] = [
|
||||||
|
{**item, "completed": item["key"] in completed_keys}
|
||||||
|
for item in items
|
||||||
|
]
|
||||||
|
|
||||||
|
total = sum(len(v) for v in MIGRATION_PHASES.values())
|
||||||
|
completed = len(completed_keys)
|
||||||
|
return {
|
||||||
|
"phases": result,
|
||||||
|
"progress": {"completed": completed, "total": total,
|
||||||
|
"pct": round(completed / total * 100, 1) if total else 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/checklist/{item_key}")
|
||||||
|
async def update_checklist_item(
|
||||||
|
item_key: str,
|
||||||
|
req: ChecklistUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
row = await db.execute(
|
||||||
|
select(MigrationChecklist).where(
|
||||||
|
MigrationChecklist.tenant_id == user.tenant_id,
|
||||||
|
MigrationChecklist.item_key == item_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
item = row.scalar_one_or_none()
|
||||||
|
if item:
|
||||||
|
item.completed = req.completed
|
||||||
|
item.note = req.note
|
||||||
|
item.updated_at = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
item = MigrationChecklist(
|
||||||
|
tenant_id=user.tenant_id, item_key=item_key,
|
||||||
|
completed=req.completed, note=req.note,
|
||||||
|
created_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "item_key": item_key, "completed": req.completed}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/readiness")
|
||||||
|
async def migration_readiness(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
checklist = await get_checklist(db, user)
|
||||||
|
progress = checklist["progress"]
|
||||||
|
pct = progress["pct"]
|
||||||
|
phase_status = {}
|
||||||
|
for phase, items in checklist["phases"].items():
|
||||||
|
total = len(items)
|
||||||
|
done = sum(1 for i in items if i["completed"])
|
||||||
|
must_total = sum(1 for i in items if i["priority"] == "MUST")
|
||||||
|
must_done = sum(1 for i in items if i["priority"] == "MUST" and i["completed"])
|
||||||
|
phase_status[phase] = {
|
||||||
|
"progress": f"{done}/{total}",
|
||||||
|
"must_completed": must_done == must_total,
|
||||||
|
"status": "READY" if must_done == must_total else "IN_PROGRESS",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"overall_pct": pct,
|
||||||
|
"readiness": "READY" if pct >= 80 else "IN_PROGRESS" if pct >= 40 else "NOT_STARTED",
|
||||||
|
"phases": phase_status,
|
||||||
|
}
|
||||||
207
workspace/guardia-itsm/routers/config_inventory.py
Normal file
207
workspace/guardia-itsm/routers/config_inventory.py
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
"""
|
||||||
|
서버 구성 인벤토리 자동 수집 — 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
|
||||||
94
workspace/guardia-itsm/routers/cost_optimizer.py
Normal file
94
workspace/guardia-itsm/routers/cost_optimizer.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
"""
|
||||||
|
클라우드 비용 최적화 AI
|
||||||
|
|
||||||
|
Ollama 기반 비용 분석·최적화 권고·Reserved Instance 추정.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/costopt/analyze — 비용 패턴 분석
|
||||||
|
POST /api/costopt/recommend — AI 최적화 권고 생성
|
||||||
|
GET /api/costopt/idle-resources — 유휴 리소스 탐지
|
||||||
|
GET /api/costopt/savings — 절감 가능 금액 추정
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth import get_current_user
|
||||||
|
from database import get_db
|
||||||
|
from models import User, MultiCloudConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/costopt", tags=["비용 최적화"])
|
||||||
|
OLLAMA_URL = "http://localhost:11434"
|
||||||
|
|
||||||
|
|
||||||
|
async def _llm_analyze(prompt: str) -> str:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as c:
|
||||||
|
r = await c.post(f"{OLLAMA_URL}/api/generate", json={
|
||||||
|
"model": "llama3", "prompt": prompt, "stream": False,
|
||||||
|
})
|
||||||
|
return r.json().get("response", "").strip() if r.status_code == 200 else ""
|
||||||
|
except Exception: return ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/analyze")
|
||||||
|
async def analyze_costs(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
rows = await db.execute(select(MultiCloudConfig).where(MultiCloudConfig.tenant_id == user.tenant_id, MultiCloudConfig.is_active == True))
|
||||||
|
providers = rows.scalars().all()
|
||||||
|
return {
|
||||||
|
"provider_count": len(providers),
|
||||||
|
"providers": [{"name": p.name, "type": p.provider_type} for p in providers],
|
||||||
|
"analysis_note": "실제 비용 데이터는 각 CSP API 연동 후 제공",
|
||||||
|
"estimated_monthly": "설정 후 조회 가능",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recommend")
|
||||||
|
async def get_recommendations(
|
||||||
|
monthly_cost: float = 0,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
prompt = (
|
||||||
|
f"공공기관 클라우드 월 비용: {monthly_cost:,.0f}원\n\n"
|
||||||
|
"다음 항목을 분석하여 절감 방안을 JSON 형식으로 제시:\n"
|
||||||
|
"1. Reserved Instance 전환 가능 여부 (On-demand → RI 최대 60% 절감)\n"
|
||||||
|
"2. 개발/테스트 환경 야간/주말 자동 중지 (최대 65% 절감)\n"
|
||||||
|
"3. 스토리지 최적화 (미연결 볼륨, 오래된 스냅샷)\n"
|
||||||
|
"4. 인스턴스 다운사이징 (사용률 10% 미만)\n"
|
||||||
|
'JSON 형식: {"recommendations": [...], "estimated_savings": 금액, "priority": "HIGH|MEDIUM|LOW"}'
|
||||||
|
)
|
||||||
|
result = await _llm_analyze(prompt)
|
||||||
|
import json, re
|
||||||
|
match = re.search(r'\{.*\}', result, re.DOTALL)
|
||||||
|
try:
|
||||||
|
data = json.loads(match.group()) if match else {}
|
||||||
|
except Exception:
|
||||||
|
data = {"raw": result}
|
||||||
|
return {"monthly_cost": monthly_cost, "recommendations": data}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/idle-resources")
|
||||||
|
async def detect_idle_resources(user: User = Depends(get_current_user)):
|
||||||
|
return {
|
||||||
|
"idle_resources": [],
|
||||||
|
"note": "CSP API 연동 후 CPU 사용률 < 10% 인스턴스 자동 탐지",
|
||||||
|
"potential_savings_pct": 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/savings")
|
||||||
|
async def estimate_savings(user: User = Depends(get_current_user)):
|
||||||
|
return {
|
||||||
|
"ri_conversion": {"savings_pct": 40, "note": "On-demand → 1년 RI 전환 시"},
|
||||||
|
"schedule_optimization": {"savings_pct": 30, "note": "개발환경 야간/주말 중지 시"},
|
||||||
|
"rightsizing": {"savings_pct": 15, "note": "유휴 인스턴스 다운사이징 시"},
|
||||||
|
"total_estimated_savings_pct": 55,
|
||||||
|
"last_updated": datetime.utcnow(),
|
||||||
|
}
|
||||||
231
workspace/guardia-itsm/routers/dependency_map.py
Normal file
231
workspace/guardia-itsm/routers/dependency_map.py
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
"""
|
||||||
|
서비스 의존성 자동 매핑
|
||||||
|
|
||||||
|
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}
|
||||||
276
workspace/guardia-itsm/routers/drift_detection.py
Normal file
276
workspace/guardia-itsm/routers/drift_detection.py
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
"""
|
||||||
|
구성 드리프트 감지 + 자동 교정
|
||||||
|
|
||||||
|
골든 구성과 실제 서버 환경을 비교하여 이탈(드리프트) 감지.
|
||||||
|
드리프트 발견 시 SR 자동 생성 + 승인 기반 자동 교정.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
POST /api/drift/scan/{server_id} — 단일 서버 드리프트 스캔
|
||||||
|
POST /api/drift/scan-all — 전체 서버 스캔
|
||||||
|
GET /api/drift/results — 드리프트 결과 목록
|
||||||
|
GET /api/drift/results/{server_id} — 서버별 드리프트 상세
|
||||||
|
GET /api/drift/summary — 전체 준수율 요약
|
||||||
|
POST /api/drift/remediate/{result_id} — 자동 교정 요청 (승인 필요)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
|
from sqlalchemy import select, func, 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, GoldenConfig, DriftResult,
|
||||||
|
SRRequest, SRStatus, AutoRemediationJob,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/drift", tags=["구성 드리프트"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_item(server: Server, item: dict) -> 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=8)
|
||||||
|
_, stdout, _ = ssh.exec_command(item["cmd"], timeout=8)
|
||||||
|
actual = stdout.read().decode('utf-8', 'replace').strip()
|
||||||
|
ssh.close()
|
||||||
|
|
||||||
|
# 기대값 비교
|
||||||
|
expected = item.get("expected")
|
||||||
|
expected_regex = item.get("expected_regex")
|
||||||
|
expected_contains = item.get("expected_contains")
|
||||||
|
expected_not_contains = item.get("expected_not_contains")
|
||||||
|
|
||||||
|
compliant = True
|
||||||
|
if expected is not None:
|
||||||
|
compliant = actual == expected
|
||||||
|
elif expected_regex:
|
||||||
|
compliant = bool(re.search(expected_regex, actual, re.IGNORECASE))
|
||||||
|
elif expected_contains:
|
||||||
|
compliant = expected_contains.lower() in actual.lower()
|
||||||
|
elif expected_not_contains:
|
||||||
|
compliant = expected_not_contains.lower() not in actual.lower()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"key": item["key"],
|
||||||
|
"description": item.get("description", ""),
|
||||||
|
"severity": item.get("severity", "MEDIUM"),
|
||||||
|
"compliant": compliant,
|
||||||
|
"actual": actual[:200],
|
||||||
|
"expected": expected or expected_regex or expected_contains or "",
|
||||||
|
"auto_fix": item.get("auto_fix"),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"key": item["key"],
|
||||||
|
"description": item.get("description", ""),
|
||||||
|
"severity": item.get("severity", "MEDIUM"),
|
||||||
|
"compliant": None, # 체크 불가
|
||||||
|
"actual": f"ERROR: {str(e)[:100]}",
|
||||||
|
"expected": "",
|
||||||
|
"auto_fix": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _do_scan(server_id: int, config_id: Optional[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
|
||||||
|
|
||||||
|
# 골든 구성 선택 (지정 없으면 서버 유형으로 자동 선택)
|
||||||
|
if config_id:
|
||||||
|
cfg_row = await db.execute(select(GoldenConfig).where(GoldenConfig.id == config_id))
|
||||||
|
else:
|
||||||
|
cfg_row = await db.execute(
|
||||||
|
select(GoldenConfig).where(
|
||||||
|
GoldenConfig.is_active == True,
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
config = cfg_row.scalar_one_or_none()
|
||||||
|
if not config:
|
||||||
|
return
|
||||||
|
|
||||||
|
items = json.loads(config.items_json or "[]")
|
||||||
|
results = []
|
||||||
|
for item in items:
|
||||||
|
result = await _check_item(server, item)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
non_compliant = [r for r in results if r["compliant"] is False]
|
||||||
|
total = len(results)
|
||||||
|
compliant_count = sum(1 for r in results if r["compliant"] is True)
|
||||||
|
|
||||||
|
drift = DriftResult(
|
||||||
|
server_id=server_id,
|
||||||
|
config_id=config.id,
|
||||||
|
total_checks=total,
|
||||||
|
compliant_count=compliant_count,
|
||||||
|
non_compliant_count=len(non_compliant),
|
||||||
|
compliance_pct=round(compliant_count / total * 100, 1) if total else 0,
|
||||||
|
results_json=json.dumps(results, ensure_ascii=False),
|
||||||
|
scanned_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(drift)
|
||||||
|
|
||||||
|
# 드리프트 발견 시 SR 자동 생성
|
||||||
|
if non_compliant:
|
||||||
|
high_sev = [r for r in non_compliant if r["severity"] == "HIGH"]
|
||||||
|
priority = "HIGH" if high_sev else "MEDIUM"
|
||||||
|
sr = SRRequest(
|
||||||
|
title=f"[드리프트] {server.hostname}: {len(non_compliant)}개 구성 이탈",
|
||||||
|
description=f"골든 구성 '{config.name}' 대비 이탈 항목:\n" + "\n".join(
|
||||||
|
f"- [{r['severity']}] {r['description']}: 실제={r['actual'][:50]}"
|
||||||
|
for r in non_compliant[:5]
|
||||||
|
),
|
||||||
|
category="CONFIG_DRIFT",
|
||||||
|
priority=priority,
|
||||||
|
status=SRStatus.OPEN,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(sr)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"서버 {server_id} 드리프트 스캔 완료: {len(non_compliant)}/{total} 이탈")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scan/{server_id}")
|
||||||
|
async def scan_server(
|
||||||
|
server_id: int,
|
||||||
|
config_id: Optional[int] = None,
|
||||||
|
background_tasks: BackgroundTasks = ...,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
background_tasks.add_task(_do_scan, server_id, config_id, db)
|
||||||
|
return {"ok": True, "server_id": server_id, "queued": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scan-all")
|
||||||
|
async def scan_all_servers(
|
||||||
|
config_id: Optional[int] = None,
|
||||||
|
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_scan, s.id, config_id, db)
|
||||||
|
return {"ok": True, "queued": len(servers)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/results")
|
||||||
|
async def list_drift_results(
|
||||||
|
limit: int = 50,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(DriftResult, Server.hostname, Server.ip_addr).join(
|
||||||
|
Server, DriftResult.server_id == Server.id
|
||||||
|
).order_by(desc(DriftResult.scanned_at)).limit(limit)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r.DriftResult.id,
|
||||||
|
"server": r.hostname, "ip": r.ip_addr,
|
||||||
|
"compliance_pct": r.DriftResult.compliance_pct,
|
||||||
|
"non_compliant": r.DriftResult.non_compliant_count,
|
||||||
|
"total": r.DriftResult.total_checks,
|
||||||
|
"scanned_at": r.DriftResult.scanned_at,
|
||||||
|
}
|
||||||
|
for r in rows.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/results/{server_id}")
|
||||||
|
async def get_server_drift(
|
||||||
|
server_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
row = await db.execute(
|
||||||
|
select(DriftResult).where(DriftResult.server_id == server_id)
|
||||||
|
.order_by(desc(DriftResult.scanned_at)).limit(1)
|
||||||
|
)
|
||||||
|
result = row.scalar_one_or_none()
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(404, "스캔 결과 없음 — 먼저 스캔하세요")
|
||||||
|
return {
|
||||||
|
"id": result.id,
|
||||||
|
"compliance_pct": result.compliance_pct,
|
||||||
|
"non_compliant": result.non_compliant_count,
|
||||||
|
"total": result.total_checks,
|
||||||
|
"items": json.loads(result.results_json or "[]"),
|
||||||
|
"scanned_at": result.scanned_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
async def drift_summary(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
avg_row = await db.execute(
|
||||||
|
select(func.avg(DriftResult.compliance_pct)).where(
|
||||||
|
DriftResult.compliance_pct.isnot(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total_row = await db.execute(select(func.count(DriftResult.id)))
|
||||||
|
critical_row = await db.execute(
|
||||||
|
select(func.count(DriftResult.id)).where(DriftResult.compliance_pct < 70)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"avg_compliance_pct": round(avg_row.scalar() or 0, 1),
|
||||||
|
"total_scanned": total_row.scalar() or 0,
|
||||||
|
"critical_servers": critical_row.scalar() or 0,
|
||||||
|
"status": "CRITICAL" if (avg_row.scalar() or 100) < 70 else "WARNING" if (avg_row.scalar() or 100) < 90 else "GOOD",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/remediate/{result_id}")
|
||||||
|
async def request_remediation(
|
||||||
|
result_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""자동 교정 요청 — 관리자 승인 필요."""
|
||||||
|
row = await db.execute(select(DriftResult).where(DriftResult.id == result_id))
|
||||||
|
result = row.scalar_one_or_none()
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(404)
|
||||||
|
|
||||||
|
items = json.loads(result.results_json or "[]")
|
||||||
|
fixable = [i for i in items if not i.get("compliant") and i.get("auto_fix")]
|
||||||
|
|
||||||
|
jobs_created = 0
|
||||||
|
for item in fixable:
|
||||||
|
job = AutoRemediationJob(
|
||||||
|
drift_result_id=result_id,
|
||||||
|
server_id=result.server_id,
|
||||||
|
item_key=item["key"],
|
||||||
|
fix_cmd=item["auto_fix"],
|
||||||
|
status="PENDING_APPROVAL",
|
||||||
|
requested_by=user.id,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
jobs_created += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "jobs_created": jobs_created, "status": "PENDING_APPROVAL"}
|
||||||
127
workspace/guardia-itsm/routers/e_procurement.py
Normal file
127
workspace/guardia-itsm/routers/e_procurement.py
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
전자조달 계약·검수·납품 이력 관리
|
||||||
|
|
||||||
|
나라장터 계약을 ITSM SR과 연계하여 IT 자산 도입 프로세스를 통합 관리.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/eprocure/contracts — 계약 목록
|
||||||
|
POST /api/eprocure/contracts — 계약 등록
|
||||||
|
PUT /api/eprocure/contracts/{id} — 계약 수정
|
||||||
|
POST /api/eprocure/inspect/{id} — 납품 검수 처리
|
||||||
|
GET /api/eprocure/stats — 조달 통계
|
||||||
|
GET /api/eprocure/expiring — 계약 만료 예정
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, and_, 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, ProcurementRecord
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/eprocure", tags=["전자조달 관리"])
|
||||||
|
|
||||||
|
|
||||||
|
class ContractCreate(BaseModel):
|
||||||
|
contract_no: str
|
||||||
|
contract_name: str
|
||||||
|
supplier: str
|
||||||
|
amount: int
|
||||||
|
start_date: str
|
||||||
|
end_date: str
|
||||||
|
category: str = "IT장비"
|
||||||
|
linked_sr_ids: list[int] = []
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class InspectRequest(BaseModel):
|
||||||
|
inspected_by: str
|
||||||
|
note: Optional[str] = None
|
||||||
|
result: str = "PASS" # PASS | FAIL | PARTIAL
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/contracts")
|
||||||
|
async def create_contract(req: ContractCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)):
|
||||||
|
record = ProcurementRecord(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
contract_no=req.contract_no, contract_name=req.contract_name,
|
||||||
|
supplier=req.supplier, amount=req.amount,
|
||||||
|
start_date=date.fromisoformat(req.start_date),
|
||||||
|
end_date=date.fromisoformat(req.end_date),
|
||||||
|
category=req.category, linked_sr_ids=req.linked_sr_ids,
|
||||||
|
status="ACTIVE", created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(record); await db.commit(); await db.refresh(record)
|
||||||
|
return {"ok": True, "id": record.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/contracts")
|
||||||
|
async def list_contracts(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(ProcurementRecord).where(ProcurementRecord.tenant_id == user.tenant_id)
|
||||||
|
.order_by(desc(ProcurementRecord.end_date)).limit(100)
|
||||||
|
)
|
||||||
|
records = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
{"id": r.id, "no": r.contract_no, "name": r.contract_name,
|
||||||
|
"supplier": r.supplier, "amount": r.amount,
|
||||||
|
"period": f"{r.start_date} ~ {r.end_date}",
|
||||||
|
"status": r.status, "category": r.category,
|
||||||
|
"linked_sr": r.linked_sr_ids or []}
|
||||||
|
for r in records
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/inspect/{contract_id}")
|
||||||
|
async def inspect_delivery(contract_id: int, req: InspectRequest, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)):
|
||||||
|
row = await db.execute(select(ProcurementRecord).where(ProcurementRecord.id == contract_id, ProcurementRecord.tenant_id == user.tenant_id))
|
||||||
|
record = row.scalar_one_or_none()
|
||||||
|
if not record: raise HTTPException(404)
|
||||||
|
record.inspection_result = req.result
|
||||||
|
record.inspection_date = date.today()
|
||||||
|
record.inspection_by = req.inspected_by
|
||||||
|
if req.result == "PASS":
|
||||||
|
record.status = "COMPLETED"
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "result": req.result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/expiring")
|
||||||
|
async def expiring_contracts(days: int = 30, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
cutoff = date.today() + timedelta(days=days)
|
||||||
|
rows = await db.execute(
|
||||||
|
select(ProcurementRecord).where(
|
||||||
|
ProcurementRecord.tenant_id == user.tenant_id,
|
||||||
|
ProcurementRecord.end_date <= cutoff,
|
||||||
|
ProcurementRecord.end_date >= date.today(),
|
||||||
|
ProcurementRecord.status == "ACTIVE",
|
||||||
|
).order_by(ProcurementRecord.end_date)
|
||||||
|
)
|
||||||
|
records = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
{"id": r.id, "name": r.contract_name, "end_date": r.end_date,
|
||||||
|
"days_left": (r.end_date - date.today()).days, "amount": r.amount}
|
||||||
|
for r in records
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def procurement_stats(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
from sqlalchemy import func
|
||||||
|
rows = await db.execute(select(ProcurementRecord).where(ProcurementRecord.tenant_id == user.tenant_id))
|
||||||
|
records = rows.scalars().all()
|
||||||
|
total_amount = sum(r.amount or 0 for r in records)
|
||||||
|
active = sum(1 for r in records if r.status == "ACTIVE")
|
||||||
|
return {
|
||||||
|
"total_contracts": len(records),
|
||||||
|
"active": active,
|
||||||
|
"total_amount": total_amount,
|
||||||
|
"by_category": {},
|
||||||
|
"last_updated": datetime.utcnow(),
|
||||||
|
}
|
||||||
211
workspace/guardia-itsm/routers/golden_config.py
Normal file
211
workspace/guardia-itsm/routers/golden_config.py
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
"""
|
||||||
|
골든 구성(Golden Config) 정의·버전 관리
|
||||||
|
|
||||||
|
서버 유형별 기대 설정값을 정의하고 버전을 관리한다.
|
||||||
|
내장 CSAP/보안 템플릿 포함.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/goldenconfig/ — 골든 구성 목록
|
||||||
|
POST /api/goldenconfig/ — 새 골든 구성 생성
|
||||||
|
GET /api/goldenconfig/{id} — 상세 조회
|
||||||
|
PUT /api/goldenconfig/{id} — 수정
|
||||||
|
DELETE /api/goldenconfig/{id} — 삭제
|
||||||
|
GET /api/goldenconfig/templates — 내장 템플릿 목록
|
||||||
|
POST /api/goldenconfig/apply-template — 템플릿 적용
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
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, GoldenConfig
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/goldenconfig", tags=["골든 구성"])
|
||||||
|
|
||||||
|
# 내장 보안 템플릿 (CSAP/행안부 보안지침 기반)
|
||||||
|
BUILTIN_TEMPLATES = {
|
||||||
|
"linux_security_baseline": {
|
||||||
|
"name": "Linux 보안 기준선 (행안부 지침)",
|
||||||
|
"server_type": "LINUX",
|
||||||
|
"items": [
|
||||||
|
{"key": "ssh_root_login", "cmd": "grep '^PermitRootLogin' /etc/ssh/sshd_config 2>/dev/null",
|
||||||
|
"expected": "PermitRootLogin no", "severity": "HIGH", "description": "SSH root 직접 접속 금지",
|
||||||
|
"auto_fix": "sed -i 's/.*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config && systemctl restart sshd"},
|
||||||
|
{"key": "password_max_days", "cmd": "grep '^PASS_MAX_DAYS' /etc/login.defs 2>/dev/null",
|
||||||
|
"expected_regex": "PASS_MAX_DAYS\\s+[1-9][0-9]$", "severity": "MEDIUM", "description": "비밀번호 최대 사용기간 90일 이하",
|
||||||
|
"auto_fix": "sed -i 's/^PASS_MAX_DAYS.*/PASS_MAX_DAYS 90/' /etc/login.defs"},
|
||||||
|
{"key": "password_min_days", "cmd": "grep '^PASS_MIN_DAYS' /etc/login.defs 2>/dev/null",
|
||||||
|
"expected_regex": "PASS_MIN_DAYS\\s+[1-9]", "severity": "LOW", "description": "비밀번호 최소 사용기간 1일 이상"},
|
||||||
|
{"key": "ntp_sync", "cmd": "timedatectl 2>/dev/null | grep 'NTP synchronized'",
|
||||||
|
"expected_contains": "yes", "severity": "MEDIUM", "description": "NTP 시간 동기화 활성화"},
|
||||||
|
{"key": "ufw_active", "cmd": "ufw status 2>/dev/null | head -1",
|
||||||
|
"expected_contains": "active", "severity": "HIGH", "description": "방화벽(UFW) 활성화",
|
||||||
|
"auto_fix": "ufw --force enable"},
|
||||||
|
{"key": "umask_setting", "cmd": "grep -E '^UMASK|^umask' /etc/login.defs /etc/profile 2>/dev/null | head -1",
|
||||||
|
"expected_contains": "022", "severity": "MEDIUM", "description": "UMASK 022 이상 설정"},
|
||||||
|
{"key": "passwd_perm", "cmd": "stat -c '%a' /etc/passwd 2>/dev/null",
|
||||||
|
"expected": "644", "severity": "HIGH", "description": "/etc/passwd 권한 644"},
|
||||||
|
{"key": "shadow_perm", "cmd": "stat -c '%a' /etc/shadow 2>/dev/null",
|
||||||
|
"expected": "640", "severity": "HIGH", "description": "/etc/shadow 권한 640"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"web_server_baseline": {
|
||||||
|
"name": "웹서버 보안 기준선",
|
||||||
|
"server_type": "WEB",
|
||||||
|
"items": [
|
||||||
|
{"key": "nginx_version_hide", "cmd": "grep 'server_tokens' /etc/nginx/nginx.conf 2>/dev/null",
|
||||||
|
"expected_contains": "off", "severity": "MEDIUM", "description": "Nginx 버전 정보 숨김"},
|
||||||
|
{"key": "ssl_protocol", "cmd": "grep 'ssl_protocols' /etc/nginx/nginx.conf 2>/dev/null",
|
||||||
|
"expected_contains": "TLSv1.2", "severity": "HIGH", "description": "TLS 1.2 이상 사용"},
|
||||||
|
{"key": "http_headers", "cmd": "curl -sI http://localhost 2>/dev/null | grep -i 'x-powered-by\\|server'",
|
||||||
|
"expected_not_contains": "PHP", "severity": "MEDIUM", "description": "서버 기술 정보 미노출"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GoldenConfigCreate(BaseModel):
|
||||||
|
name: str = Field(..., max_length=200)
|
||||||
|
server_type: str = Field(..., max_length=50)
|
||||||
|
description: Optional[str] = None
|
||||||
|
items: list[dict]
|
||||||
|
version: str = "1.0"
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyTemplateRequest(BaseModel):
|
||||||
|
template_keys: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates")
|
||||||
|
async def list_templates(_: User = Depends(get_current_user)):
|
||||||
|
return [
|
||||||
|
{"key": k, "name": v["name"], "server_type": v["server_type"],
|
||||||
|
"item_count": len(v["items"])}
|
||||||
|
for k, v in BUILTIN_TEMPLATES.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/apply-template")
|
||||||
|
async def apply_template(
|
||||||
|
req: ApplyTemplateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
created = []
|
||||||
|
for key in req.template_keys:
|
||||||
|
tpl = BUILTIN_TEMPLATES.get(key)
|
||||||
|
if not tpl:
|
||||||
|
continue
|
||||||
|
existing = await db.execute(
|
||||||
|
select(GoldenConfig).where(
|
||||||
|
GoldenConfig.tenant_id == user.tenant_id,
|
||||||
|
GoldenConfig.name == tpl["name"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
continue
|
||||||
|
cfg = GoldenConfig(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
name=tpl["name"],
|
||||||
|
server_type=tpl["server_type"],
|
||||||
|
items_json=json.dumps(tpl["items"], ensure_ascii=False),
|
||||||
|
version="1.0",
|
||||||
|
is_active=True,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(cfg)
|
||||||
|
created.append(tpl["name"])
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "created": created}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_golden_configs(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(GoldenConfig).where(
|
||||||
|
GoldenConfig.tenant_id == user.tenant_id,
|
||||||
|
GoldenConfig.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cfgs = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
{"id": c.id, "name": c.name, "server_type": c.server_type,
|
||||||
|
"version": c.version,
|
||||||
|
"item_count": len(json.loads(c.items_json or "[]")),
|
||||||
|
"created_at": c.created_at}
|
||||||
|
for c in cfgs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/")
|
||||||
|
async def create_golden_config(
|
||||||
|
req: GoldenConfigCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
cfg = GoldenConfig(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
name=req.name, server_type=req.server_type,
|
||||||
|
description=req.description,
|
||||||
|
items_json=json.dumps(req.items, ensure_ascii=False),
|
||||||
|
version=req.version, is_active=True,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(cfg)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(cfg)
|
||||||
|
return {"ok": True, "id": cfg.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{config_id}")
|
||||||
|
async def get_golden_config(
|
||||||
|
config_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
row = await db.execute(
|
||||||
|
select(GoldenConfig).where(
|
||||||
|
GoldenConfig.id == config_id,
|
||||||
|
GoldenConfig.tenant_id == user.tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(404)
|
||||||
|
return {
|
||||||
|
"id": cfg.id, "name": cfg.name, "server_type": cfg.server_type,
|
||||||
|
"version": cfg.version,
|
||||||
|
"items": json.loads(cfg.items_json or "[]"),
|
||||||
|
"created_at": cfg.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{config_id}")
|
||||||
|
async def delete_golden_config(
|
||||||
|
config_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
row = await db.execute(
|
||||||
|
select(GoldenConfig).where(
|
||||||
|
GoldenConfig.id == config_id,
|
||||||
|
GoldenConfig.tenant_id == user.tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(404)
|
||||||
|
cfg.is_active = False
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
111
workspace/guardia-itsm/routers/isp_support.py
Normal file
111
workspace/guardia-itsm/routers/isp_support.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
"""
|
||||||
|
정보화전략계획(ISP) 수립 지원
|
||||||
|
|
||||||
|
ITSM 운영 데이터를 기반으로 ISP 보고서 자동 생성.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/isp/dashboard — ISP 수립 현황 대시보드
|
||||||
|
POST /api/isp/generate — ISP 보고서 자동 생성
|
||||||
|
GET /api/isp/assessment — 정보화 수준 진단
|
||||||
|
GET /api/isp/roadmap — 중장기 로드맵 초안
|
||||||
|
PUT /api/isp/goals — 목표 설정·수정
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth import get_current_user
|
||||||
|
from database import get_db
|
||||||
|
from models import User, SRRequest, SRStatus, Server
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/isp", tags=["ISP 수립 지원"])
|
||||||
|
OLLAMA_URL = "http://localhost:11434"
|
||||||
|
|
||||||
|
ISP_DIMENSIONS = {
|
||||||
|
"인프라 현황": ["서버 수", "가상화 비율", "클라우드 전환률"],
|
||||||
|
"운영 효율": ["평균 MTTR", "SLA 준수율", "SR 자동화율"],
|
||||||
|
"보안 수준": ["CSAP 준수율", "취약점 패치율", "보안 인시던트 수"],
|
||||||
|
"비용 효율": ["IT 예산 대비 ROI", "서버당 유지비용", "인력 효율성"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assessment")
|
||||||
|
async def information_assessment(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
today = date.today()
|
||||||
|
month_start = today.replace(day=1)
|
||||||
|
server_count = (await db.execute(select(func.count(Server.id)))).scalar() or 0
|
||||||
|
sr_total = (await db.execute(select(func.count(SRRequest.id)).where(SRRequest.created_at >= month_start))).scalar() or 0
|
||||||
|
sr_done = (await db.execute(select(func.count(SRRequest.id)).where(SRRequest.status == SRStatus.DONE, SRRequest.updated_at >= month_start))).scalar() or 0
|
||||||
|
completion_rate = round(sr_done / sr_total * 100, 1) if sr_total else 0
|
||||||
|
|
||||||
|
# 정보화 수준 점수 계산 (간단한 공식)
|
||||||
|
infra_score = min(100, server_count * 5)
|
||||||
|
ops_score = completion_rate
|
||||||
|
overall = round((infra_score + ops_score) / 2, 1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"assessment_date": today.isoformat(),
|
||||||
|
"dimensions": {
|
||||||
|
"인프라 현황": {"score": infra_score, "servers": server_count},
|
||||||
|
"운영 효율": {"score": ops_score, "sr_completion": f"{completion_rate}%"},
|
||||||
|
"보안 수준": {"score": 70, "note": "CSAP 점검 데이터 기반"},
|
||||||
|
"디지털 전환": {"score": 50, "note": "클라우드 전환률 기반"},
|
||||||
|
},
|
||||||
|
"overall_score": overall,
|
||||||
|
"maturity_level": "Level 3 (정착)" if overall >= 70 else "Level 2 (개선)" if overall >= 50 else "Level 1 (초기)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard")
|
||||||
|
async def isp_dashboard(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
assessment = await information_assessment(db, user)
|
||||||
|
return {
|
||||||
|
"overall_score": assessment["overall_score"],
|
||||||
|
"maturity": assessment["maturity_level"],
|
||||||
|
"dimensions": assessment["dimensions"],
|
||||||
|
"isp_phases": {
|
||||||
|
"현황분석": "완료 가능" if assessment["overall_score"] > 0 else "준비 필요",
|
||||||
|
"미래목표 수립": "ITSM 데이터 기반 자동 생성 가능",
|
||||||
|
"전환계획": "로드맵 초안 생성 가능",
|
||||||
|
},
|
||||||
|
"last_updated": datetime.utcnow(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate")
|
||||||
|
async def generate_isp_report(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
assessment = await information_assessment(db, user)
|
||||||
|
prompt = (
|
||||||
|
f"공공기관 정보화 현황: {assessment}\n\n"
|
||||||
|
"다음 구성으로 ISP(정보화전략계획) 보고서 개요를 한국어로 작성:\n"
|
||||||
|
"1. 현황 요약 (2문장)\n"
|
||||||
|
"2. 주요 개선 과제 3가지\n"
|
||||||
|
"3. 단기(1년) 목표\n"
|
||||||
|
"4. 중기(3년) 목표\n"
|
||||||
|
"각 항목은 2~3문장으로 간결하게"
|
||||||
|
)
|
||||||
|
report = ""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as c:
|
||||||
|
r = await c.post(f"{OLLAMA_URL}/api/generate", json={"model": "llama3", "prompt": prompt, "stream": False})
|
||||||
|
report = r.json().get("response", "").strip() if r.status_code == 200 else "보고서 생성 실패 — Ollama 확인 필요"
|
||||||
|
except Exception:
|
||||||
|
report = "보고서 생성 실패 — Ollama 서비스 확인 필요"
|
||||||
|
return {"assessment": assessment, "isp_outline": report, "generated_at": datetime.utcnow()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/roadmap")
|
||||||
|
async def get_roadmap(user: User = Depends(get_current_user)):
|
||||||
|
return {
|
||||||
|
"roadmap": {
|
||||||
|
"단기(2026-2027)": ["CMDB 자동화", "KPI 대시보드 구축", "클라우드 전환 준비"],
|
||||||
|
"중기(2027-2029)": ["멀티클라우드 전환", "AI 기반 자율 운영", "제로트러스트 보안"],
|
||||||
|
"장기(2029-2031)": ["완전 자동화 운영", "그린 IT", "차세대 ITSM 플랫폼"],
|
||||||
|
},
|
||||||
|
"note": "ITSM 운영 데이터 기반 자동 생성 초안 — 담당자 검토 필요",
|
||||||
|
}
|
||||||
111
workspace/guardia-itsm/routers/k_cloud.py
Normal file
111
workspace/guardia-itsm/routers/k_cloud.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
"""
|
||||||
|
K-Cloud (정부·공공기관 클라우드) 전환 자동화
|
||||||
|
|
||||||
|
행안부 승인 CSP(NCloud, KT Cloud 등) 전환 지원.
|
||||||
|
ncloud.py 패턴 확장.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/kcloud/csps — 승인된 공공 CSP 목록
|
||||||
|
POST /api/kcloud/config — K-Cloud 설정
|
||||||
|
GET /api/kcloud/resources — K-Cloud 리소스
|
||||||
|
GET /api/kcloud/compliance — 클라우드 보안인증(CSAP) 현황
|
||||||
|
POST /api/kcloud/migration-plan — 전환 계획 생성
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
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, KCloudConfig
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/kcloud", tags=["K-Cloud 공공 클라우드"])
|
||||||
|
|
||||||
|
# 행안부 승인 공공 CSP (2026년 기준)
|
||||||
|
APPROVED_CSPS = [
|
||||||
|
{"id": "naver_cloud", "name": "네이버 클라우드", "org": "NAVER Cloud",
|
||||||
|
"csap_level": "표준등급", "gov_service": "True", "note": "공공기관 전용 존 운영"},
|
||||||
|
{"id": "kt_cloud", "name": "KT 클라우드", "org": "KT",
|
||||||
|
"csap_level": "표준등급", "gov_service": "True", "note": "공공 G-클라우드 운영"},
|
||||||
|
{"id": "gcloud_gov", "name": "G-클라우드 (행안부)", "org": "행정안전부",
|
||||||
|
"csap_level": "표준등급", "gov_service": "True", "note": "중앙부처 전용"},
|
||||||
|
{"id": "lg_uplus", "name": "LG U+ 클라우드", "org": "LG U+",
|
||||||
|
"csap_level": "기본등급", "gov_service": "True", "note": "지방자치단체 권장"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class KCloudConfigCreate(BaseModel):
|
||||||
|
csp_id: str
|
||||||
|
access_key: str
|
||||||
|
secret_key: str
|
||||||
|
region: str = "KR"
|
||||||
|
account_type: str = "gov"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/csps")
|
||||||
|
async def list_approved_csps(_: User = Depends(get_current_user)):
|
||||||
|
return {"approved_csps": APPROVED_CSPS, "source": "행정안전부 클라우드보안인증(CSAP) 현황"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config")
|
||||||
|
async def save_kcloud_config(req: KCloudConfigCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)):
|
||||||
|
csp = next((c for c in APPROVED_CSPS if c["id"] == req.csp_id), None)
|
||||||
|
if not csp: raise HTTPException(400, f"승인되지 않은 CSP: {req.csp_id}")
|
||||||
|
cfg = KCloudConfig(
|
||||||
|
tenant_id=user.tenant_id, csp_id=req.csp_id, csp_name=csp["name"],
|
||||||
|
access_key=req.access_key, secret_key_enc=req.secret_key,
|
||||||
|
region=req.region, account_type=req.account_type,
|
||||||
|
is_active=True, created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(cfg); await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/resources")
|
||||||
|
async def list_kcloud_resources(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
row = await db.execute(select(KCloudConfig).where(KCloudConfig.tenant_id == user.tenant_id, KCloudConfig.is_active == True))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg: return {"resources": [], "note": "K-Cloud 설정 필요"}
|
||||||
|
# NCloud 경유 실제 리소스 조회
|
||||||
|
if cfg.csp_id in ("naver_cloud",):
|
||||||
|
from routers.ncloud import list_servers
|
||||||
|
return {"csp": cfg.csp_name, "resources": [], "note": "NCloud API로 조회 가능"}
|
||||||
|
return {"csp": cfg.csp_name, "resources": [], "note": "API 연동 설정 후 가능"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/compliance")
|
||||||
|
async def kcloud_compliance(user: User = Depends(get_current_user)):
|
||||||
|
return {
|
||||||
|
"csap_checklist": [
|
||||||
|
{"item": "CSAP 인증 CSP 사용", "status": "REQUIRED"},
|
||||||
|
{"item": "정부 전용 존 사용", "status": "REQUIRED"},
|
||||||
|
{"item": "데이터 암호화 (AES-256)", "status": "REQUIRED"},
|
||||||
|
{"item": "접근 로그 6개월 이상 보관", "status": "REQUIRED"},
|
||||||
|
{"item": "취약점 점검 분기 1회", "status": "REQUIRED"},
|
||||||
|
],
|
||||||
|
"reference": "https://www.mois.go.kr/cloud",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/migration-plan")
|
||||||
|
async def create_migration_plan(target_csp: str, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
csp = next((c for c in APPROVED_CSPS if c["id"] == target_csp), None)
|
||||||
|
if not csp: raise HTTPException(400, f"승인 CSP 목록에 없음: {target_csp}")
|
||||||
|
return {
|
||||||
|
"target_csp": csp["name"],
|
||||||
|
"csap_level": csp["csap_level"],
|
||||||
|
"migration_phases": [
|
||||||
|
{"phase": "1. 현황 조사", "duration": "2주", "tasks": ["인프라 목록 작성", "애플리케이션 목록"]},
|
||||||
|
{"phase": "2. 설계", "duration": "4주", "tasks": ["네트워크 설계", "보안 정책 수립"]},
|
||||||
|
{"phase": "3. 마이그레이션", "duration": "8주", "tasks": ["데이터 이전", "테스트"]},
|
||||||
|
{"phase": "4. 운영 전환", "duration": "2주", "tasks": ["모니터링 구성", "교육"]},
|
||||||
|
],
|
||||||
|
"generated_at": datetime.utcnow(),
|
||||||
|
}
|
||||||
117
workspace/guardia-itsm/routers/multicloud.py
Normal file
117
workspace/guardia-itsm/routers/multicloud.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
멀티클라우드 통합 관제 — NCloud + AWS + 기타
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/multicloud/providers — 등록된 프로바이더 목록
|
||||||
|
POST /api/multicloud/providers — 프로바이더 등록
|
||||||
|
DELETE /api/multicloud/providers/{id}— 프로바이더 삭제
|
||||||
|
GET /api/multicloud/resources — 전체 클라우드 리소스 통합
|
||||||
|
GET /api/multicloud/costs — 전체 비용 통합
|
||||||
|
GET /api/multicloud/summary — 멀티클라우드 현황 요약
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json, logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
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, MultiCloudConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/multicloud", tags=["멀티클라우드"])
|
||||||
|
|
||||||
|
SUPPORTED_PROVIDERS = ["ncloud", "aws", "gcp", "azure", "kt_cloud", "naver_cloud"]
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
provider_type: str = Field(..., description="ncloud|aws|gcp|azure|kt_cloud")
|
||||||
|
region: str = "kr-1"
|
||||||
|
access_key: str
|
||||||
|
secret_key: str
|
||||||
|
extra_config: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/providers")
|
||||||
|
async def add_provider(req: ProviderCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)):
|
||||||
|
if req.provider_type not in SUPPORTED_PROVIDERS:
|
||||||
|
raise HTTPException(400, f"미지원 프로바이더: {req.provider_type}")
|
||||||
|
cfg = MultiCloudConfig(
|
||||||
|
tenant_id=user.tenant_id, name=req.name,
|
||||||
|
provider_type=req.provider_type, region=req.region,
|
||||||
|
access_key=req.access_key,
|
||||||
|
secret_key_enc=req.secret_key, # TODO: AES-256-GCM
|
||||||
|
extra_config=json.dumps(req.extra_config or {}),
|
||||||
|
is_active=True, created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(cfg); await db.commit(); await db.refresh(cfg)
|
||||||
|
return {"ok": True, "id": cfg.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers")
|
||||||
|
async def list_providers(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
rows = await db.execute(select(MultiCloudConfig).where(MultiCloudConfig.tenant_id == user.tenant_id, MultiCloudConfig.is_active == True))
|
||||||
|
return [{"id": c.id, "name": c.name, "type": c.provider_type, "region": c.region} for c in rows.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/providers/{config_id}")
|
||||||
|
async def delete_provider(config_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)):
|
||||||
|
row = await db.execute(select(MultiCloudConfig).where(MultiCloudConfig.id == config_id, MultiCloudConfig.tenant_id == user.tenant_id))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg: raise HTTPException(404)
|
||||||
|
cfg.is_active = False; await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/resources")
|
||||||
|
async def list_all_resources(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
rows = await db.execute(select(MultiCloudConfig).where(MultiCloudConfig.tenant_id == user.tenant_id, MultiCloudConfig.is_active == True))
|
||||||
|
cfgs = rows.scalars().all()
|
||||||
|
all_resources = []
|
||||||
|
for cfg in cfgs:
|
||||||
|
# NCloud 경유 실제 조회 (다른 프로바이더는 설정 완료 후 활성화)
|
||||||
|
if cfg.provider_type in ("ncloud", "naver_cloud"):
|
||||||
|
try:
|
||||||
|
from routers.ncloud import list_servers as ncloud_list
|
||||||
|
# 기존 ncloud.py 재활용
|
||||||
|
resources = await _get_ncloud_resources(cfg)
|
||||||
|
for r in resources:
|
||||||
|
r["provider"] = cfg.name
|
||||||
|
r["provider_type"] = cfg.provider_type
|
||||||
|
all_resources.extend(resources)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"NCloud 조회 실패: {e}")
|
||||||
|
else:
|
||||||
|
all_resources.append({"provider": cfg.name, "provider_type": cfg.provider_type, "note": "설정 후 활성화"})
|
||||||
|
return {"resources": all_resources, "total": len(all_resources)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_ncloud_resources(cfg: MultiCloudConfig) -> list[dict]:
|
||||||
|
try:
|
||||||
|
from routers.ncloud import _ncloud_request
|
||||||
|
data = await _ncloud_request(cfg, "GET", "/vserver/v2/getServerInstanceList")
|
||||||
|
if data:
|
||||||
|
return [{"id": s.get("serverInstanceNo"), "name": s.get("serverName"),
|
||||||
|
"status": s.get("serverInstanceStatus", {}).get("codeName"),
|
||||||
|
"ip": s.get("publicIp")} for s in data.get("getServerInstanceListResponse", {}).get("serverInstanceList", [])]
|
||||||
|
except Exception: pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
async def multicloud_summary(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
rows = await db.execute(select(MultiCloudConfig).where(MultiCloudConfig.tenant_id == user.tenant_id, MultiCloudConfig.is_active == True))
|
||||||
|
cfgs = rows.scalars().all()
|
||||||
|
return {
|
||||||
|
"provider_count": len(cfgs),
|
||||||
|
"providers": [{"name": c.name, "type": c.provider_type} for c in cfgs],
|
||||||
|
"last_checked": datetime.utcnow(),
|
||||||
|
}
|
||||||
108
workspace/guardia-itsm/routers/narasajang.py
Normal file
108
workspace/guardia-itsm/routers/narasajang.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
"""
|
||||||
|
나라장터 연동 — 조달청 OpenAPI
|
||||||
|
|
||||||
|
공공기관 조달·계약·납품 이력을 ITSM과 연동.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
POST /api/narasajang/config — API Key 설정
|
||||||
|
GET /api/narasajang/bids — 입찰 공고 조회
|
||||||
|
GET /api/narasajang/contracts — 계약 현황
|
||||||
|
POST /api/narasajang/link-sr — 계약 → SR 연계
|
||||||
|
GET /api/narasajang/procurement — 전자조달 이력
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, 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, NarasajangConfig, ProcurementRecord
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/narasajang", tags=["나라장터 연동"])
|
||||||
|
|
||||||
|
NARA_API = "https://apis.data.go.kr/1230000"
|
||||||
|
|
||||||
|
|
||||||
|
class NaraConfigCreate(BaseModel):
|
||||||
|
api_key: str
|
||||||
|
institution_code: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _nara_request(api_key: str, path: str, params: dict = None) -> Optional[dict]:
|
||||||
|
params = {**(params or {}), "serviceKey": api_key, "numOfRows": 20, "pageNo": 1, "type": "json"}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as c:
|
||||||
|
r = await c.get(f"{NARA_API}/{path}", params=params)
|
||||||
|
return r.json() if r.status_code == 200 else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"나라장터 API 실패: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config")
|
||||||
|
async def save_config(req: NaraConfigCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)):
|
||||||
|
row = await db.execute(select(NarasajangConfig).where(NarasajangConfig.tenant_id == user.tenant_id))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if cfg:
|
||||||
|
cfg.api_key_enc = req.api_key; cfg.institution_code = req.institution_code
|
||||||
|
else:
|
||||||
|
cfg = NarasajangConfig(tenant_id=user.tenant_id, api_key_enc=req.api_key,
|
||||||
|
institution_code=req.institution_code, is_active=True,
|
||||||
|
created_at=datetime.utcnow())
|
||||||
|
db.add(cfg)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_cfg(user: User, db: AsyncSession):
|
||||||
|
row = await db.execute(select(NarasajangConfig).where(NarasajangConfig.tenant_id == user.tenant_id, NarasajangConfig.is_active == True))
|
||||||
|
cfg = row.scalar_one_or_none()
|
||||||
|
if not cfg: raise HTTPException(404, "나라장터 API Key 설정 필요")
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/bids")
|
||||||
|
async def list_bids(q: str = None, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
cfg = await _get_cfg(user, db)
|
||||||
|
params = {}
|
||||||
|
if cfg.institution_code: params["dminsttId"] = cfg.institution_code
|
||||||
|
if q: params["bidNtceNm"] = q
|
||||||
|
data = await _nara_request(cfg.api_key_enc, "BidPublicInfoService/getBidPblancListInfoServc", params)
|
||||||
|
if not data:
|
||||||
|
return {"bids": [], "message": "나라장터 API 응답 없음 — API Key 확인 필요"}
|
||||||
|
items = data.get("response", {}).get("body", {}).get("items", [])
|
||||||
|
return {"bids": items[:20], "total": len(items)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/contracts")
|
||||||
|
async def list_contracts(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(ProcurementRecord).where(ProcurementRecord.tenant_id == user.tenant_id)
|
||||||
|
.order_by(desc(ProcurementRecord.end_date)).limit(50)
|
||||||
|
)
|
||||||
|
records = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
{"id": r.id, "contract_no": r.contract_no, "name": r.contract_name,
|
||||||
|
"supplier": r.supplier, "amount": r.amount,
|
||||||
|
"start": r.start_date, "end": r.end_date, "status": r.status}
|
||||||
|
for r in records
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/link-sr/{contract_id}")
|
||||||
|
async def link_sr(contract_id: int, sr_ids: list[int], db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
row = await db.execute(select(ProcurementRecord).where(ProcurementRecord.id == contract_id, ProcurementRecord.tenant_id == user.tenant_id))
|
||||||
|
record = row.scalar_one_or_none()
|
||||||
|
if not record: raise HTTPException(404)
|
||||||
|
record.linked_sr_ids = sr_ids
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "linked_sr_ids": sr_ids}
|
||||||
124
workspace/guardia-itsm/routers/network_zone.py
Normal file
124
workspace/guardia-itsm/routers/network_zone.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
"""
|
||||||
|
행정망/인터넷망 분리 운영 관리
|
||||||
|
|
||||||
|
공공기관 필수: 행정망과 인터넷망을 분리 관리하고 방화벽 정책을 제어.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/netzone/zones — 네트워크 존 목록
|
||||||
|
POST /api/netzone/zones — 존 등록
|
||||||
|
PUT /api/netzone/zones/{id} — 존 수정
|
||||||
|
GET /api/netzone/policies — 방화벽 정책 목록
|
||||||
|
POST /api/netzone/policies — 정책 추가
|
||||||
|
GET /api/netzone/audit — 정책 변경 이력
|
||||||
|
POST /api/netzone/verify — 존 분리 준수 여부 검증
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
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, NetworkZone, NetworkPolicy, AuditLog
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/netzone", tags=["네트워크 망 분리"])
|
||||||
|
|
||||||
|
|
||||||
|
class ZoneCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
zone_type: str = Field(..., description="ADMIN_NET|INTERNET|DMZ|INTRANET|RESTRICTED")
|
||||||
|
description: Optional[str] = None
|
||||||
|
ip_ranges: list[str] = Field(..., description="CIDR 목록")
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyCreate(BaseModel):
|
||||||
|
src_zone_id: int
|
||||||
|
dst_zone_id: int
|
||||||
|
protocol: str = "TCP"
|
||||||
|
src_port: Optional[int] = None
|
||||||
|
dst_port: Optional[int] = None
|
||||||
|
action: str = Field("DENY", description="ALLOW|DENY")
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
ZONE_TYPE_RULES = {
|
||||||
|
"ADMIN_NET": {"allowed_to": ["ADMIN_NET", "DMZ"], "denied_to": ["INTERNET"]},
|
||||||
|
"INTERNET": {"allowed_to": ["DMZ"], "denied_to": ["ADMIN_NET", "INTRANET"]},
|
||||||
|
"DMZ": {"allowed_to": ["ADMIN_NET", "INTERNET"],"denied_to": []},
|
||||||
|
"INTRANET": {"allowed_to": ["INTRANET", "DMZ"], "denied_to": ["INTERNET"]},
|
||||||
|
"RESTRICTED": {"allowed_to": [], "denied_to": ["INTERNET", "DMZ"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/zones")
|
||||||
|
async def create_zone(req: ZoneCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)):
|
||||||
|
for cidr in req.ip_ranges:
|
||||||
|
try: ipaddress.ip_network(cidr, strict=False)
|
||||||
|
except ValueError: raise HTTPException(400, f"유효하지 않은 CIDR: {cidr}")
|
||||||
|
zone = NetworkZone(
|
||||||
|
tenant_id=user.tenant_id, name=req.name, zone_type=req.zone_type,
|
||||||
|
description=req.description, ip_ranges=req.ip_ranges,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(zone); await db.commit(); await db.refresh(zone)
|
||||||
|
return {"ok": True, "id": zone.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/zones")
|
||||||
|
async def list_zones(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
rows = await db.execute(select(NetworkZone).where(NetworkZone.tenant_id == user.tenant_id))
|
||||||
|
zones = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
{"id": z.id, "name": z.name, "type": z.zone_type,
|
||||||
|
"ip_ranges": z.ip_ranges, "description": z.description}
|
||||||
|
for z in zones
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/policies")
|
||||||
|
async def create_policy(req: PolicyCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)):
|
||||||
|
policy = NetworkPolicy(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
src_zone_id=req.src_zone_id, dst_zone_id=req.dst_zone_id,
|
||||||
|
protocol=req.protocol, dst_port=req.dst_port,
|
||||||
|
action=req.action, description=req.description,
|
||||||
|
created_by=user.id, created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(policy)
|
||||||
|
log = AuditLog(user_id=user.id, action="NETWORK_POLICY_CREATED",
|
||||||
|
detail=f"정책 추가: Zone {req.src_zone_id} → {req.dst_zone_id} {req.action}",
|
||||||
|
created_at=datetime.utcnow())
|
||||||
|
db.add(log)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/policies")
|
||||||
|
async def list_policies(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
rows = await db.execute(select(NetworkPolicy).where(NetworkPolicy.tenant_id == user.tenant_id))
|
||||||
|
return [{"id": p.id, "src": p.src_zone_id, "dst": p.dst_zone_id,
|
||||||
|
"protocol": p.protocol, "port": p.dst_port, "action": p.action} for p in rows.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/verify")
|
||||||
|
async def verify_zone_separation(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
rows = await db.execute(select(NetworkZone).where(NetworkZone.tenant_id == user.tenant_id))
|
||||||
|
zones = {z.zone_type: z for z in rows.scalars().all()}
|
||||||
|
issues = []
|
||||||
|
if "ADMIN_NET" not in zones: issues.append("행정망 존 미등록")
|
||||||
|
if "INTERNET" not in zones: issues.append("인터넷망 존 미등록")
|
||||||
|
policy_rows = await db.execute(select(NetworkPolicy).where(NetworkPolicy.tenant_id == user.tenant_id, NetworkPolicy.action == "ALLOW"))
|
||||||
|
for p in policy_rows.scalars().all():
|
||||||
|
src = next((z.zone_type for z in zones.values() if z.id == p.src_zone_id), None)
|
||||||
|
dst = next((z.zone_type for z in zones.values() if z.id == p.dst_zone_id), None)
|
||||||
|
if src and dst:
|
||||||
|
rules = ZONE_TYPE_RULES.get(src, {})
|
||||||
|
if dst in rules.get("denied_to", []):
|
||||||
|
issues.append(f"정책 위반: {src} → {dst} ALLOW 설정")
|
||||||
|
return {"compliant": len(issues) == 0, "issues": issues, "zone_count": len(zones)}
|
||||||
231
workspace/guardia-itsm/routers/nlquery.py
Normal file
231
workspace/guardia-itsm/routers/nlquery.py
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
"""
|
||||||
|
자연어 쿼리 엔진 (Text-to-SQL) — Ollama 기반
|
||||||
|
|
||||||
|
운영자가 자연어로 ITSM 데이터를 조회한다.
|
||||||
|
Ollama가 SQL을 생성하고 ITSM DB에서 결과를 반환.
|
||||||
|
SELECT만 허용 — DML/DDL 절대 차단.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
POST /api/nlquery/ask — 자연어 → SQL → 결과
|
||||||
|
POST /api/nlquery/validate — SQL 안전성 검증 (실행 없음)
|
||||||
|
GET /api/nlquery/schema — DB 스키마 컨텍스트 조회
|
||||||
|
GET /api/nlquery/examples — 예시 질의 목록
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth import get_current_user
|
||||||
|
from database import get_db
|
||||||
|
from models import User, QueryHistory
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/nlquery", tags=["자연어 쿼리"])
|
||||||
|
|
||||||
|
OLLAMA_URL = "http://localhost:11434"
|
||||||
|
CHAT_MODEL = "llama3"
|
||||||
|
MAX_ROWS = 500
|
||||||
|
QUERY_TIMEOUT = 5.0
|
||||||
|
|
||||||
|
# 민감 컬럼 — SQL 결과에서 자동 마스킹
|
||||||
|
SENSITIVE_COLS = {"os_pw_enc", "ssh_user", "ip_addr", "password", "secret", "token"}
|
||||||
|
|
||||||
|
# DML/DDL 금지 키워드
|
||||||
|
FORBIDDEN_KEYWORDS = [
|
||||||
|
"INSERT", "UPDATE", "DELETE", "DROP", "TRUNCATE",
|
||||||
|
"ALTER", "CREATE", "REPLACE", "GRANT", "REVOKE",
|
||||||
|
"EXEC", "EXECUTE", "CALL", "MERGE", "UPSERT",
|
||||||
|
]
|
||||||
|
|
||||||
|
DB_SCHEMA_CONTEXT = """
|
||||||
|
GUARDiA ITSM PostgreSQL 스키마 (주요 테이블):
|
||||||
|
|
||||||
|
tb_sr_request: id, title, description, status(OPEN/IN_PROGRESS/PENDING/DONE),
|
||||||
|
priority(LOW/MEDIUM/HIGH), category, assignee_id, created_at, updated_at
|
||||||
|
|
||||||
|
tb_user: id, name, email, role(ADMIN/ENGINEER/PM/CUSTOMER), tenant_id,
|
||||||
|
is_active, created_at
|
||||||
|
|
||||||
|
tb_server_info: id, hostname, ip_addr, os_type, cpu_cores, memory_mb,
|
||||||
|
inst_id, is_active, created_at
|
||||||
|
|
||||||
|
tb_audit_log: id, user_id, action, detail, created_at
|
||||||
|
|
||||||
|
tb_incident: id, title, severity(P1/P2/P3/P4), status,
|
||||||
|
rca_summary, created_at, resolved_at
|
||||||
|
|
||||||
|
tb_kpi_definition: id, name, display_name, unit, direction, target, period
|
||||||
|
tb_kpi_value: id, kpi_id, value, calculated_at
|
||||||
|
|
||||||
|
tb_jira_sync_mapping: id, sr_id, jira_issue_key, synced_at
|
||||||
|
|
||||||
|
조인 예시:
|
||||||
|
- SR + 담당자: JOIN tb_user u ON sr.assignee_id = u.id
|
||||||
|
- SR 완료 시간(시간): EXTRACT(EPOCH FROM (updated_at-created_at))/3600
|
||||||
|
"""
|
||||||
|
|
||||||
|
EXAMPLE_QUERIES = [
|
||||||
|
{"question": "이번 달 미처리 SR 수는?",
|
||||||
|
"sql": "SELECT COUNT(*) FROM tb_sr_request WHERE status IN ('OPEN','IN_PROGRESS') AND created_at >= DATE_TRUNC('month', NOW())"},
|
||||||
|
{"question": "HIGH 우선순위 SR TOP 5",
|
||||||
|
"sql": "SELECT id, title, assignee_id, created_at FROM tb_sr_request WHERE priority='HIGH' AND status!='DONE' ORDER BY created_at DESC LIMIT 5"},
|
||||||
|
{"question": "엔지니어별 이번 달 완료 SR 수",
|
||||||
|
"sql": "SELECT u.name, COUNT(*) as cnt FROM tb_sr_request sr JOIN tb_user u ON sr.assignee_id=u.id WHERE sr.status='DONE' AND sr.updated_at>=DATE_TRUNC('month',NOW()) GROUP BY u.name ORDER BY cnt DESC"},
|
||||||
|
{"question": "평균 SR 처리 시간 (시간)",
|
||||||
|
"sql": "SELECT ROUND(AVG(EXTRACT(EPOCH FROM (updated_at-created_at))/3600)::numeric,1) as avg_hours FROM tb_sr_request WHERE status='DONE'"},
|
||||||
|
{"question": "가장 많이 발생한 SR 카테고리 TOP 5",
|
||||||
|
"sql": "SELECT category, COUNT(*) as cnt FROM tb_sr_request GROUP BY category ORDER BY cnt DESC LIMIT 5"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class NLQueryRequest(BaseModel):
|
||||||
|
question: str = Field(..., min_length=5, max_length=500)
|
||||||
|
explain_sql: bool = False
|
||||||
|
|
||||||
|
class ValidateRequest(BaseModel):
|
||||||
|
sql: str
|
||||||
|
|
||||||
|
|
||||||
|
def _is_safe_sql(sql: str) -> tuple[bool, str]:
|
||||||
|
"""SQL 안전성 검증."""
|
||||||
|
sql_upper = sql.upper().strip()
|
||||||
|
# SELECT로 시작해야 함
|
||||||
|
if not re.match(r'^\s*SELECT\b', sql_upper):
|
||||||
|
return False, "SELECT 문만 허용됩니다"
|
||||||
|
# 금지 키워드 검사
|
||||||
|
for kw in FORBIDDEN_KEYWORDS:
|
||||||
|
if re.search(r'\b' + kw + r'\b', sql_upper):
|
||||||
|
return False, f"금지된 SQL 키워드: {kw}"
|
||||||
|
# 세미콜론 다중 구문 방지
|
||||||
|
if sql.count(';') > 1:
|
||||||
|
return False, "다중 SQL 구문 금지"
|
||||||
|
return True, "OK"
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_sensitive(rows: list[dict]) -> list[dict]:
|
||||||
|
"""결과에서 민감 컬럼 마스킹."""
|
||||||
|
masked = []
|
||||||
|
for row in rows:
|
||||||
|
new_row = {}
|
||||||
|
for k, v in row.items():
|
||||||
|
if k.lower() in SENSITIVE_COLS:
|
||||||
|
new_row[k] = "***"
|
||||||
|
else:
|
||||||
|
new_row[k] = v
|
||||||
|
masked.append(new_row)
|
||||||
|
return masked
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_sql(question: str) -> tuple[str, str]:
|
||||||
|
"""Ollama로 SQL 생성 → (sql, explanation)."""
|
||||||
|
prompt = (
|
||||||
|
f"다음 GUARDiA ITSM DB 스키마를 참조하여 SQL을 생성하세요:\n\n"
|
||||||
|
f"{DB_SCHEMA_CONTEXT}\n\n"
|
||||||
|
f"질문: {question}\n\n"
|
||||||
|
f"규칙:\n"
|
||||||
|
f"- SELECT 문만 생성 (DML/DDL 절대 금지)\n"
|
||||||
|
f"- PostgreSQL 문법 사용\n"
|
||||||
|
f"- 결과는 JSON 형식으로만: {{\"sql\": \"SELECT ...\", \"explanation\": \"한국어 설명\"}}\n"
|
||||||
|
f"- 테이블명 앞에 스키마 prefix 불필요\n"
|
||||||
|
f"- LIMIT은 최대 {MAX_ROWS}으로 제한"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
r = await client.post(f"{OLLAMA_URL}/api/generate", json={
|
||||||
|
"model": CHAT_MODEL, "prompt": prompt, "stream": False,
|
||||||
|
})
|
||||||
|
if r.status_code == 200:
|
||||||
|
response_text = r.json().get("response", "")
|
||||||
|
# JSON 추출
|
||||||
|
match = re.search(r'\{[^{}]*"sql"[^{}]*\}', response_text, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
data = json.loads(match.group())
|
||||||
|
return data.get("sql", ""), data.get("explanation", "")
|
||||||
|
# JSON 없으면 코드블록에서 추출
|
||||||
|
match = re.search(r'```(?:sql)?\s*(SELECT[^`]+)```', response_text, re.IGNORECASE | re.DOTALL)
|
||||||
|
if match:
|
||||||
|
return match.group(1).strip(), "Ollama 생성 SQL"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Ollama 호출 실패: {e}")
|
||||||
|
return "", "SQL 생성 실패"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ask")
|
||||||
|
async def nl_query(
|
||||||
|
req: NLQueryRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""자연어 질문 → SQL 생성 → 실행 → 결과 반환."""
|
||||||
|
sql, explanation = await _generate_sql(req.question)
|
||||||
|
if not sql:
|
||||||
|
return {"question": req.question, "error": "SQL 생성 실패 — 질문을 다시 표현해 보세요", "sql": None}
|
||||||
|
|
||||||
|
# 안전성 검증
|
||||||
|
is_safe, reason = _is_safe_sql(sql)
|
||||||
|
if not is_safe:
|
||||||
|
# 감사 로그
|
||||||
|
logger.warning(f"위험 SQL 차단: user={user.email}, reason={reason}, sql={sql[:100]}")
|
||||||
|
return {"question": req.question, "error": f"보안 차단: {reason}", "sql": sql}
|
||||||
|
|
||||||
|
# LIMIT 강제 추가
|
||||||
|
if "LIMIT" not in sql.upper():
|
||||||
|
sql = sql.rstrip(';') + f" LIMIT {MAX_ROWS}"
|
||||||
|
|
||||||
|
# SQL 실행
|
||||||
|
try:
|
||||||
|
result = await db.execute(text(sql))
|
||||||
|
rows = result.fetchall()
|
||||||
|
columns = list(result.keys()) if rows else []
|
||||||
|
data = [dict(zip(columns, row)) for row in rows]
|
||||||
|
data = _mask_sensitive(data)
|
||||||
|
except Exception as e:
|
||||||
|
return {"question": req.question, "sql": sql, "error": f"SQL 실행 오류: {str(e)[:200]}"}
|
||||||
|
|
||||||
|
# 쿼리 이력 저장
|
||||||
|
history = QueryHistory(
|
||||||
|
user_id=user.id,
|
||||||
|
question=req.question,
|
||||||
|
generated_sql=sql,
|
||||||
|
row_count=len(data),
|
||||||
|
executed_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(history)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"question": req.question,
|
||||||
|
"row_count": len(data),
|
||||||
|
"data": data[:MAX_ROWS],
|
||||||
|
"truncated": len(data) >= MAX_ROWS,
|
||||||
|
}
|
||||||
|
if req.explain_sql:
|
||||||
|
response["sql"] = sql
|
||||||
|
response["explanation"] = explanation
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/validate")
|
||||||
|
async def validate_sql(req: ValidateRequest):
|
||||||
|
"""SQL 안전성 검증 (실행 없음)."""
|
||||||
|
is_safe, reason = _is_safe_sql(req.sql)
|
||||||
|
return {"safe": is_safe, "reason": reason, "sql": req.sql}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/schema")
|
||||||
|
async def get_schema(_: User = Depends(get_current_user)):
|
||||||
|
return {"schema": DB_SCHEMA_CONTEXT, "sensitive_columns": list(SENSITIVE_COLS)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/examples")
|
||||||
|
async def get_examples(_: User = Depends(get_current_user)):
|
||||||
|
return {"examples": EXAMPLE_QUERIES}
|
||||||
208
workspace/guardia-itsm/routers/op_assistant.py
Normal file
208
workspace/guardia-itsm/routers/op_assistant.py
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
"""
|
||||||
|
대화형 운영 어시스턴트 — Multi-turn 컨텍스트 유지
|
||||||
|
|
||||||
|
여러 번의 대화로 복잡한 운영 쿼리를 처리한다.
|
||||||
|
이전 대화 내용을 기억하고 후속 질문에 맥락 있게 답변.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
POST /api/assistant/chat — 대화 메시지 전송
|
||||||
|
GET /api/assistant/sessions — 대화 세션 목록
|
||||||
|
GET /api/assistant/sessions/{id} — 특정 세션 대화 이력
|
||||||
|
DELETE /api/assistant/sessions/{id} — 세션 종료
|
||||||
|
POST /api/assistant/quick — 빠른 질의 (단일 턴)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth import get_current_user
|
||||||
|
from database import get_db
|
||||||
|
from models import User, AssistantSession, AssistantMessage
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/assistant", tags=["운영 어시스턴트"])
|
||||||
|
|
||||||
|
OLLAMA_URL = "http://localhost:11434"
|
||||||
|
CHAT_MODEL = "llama3"
|
||||||
|
MAX_HISTORY = 10 # 컨텍스트에 포함할 최근 메시지 수
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """당신은 GUARDiA ITSM 운영 전문 어시스턴트입니다.
|
||||||
|
|
||||||
|
역할:
|
||||||
|
- IT 운영 문제 진단 및 해결 방안 제시
|
||||||
|
- SR·인시던트·KPI 데이터 분석 및 해석
|
||||||
|
- 장애 예방을 위한 선제적 권고
|
||||||
|
- 공공기관 IT 운영 모범 사례 안내
|
||||||
|
|
||||||
|
원칙:
|
||||||
|
- 온프레미스 환경 우선 (외부 클라우드 API 제안 금지)
|
||||||
|
- 에이전트리스 접근 권장 (SSH/SFTP만 사용)
|
||||||
|
- 보안 정보(IP, 비밀번호) 응답에 포함 금지
|
||||||
|
- 한국어로만 답변
|
||||||
|
- 3~5문장으로 간결하게"""
|
||||||
|
|
||||||
|
|
||||||
|
class ChatRequest(BaseModel):
|
||||||
|
session_id: Optional[int] = None # None이면 새 세션 생성
|
||||||
|
message: str = Field(..., min_length=1, max_length=2000)
|
||||||
|
context_data: Optional[dict] = None # 추가 컨텍스트 (SR ID, 서버명 등)
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_ollama(messages: list[dict]) -> str:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
r = await client.post(f"{OLLAMA_URL}/api/chat", json={
|
||||||
|
"model": CHAT_MODEL,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": False,
|
||||||
|
})
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json().get("message", {}).get("content", "").strip()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Ollama 호출 실패: {e}")
|
||||||
|
return "현재 AI 어시스턴트를 사용할 수 없습니다. 잠시 후 다시 시도하세요."
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat")
|
||||||
|
async def chat(
|
||||||
|
req: ChatRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""대화 메시지 전송 — Multi-turn 컨텍스트 유지."""
|
||||||
|
# 세션 조회 또는 생성
|
||||||
|
if req.session_id:
|
||||||
|
sess_row = await db.execute(
|
||||||
|
select(AssistantSession).where(
|
||||||
|
AssistantSession.id == req.session_id,
|
||||||
|
AssistantSession.user_id == user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session = sess_row.scalar_one_or_none()
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(404, "세션 없음")
|
||||||
|
else:
|
||||||
|
session = AssistantSession(
|
||||||
|
user_id=user.id,
|
||||||
|
title=req.message[:50],
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(session)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(session)
|
||||||
|
|
||||||
|
# 이전 대화 이력 로드
|
||||||
|
hist_rows = await db.execute(
|
||||||
|
select(AssistantMessage).where(AssistantMessage.session_id == session.id)
|
||||||
|
.order_by(AssistantMessage.created_at).limit(MAX_HISTORY * 2)
|
||||||
|
)
|
||||||
|
history = hist_rows.scalars().all()
|
||||||
|
|
||||||
|
# Ollama messages 구성
|
||||||
|
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||||
|
if req.context_data:
|
||||||
|
ctx = f"\n현재 컨텍스트: {req.context_data}"
|
||||||
|
messages[0]["content"] += ctx
|
||||||
|
|
||||||
|
for h in history[-MAX_HISTORY:]:
|
||||||
|
messages.append({"role": h.role, "content": h.content})
|
||||||
|
messages.append({"role": "user", "content": req.message})
|
||||||
|
|
||||||
|
# AI 응답 생성
|
||||||
|
response = await _call_ollama(messages)
|
||||||
|
|
||||||
|
# 메시지 저장
|
||||||
|
user_msg = AssistantMessage(
|
||||||
|
session_id=session.id, role="user",
|
||||||
|
content=req.message, created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
ai_msg = AssistantMessage(
|
||||||
|
session_id=session.id, role="assistant",
|
||||||
|
content=response, created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(user_msg)
|
||||||
|
db.add(ai_msg)
|
||||||
|
session.updated_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"session_id": session.id,
|
||||||
|
"response": response,
|
||||||
|
"turn": len(history) // 2 + 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/quick")
|
||||||
|
async def quick_query(
|
||||||
|
message: str,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""단일 턴 빠른 질의 (세션 저장 없음)."""
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": message},
|
||||||
|
]
|
||||||
|
response = await _call_ollama(messages)
|
||||||
|
return {"response": response}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions")
|
||||||
|
async def list_sessions(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(AssistantSession).where(AssistantSession.user_id == user.id)
|
||||||
|
.order_by(desc(AssistantSession.updated_at)).limit(20)
|
||||||
|
)
|
||||||
|
sessions = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
{"id": s.id, "title": s.title,
|
||||||
|
"created_at": s.created_at, "updated_at": s.updated_at}
|
||||||
|
for s in sessions
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}")
|
||||||
|
async def get_session(
|
||||||
|
session_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(AssistantMessage).where(AssistantMessage.session_id == session_id)
|
||||||
|
.order_by(AssistantMessage.created_at)
|
||||||
|
)
|
||||||
|
msgs = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
{"role": m.role, "content": m.content, "created_at": m.created_at}
|
||||||
|
for m in msgs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/sessions/{session_id}")
|
||||||
|
async def delete_session(
|
||||||
|
session_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
sess_row = await db.execute(
|
||||||
|
select(AssistantSession).where(
|
||||||
|
AssistantSession.id == session_id,
|
||||||
|
AssistantSession.user_id == user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session = sess_row.scalar_one_or_none()
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(404)
|
||||||
|
await db.delete(session)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
102
workspace/guardia-itsm/routers/public_api_hub.py
Normal file
102
workspace/guardia-itsm/routers/public_api_hub.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
공공 API 허브 — data.go.kr 연동
|
||||||
|
|
||||||
|
공공데이터포털 API를 통합 관리하고 ITSM에 연동.
|
||||||
|
기상청, 행안부, KISA CVE 등 공공기관 필수 API.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/pubapi/catalog — 연동 가능 공공 API 목록
|
||||||
|
POST /api/pubapi/register — API 등록
|
||||||
|
GET /api/pubapi/registered — 등록된 API 목록
|
||||||
|
POST /api/pubapi/call/{api_id} — API 호출
|
||||||
|
GET /api/pubapi/kisa-cve — KISA CVE 취약점 조회 (보안 연계)
|
||||||
|
GET /api/pubapi/weather — 기상청 날씨 (서버실 환경 모니터링)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
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, PublicApiConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/pubapi", tags=["공공 API 허브"])
|
||||||
|
|
||||||
|
PUBLIC_API_CATALOG = [
|
||||||
|
{"id": "kma_weather", "name": "기상청 단기예보", "org": "기상청",
|
||||||
|
"endpoint": "https://apis.data.go.kr/1360000/VilageFcstInfoService2.0/getVilageFcst",
|
||||||
|
"description": "서버실 온도·습도 모니터링 연계"},
|
||||||
|
{"id": "kisa_cve", "name": "KISA CVE 취약점", "org": "KISA",
|
||||||
|
"endpoint": "https://www.kisa.or.kr/openApi/cve",
|
||||||
|
"description": "취약점 스캔 결과와 자동 연계"},
|
||||||
|
{"id": "mois_address", "name": "행안부 주소 API", "org": "행정안전부",
|
||||||
|
"endpoint": "https://business.juso.go.kr/addrlink/addrLinkApi.do",
|
||||||
|
"description": "기관 사이트 주소 자동완성"},
|
||||||
|
{"id": "nts_business", "name": "국세청 사업자 정보", "org": "국세청",
|
||||||
|
"endpoint": "https://api.odcloud.kr/api/nts-businessman/v1/validate",
|
||||||
|
"description": "공급사 사업자 유효성 검증"},
|
||||||
|
{"id": "data_go_kr", "name": "공공데이터포털 범용", "org": "행정안전부",
|
||||||
|
"endpoint": "https://apis.data.go.kr", "description": "기타 공공 API"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ApiRegisterRequest(BaseModel):
|
||||||
|
api_id: str
|
||||||
|
api_key: str
|
||||||
|
custom_params: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalog")
|
||||||
|
async def get_catalog(_: User = Depends(get_current_user)):
|
||||||
|
return {"apis": PUBLIC_API_CATALOG}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register")
|
||||||
|
async def register_api(req: ApiRegisterRequest, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)):
|
||||||
|
catalog_item = next((a for a in PUBLIC_API_CATALOG if a["id"] == req.api_id), None)
|
||||||
|
if not catalog_item:
|
||||||
|
raise HTTPException(400, f"카탈로그에 없는 API: {req.api_id}")
|
||||||
|
cfg = PublicApiConfig(
|
||||||
|
tenant_id=user.tenant_id, api_id=req.api_id,
|
||||||
|
name=catalog_item["name"], endpoint=catalog_item["endpoint"],
|
||||||
|
api_key_enc=req.api_key, # TODO: AES-256-GCM
|
||||||
|
is_active=True, created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(cfg); await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/registered")
|
||||||
|
async def list_registered(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
rows = await db.execute(select(PublicApiConfig).where(PublicApiConfig.tenant_id == user.tenant_id))
|
||||||
|
return [{"id": c.id, "api_id": c.api_id, "name": c.name, "is_active": c.is_active} for c in rows.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/kisa-cve")
|
||||||
|
async def get_kisa_cve(keyword: str = None, user: User = Depends(get_current_user)):
|
||||||
|
"""KISA CVE 취약점 조회 (취약점 스캔 연계)."""
|
||||||
|
return {
|
||||||
|
"source": "KISA (Korea Internet & Security Agency)",
|
||||||
|
"note": "API Key 등록 후 실시간 CVE 조회 가능",
|
||||||
|
"manual_url": "https://www.kisa.or.kr/vulnerability/cve.do",
|
||||||
|
"keyword": keyword,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/weather")
|
||||||
|
async def get_weather(nx: int = 55, ny: int = 127, user: User = Depends(get_current_user)):
|
||||||
|
"""기상청 단기예보 (서버실 인근 날씨 — 환경 모니터링 참고)."""
|
||||||
|
return {
|
||||||
|
"note": "API Key 등록 후 실시간 날씨 조회 가능 (서버실 환경 모니터링 연계)",
|
||||||
|
"location": f"({nx}, {ny})",
|
||||||
|
"manual_url": "https://www.data.go.kr/data/15084084/openapi.do",
|
||||||
|
}
|
||||||
175
workspace/guardia-itsm/routers/query_history.py
Normal file
175
workspace/guardia-itsm/routers/query_history.py
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
"""
|
||||||
|
쿼리 이력·즐겨찾기·공유 대시보드
|
||||||
|
|
||||||
|
운영자가 자주 쓰는 쿼리를 저장하고 팀과 공유.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
GET /api/queryhistory/ — 내 쿼리 이력
|
||||||
|
GET /api/queryhistory/favorites — 즐겨찾기
|
||||||
|
POST /api/queryhistory/{id}/favorite — 즐겨찾기 토글
|
||||||
|
POST /api/queryhistory/save — 쿼리 직접 저장
|
||||||
|
GET /api/queryhistory/shared — 팀 공유 쿼리
|
||||||
|
POST /api/queryhistory/{id}/share — 공유 토글
|
||||||
|
DELETE /api/queryhistory/{id} — 이력 삭제
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth import get_current_user
|
||||||
|
from database import get_db
|
||||||
|
from models import User, QueryHistory
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/queryhistory", tags=["쿼리 이력"])
|
||||||
|
|
||||||
|
|
||||||
|
class SaveQueryRequest(BaseModel):
|
||||||
|
question: str
|
||||||
|
sql: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_history(
|
||||||
|
limit: int = 50,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(QueryHistory).where(QueryHistory.user_id == user.id)
|
||||||
|
.order_by(desc(QueryHistory.executed_at)).limit(limit)
|
||||||
|
)
|
||||||
|
hs = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
{"id": h.id, "question": h.question, "sql": h.generated_sql,
|
||||||
|
"rows": h.row_count, "is_favorite": h.is_favorite,
|
||||||
|
"is_shared": h.is_shared, "executed_at": h.executed_at}
|
||||||
|
for h in hs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/favorites")
|
||||||
|
async def list_favorites(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(QueryHistory).where(
|
||||||
|
QueryHistory.user_id == user.id,
|
||||||
|
QueryHistory.is_favorite == True,
|
||||||
|
).order_by(desc(QueryHistory.executed_at))
|
||||||
|
)
|
||||||
|
hs = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
{"id": h.id, "question": h.question, "sql": h.generated_sql,
|
||||||
|
"description": h.description, "executed_at": h.executed_at}
|
||||||
|
for h in hs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shared")
|
||||||
|
async def list_shared(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""팀 공유 쿼리 (모든 테넌트 사용자)."""
|
||||||
|
rows = await db.execute(
|
||||||
|
select(QueryHistory, User.name.label("owner")).join(
|
||||||
|
User, QueryHistory.user_id == User.id
|
||||||
|
).where(
|
||||||
|
QueryHistory.is_shared == True,
|
||||||
|
).order_by(desc(QueryHistory.executed_at)).limit(100)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{"id": r.QueryHistory.id, "question": r.QueryHistory.question,
|
||||||
|
"sql": r.QueryHistory.generated_sql, "description": r.QueryHistory.description,
|
||||||
|
"owner": r.owner, "executed_at": r.QueryHistory.executed_at}
|
||||||
|
for r in rows.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/save")
|
||||||
|
async def save_query(
|
||||||
|
req: SaveQueryRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
h = QueryHistory(
|
||||||
|
user_id=user.id,
|
||||||
|
question=req.question,
|
||||||
|
generated_sql=req.sql,
|
||||||
|
description=req.description,
|
||||||
|
row_count=0,
|
||||||
|
is_favorite=True,
|
||||||
|
executed_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(h)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(h)
|
||||||
|
return {"ok": True, "id": h.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{history_id}/favorite")
|
||||||
|
async def toggle_favorite(
|
||||||
|
history_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
row = await db.execute(
|
||||||
|
select(QueryHistory).where(
|
||||||
|
QueryHistory.id == history_id,
|
||||||
|
QueryHistory.user_id == user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
h = row.scalar_one_or_none()
|
||||||
|
if not h:
|
||||||
|
raise HTTPException(404)
|
||||||
|
h.is_favorite = not h.is_favorite
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "is_favorite": h.is_favorite}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{history_id}/share")
|
||||||
|
async def toggle_share(
|
||||||
|
history_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
row = await db.execute(
|
||||||
|
select(QueryHistory).where(
|
||||||
|
QueryHistory.id == history_id,
|
||||||
|
QueryHistory.user_id == user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
h = row.scalar_one_or_none()
|
||||||
|
if not h:
|
||||||
|
raise HTTPException(404)
|
||||||
|
h.is_shared = not h.is_shared
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "is_shared": h.is_shared}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{history_id}")
|
||||||
|
async def delete_history(
|
||||||
|
history_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
row = await db.execute(
|
||||||
|
select(QueryHistory).where(
|
||||||
|
QueryHistory.id == history_id,
|
||||||
|
QueryHistory.user_id == user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
h = row.scalar_one_or_none()
|
||||||
|
if not h:
|
||||||
|
raise HTTPException(404)
|
||||||
|
await db.delete(h)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
211
workspace/guardia-itsm/routers/snmp_discovery.py
Normal file
211
workspace/guardia-itsm/routers/snmp_discovery.py
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
"""
|
||||||
|
SNMP 기반 네트워크 장비 자동 발견
|
||||||
|
|
||||||
|
SNMP v2c/v3으로 스위치·라우터·방화벽을 발견하고 CMDB에 등록.
|
||||||
|
에이전트리스: nmap + SNMP 프로토콜만 사용.
|
||||||
|
|
||||||
|
엔드포인트:
|
||||||
|
POST /api/snmp/scan — SNMP 스캔
|
||||||
|
GET /api/snmp/devices — 발견된 장비 목록
|
||||||
|
POST /api/snmp/poll/{device_id} — 단일 장비 SNMP 폴링
|
||||||
|
GET /api/snmp/configs — SNMP 설정 목록
|
||||||
|
POST /api/snmp/configs — SNMP 커뮤니티 설정 등록
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
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, SNMPConfig, SNMPDevice
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/snmp", tags=["SNMP 자동 발견"])
|
||||||
|
|
||||||
|
# SNMP OID 상수 (RFC 1213 MIB-II)
|
||||||
|
OID_SYSNAME = "1.3.6.1.2.1.1.5.0"
|
||||||
|
OID_SYSDESCR = "1.3.6.1.2.1.1.1.0"
|
||||||
|
OID_SYSLOCATION = "1.3.6.1.2.1.1.6.0"
|
||||||
|
OID_SYSCONTACT = "1.3.6.1.2.1.1.4.0"
|
||||||
|
OID_IFNUMBER = "1.3.6.1.2.1.2.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
class SNMPScanRequest(BaseModel):
|
||||||
|
network_range: str
|
||||||
|
community: str = Field("public", description="SNMP v2c 커뮤니티 문자열")
|
||||||
|
version: int = Field(2, ge=1, le=3)
|
||||||
|
timeout_sec: int = Field(3, ge=1, le=10)
|
||||||
|
port: int = 161
|
||||||
|
|
||||||
|
class SNMPConfigCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
community: str
|
||||||
|
version: int = 2
|
||||||
|
ip_ranges: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
async def _snmp_get(ip: str, community: str, oid: str, port: int = 161, timeout: int = 3) -> Optional[str]:
|
||||||
|
"""SNMP GET 요청 (snmpget 명령 경유 또는 순수 UDP 패킷)."""
|
||||||
|
import subprocess
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["snmpget", "-v2c", f"-c{community}", "-t", str(timeout), "-r1",
|
||||||
|
f"{ip}:{port}", oid],
|
||||||
|
capture_output=True, text=True, timeout=timeout + 1
|
||||||
|
)
|
||||||
|
if r.returncode == 0 and r.stdout:
|
||||||
|
# 출력 형식: OID = STRING: "value" 또는 INTEGER: 1
|
||||||
|
parts = r.stdout.strip().split(":", 2)
|
||||||
|
return parts[-1].strip().strip('"') if len(parts) >= 2 else r.stdout.strip()
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _scan_snmp_host(ip: str, community: str, port: int, timeout: int) -> Optional[dict]:
|
||||||
|
"""단일 호스트 SNMP 정보 수집."""
|
||||||
|
sysname = await _snmp_get(ip, community, OID_SYSNAME, port, timeout)
|
||||||
|
if not sysname:
|
||||||
|
return None # SNMP 응답 없음
|
||||||
|
|
||||||
|
descr = await _snmp_get(ip, community, OID_SYSDESCR, port, timeout) or ""
|
||||||
|
location = await _snmp_get(ip, community, OID_SYSLOCATION, port, timeout) or ""
|
||||||
|
contact = await _snmp_get(ip, community, OID_SYSCONTACT, port, timeout) or ""
|
||||||
|
ifcount = await _snmp_get(ip, community, OID_IFNUMBER, port, timeout) or "0"
|
||||||
|
|
||||||
|
# 장비 유형 추정 (sysDescr 키워드 기반)
|
||||||
|
descr_lower = descr.lower()
|
||||||
|
if any(k in descr_lower for k in ["cisco", "juniper", "arista", "switch", "router"]):
|
||||||
|
device_type = "NETWORK"
|
||||||
|
elif any(k in descr_lower for k in ["linux", "windows", "unix"]):
|
||||||
|
device_type = "SERVER"
|
||||||
|
elif any(k in descr_lower for k in ["ups", "power"]):
|
||||||
|
device_type = "UPS"
|
||||||
|
elif any(k in descr_lower for k in ["printer", "print"]):
|
||||||
|
device_type = "PRINTER"
|
||||||
|
else:
|
||||||
|
device_type = "UNKNOWN"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ip": ip, "sysname": sysname, "description": descr[:200],
|
||||||
|
"location": location, "contact": contact,
|
||||||
|
"interface_count": int(ifcount) if ifcount.isdigit() else 0,
|
||||||
|
"device_type": device_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_snmp_scan(config_id: int, req: SNMPScanRequest, user_id: int, db: AsyncSession):
|
||||||
|
"""백그라운드 SNMP 스캔."""
|
||||||
|
import ipaddress
|
||||||
|
try:
|
||||||
|
network = ipaddress.ip_network(req.network_range, strict=False)
|
||||||
|
tasks = [
|
||||||
|
_scan_snmp_host(str(h), req.community, req.port, req.timeout_sec)
|
||||||
|
for h in list(network.hosts())[:254]
|
||||||
|
]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
for result in results:
|
||||||
|
if isinstance(result, dict):
|
||||||
|
device = SNMPDevice(
|
||||||
|
config_id=config_id,
|
||||||
|
ip_addr=result["ip"],
|
||||||
|
sysname=result["sysname"],
|
||||||
|
description=result["description"],
|
||||||
|
device_type=result["device_type"],
|
||||||
|
location=result["location"],
|
||||||
|
interface_count=result["interface_count"],
|
||||||
|
discovered_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(device)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"SNMP 스캔 실패: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/configs")
|
||||||
|
async def create_snmp_config(
|
||||||
|
req: SNMPConfigCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
cfg = SNMPConfig(
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
name=req.name,
|
||||||
|
community_enc=req.community, # TODO: AES-256-GCM 암호화
|
||||||
|
version=req.version,
|
||||||
|
ip_ranges=req.ip_ranges,
|
||||||
|
is_active=True,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(cfg)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(cfg)
|
||||||
|
return {"ok": True, "id": cfg.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/configs")
|
||||||
|
async def list_snmp_configs(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(SNMPConfig).where(SNMPConfig.tenant_id == user.tenant_id)
|
||||||
|
)
|
||||||
|
cfgs = rows.scalars().all()
|
||||||
|
return [
|
||||||
|
{"id": c.id, "name": c.name, "version": c.version,
|
||||||
|
"ip_ranges": c.ip_ranges, "is_active": c.is_active}
|
||||||
|
for c in cfgs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scan")
|
||||||
|
async def start_snmp_scan(
|
||||||
|
req: SNMPScanRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin_role),
|
||||||
|
):
|
||||||
|
# 기본 SNMP 설정 생성
|
||||||
|
cfg = SNMPConfig(
|
||||||
|
tenant_id=user.tenant_id, name=f"scan_{datetime.utcnow().strftime('%Y%m%d%H%M')}",
|
||||||
|
community_enc=req.community, version=req.version,
|
||||||
|
is_active=True, created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(cfg)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(cfg)
|
||||||
|
|
||||||
|
background_tasks.add_task(_run_snmp_scan, cfg.id, req, user.id, db)
|
||||||
|
return {"ok": True, "config_id": cfg.id, "network": req.network_range}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/devices")
|
||||||
|
async def list_snmp_devices(
|
||||||
|
limit: int = 100,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
rows = await db.execute(
|
||||||
|
select(SNMPDevice, SNMPConfig.tenant_id).join(
|
||||||
|
SNMPConfig, SNMPDevice.config_id == SNMPConfig.id
|
||||||
|
).where(SNMPConfig.tenant_id == user.tenant_id)
|
||||||
|
.order_by(desc(SNMPDevice.discovered_at)).limit(limit)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r.SNMPDevice.id, "ip": r.SNMPDevice.ip_addr,
|
||||||
|
"name": r.SNMPDevice.sysname, "type": r.SNMPDevice.device_type,
|
||||||
|
"location": r.SNMPDevice.location, "interfaces": r.SNMPDevice.interface_count,
|
||||||
|
"discovered_at": r.SNMPDevice.discovered_at,
|
||||||
|
}
|
||||||
|
for r in rows.all()
|
||||||
|
]
|
||||||
Loading…
Reference in New Issue
Block a user