From 5f3a0247b356b83c5c8a3a4c33e867faef4106fc Mon Sep 17 00:00:00 2001 From: "DESKTOP-TKLFCPR\\ython" Date: Wed, 3 Jun 2026 08:04:03 +0900 Subject: [PATCH] sync: update from workspace (latest ITSM/CICD/DR changes) --- main.py | 20 ++ models.py | 348 ++++++++++++++++++++++++ routers/agentic_aiops.py | 293 +++++++++++++++++++++ routers/edge_monitor.py | 125 +++++++++ routers/energy_optimizer.py | 140 ++++++++++ routers/greenops.py | 130 +++++++++ routers/idp_catalog.py | 124 +++++++++ routers/idp_portal.py | 132 ++++++++++ routers/idp_template.py | 141 ++++++++++ routers/mlsecops.py | 142 ++++++++++ routers/n2sf.py | 143 ++++++++++ routers/otel_tracing.py | 125 +++++++++ routers/sbom.py | 152 +++++++++++ routers/ztna.py | 156 +++++++++++ static/app.js | 508 ++++++++++++++++++++++++++++++++++++ static/index.html | 48 ++++ 16 files changed, 2727 insertions(+) create mode 100644 routers/agentic_aiops.py create mode 100644 routers/edge_monitor.py create mode 100644 routers/energy_optimizer.py create mode 100644 routers/greenops.py create mode 100644 routers/idp_catalog.py create mode 100644 routers/idp_portal.py create mode 100644 routers/idp_template.py create mode 100644 routers/mlsecops.py create mode 100644 routers/n2sf.py create mode 100644 routers/otel_tracing.py create mode 100644 routers/sbom.py create mode 100644 routers/ztna.py diff --git a/main.py b/main.py index e55a430..4359a28 100644 --- a/main.py +++ b/main.py @@ -390,6 +390,26 @@ app.include_router(batch_ssh.router) # 다중 서버 동시 SSH 실 app.include_router(asset_qr.router) # 서버 자산 QR 태그 관리 app.include_router(smart_notify.router) # 스마트 알림 규칙 엔진 +# ── GUARDiA 차세대 확장 — 2026 트렌드 기반 (Gartner/EU CRA/국정원 N²SF) ────── +from routers import ( + agentic_aiops, otel_tracing, mlsecops, # AIOps 2.0 + ztna, sbom, n2sf, # Zero Trust + Supply Chain + idp_catalog, idp_template, idp_portal, # Internal Developer Platform + greenops, edge_monitor, energy_optimizer, # GreenOps + Edge/IoT +) +app.include_router(agentic_aiops.router) # Agentic AI tool-calling 엔진 +app.include_router(otel_tracing.router) # OpenTelemetry 분산 트레이싱 +app.include_router(mlsecops.router) # AI 모델 보안·버전 관리 +app.include_router(ztna.router) # Zero Trust 정책 엔진 +app.include_router(sbom.router) # SBOM CycloneDX 생성·관리 +app.include_router(n2sf.router) # N²SF 준수 점검 +app.include_router(idp_catalog.router) # IDP 소프트웨어 카탈로그 +app.include_router(idp_template.router) # IDP Golden Path 템플릿 +app.include_router(idp_portal.router) # IDP 셀프서비스 포털 +app.include_router(greenops.router) # 탄소 배출 추적 +app.include_router(edge_monitor.router) # Edge/IoT 모니터링 +app.include_router(energy_optimizer.router) # 에너지 최적화 + # ── 개방망 보안 헤더 미들웨어 ──────────────────────────────────────────────── @app.middleware("http") diff --git a/models.py b/models.py index 844bea4..1b00276 100644 --- a/models.py +++ b/models.py @@ -5551,3 +5551,351 @@ class NotifyLog(Base): success = Column(Boolean, default=False) error_msg = Column(Text, nullable=True) sent_at = Column(DateTime, default=func.now()) + + +# ══════════════════════════════════════════════════════════════════════════════ +# ── GUARDiA 차세대 확장 — AIOps 2.0 / Zero Trust / IDP / GreenOps+Edge +# ── 트렌드 근거: 2026년 웹 크롤링 (Gartner, Forrester, EU CRA, 국정원 N²SF) +# ══════════════════════════════════════════════════════════════════════════════ + +# ── Agentic AIOps ───────────────────────────────────────────────────────────── + +class AgentRun(Base): + """에이전트 태스크 실행 이력.""" + __tablename__ = "tb_agent_run" + id = Column(Integer, primary_key=True, index=True) + task = Column(Text, nullable=False) + status = Column(String(30), default="PENDING") # RUNNING|DONE|STOPPED|PENDING_APPROVAL + result = Column(Text, nullable=True) + context_json = Column(Text, nullable=True) + tenant_id = Column(Integer, nullable=False, default=1) + created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + approved_by = Column(Integer, nullable=True) + created_at = Column(DateTime, default=func.now()) + finished_at = Column(DateTime, nullable=True) + + +class AgentToolCall(Base): + """에이전트 도구 호출 로그.""" + __tablename__ = "tb_agent_tool_call" + id = Column(Integer, primary_key=True, index=True) + run_id = Column(Integer, ForeignKey("tb_agent_run.id"), nullable=False) + step = Column(Integer, default=0) + tool_name = Column(String(100), nullable=False) + params_json = Column(Text, nullable=True) + result = Column(Text, nullable=True) + called_at = Column(DateTime, default=func.now()) + + +class RemediationRule(Base): + """자율 교정 규칙.""" + __tablename__ = "tb_remediation_rule" + id = Column(Integer, primary_key=True, index=True) + name = Column(String(200), nullable=False) + trigger_type = Column(String(50), nullable=False) # ANOMALY|THRESHOLD|MANUAL + condition_json = Column(Text, default="{}") + action_template = Column(Text, nullable=True) + auto_approve = Column(Boolean, default=False) + severity_threshold = Column(String(20), default="HIGH") + is_active = Column(Boolean, default=True) + created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class RemediationHistory(Base): + """교정 실행 이력.""" + __tablename__ = "tb_remediation_history" + id = Column(Integer, primary_key=True, index=True) + rule_id = Column(Integer, ForeignKey("tb_remediation_rule.id"), nullable=True) + server_id = Column(Integer, nullable=True) + trigger_data = Column(Text, nullable=True) + status = Column(String(30), default="DIAGNOSING") + diagnosis = Column(Text, nullable=True) + action_taken = Column(Text, nullable=True) + result = Column(Text, nullable=True) + triggered_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + approved_by = Column(Integer, nullable=True) + created_at = Column(DateTime, default=func.now()) + finished_at = Column(DateTime, nullable=True) + + +class OtelTrace(Base): + """OpenTelemetry 트레이스 레코드.""" + __tablename__ = "tb_otel_trace" + id = Column(Integer, primary_key=True, index=True) + trace_id = Column(String(64), nullable=False, unique=True, index=True) + service = Column(String(200), nullable=False) + start_time = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class OtelSpan(Base): + """OpenTelemetry 스팬 레코드.""" + __tablename__ = "tb_otel_span" + id = Column(Integer, primary_key=True, index=True) + trace_id = Column(String(64), ForeignKey("tb_otel_trace.trace_id"), nullable=False, index=True) + span_id = Column(String(64), nullable=False, unique=True) + parent_span_id = Column(String(64), nullable=True) + service = Column(String(200), nullable=False) + operation = Column(String(300), nullable=False) + start_time = Column(DateTime, nullable=True) + end_time = Column(DateTime, nullable=True) + duration_ms = Column(Integer, default=0) + status = Column(String(20), default="OK") + attributes = Column(Text, nullable=True) + + +class MLModel(Base): + """AI 모델 보안 스캔 이력.""" + __tablename__ = "tb_ml_model" + id = Column(Integer, primary_key=True, index=True) + name = Column(String(200), nullable=False) + scan_status = Column(String(30), default="UNKNOWN") # CLEAN|VULNERABLE|APPROVED + vulnerability = Column(Text, nullable=True) + approved_by = Column(Integer, nullable=True) + scanned_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + scanned_at = Column(DateTime, default=func.now()) + + +# ── Zero Trust + Supply Chain ───────────────────────────────────────────────── + +class ZTNAPolicy(Base): + """ZTNA 접근 정책.""" + __tablename__ = "tb_ztna_policy" + id = Column(Integer, primary_key=True, index=True) + name = Column(String(200), nullable=False) + resource = Column(String(300), nullable=False, index=True) + allowed_roles = Column(Text, default="[]") + require_mfa = Column(Boolean, default=True) + min_trust_score = Column(Integer, default=70) + allowed_ips = Column(Text, default="[]") + require_device_compliant = Column(Boolean, default=True) + is_active = Column(Boolean, default=True) + created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class ZTNADevice(Base): + """등록된 디바이스 (Zero Trust).""" + __tablename__ = "tb_ztna_device" + id = Column(Integer, primary_key=True, index=True) + device_name = Column(String(200), nullable=False) + device_type = Column(String(50), nullable=False) # PC|MOBILE|SERVER + os_name = Column(String(100), nullable=True) + os_version = Column(String(50), nullable=True) + antivirus_active = Column(Boolean, default=False) + disk_encrypted = Column(Boolean, default=False) + last_patch_days = Column(Integer, default=999) + is_compliant = Column(Boolean, default=False) + registered_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + registered_at = Column(DateTime, default=func.now()) + + +class ZTNAViolation(Base): + """ZTNA 정책 위반 로그.""" + __tablename__ = "tb_ztna_violation" + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, nullable=True) + resource = Column(String(300), nullable=False) + reason = Column(Text, nullable=True) + source_ip = Column(String(50), nullable=True) + trust_score = Column(Integer, default=0) + created_at = Column(DateTime, default=func.now()) + + +class SBOMRecord(Base): + """SBOM 레코드.""" + __tablename__ = "tb_sbom_record" + id = Column(Integer, primary_key=True, index=True) + server_id = Column(Integer, nullable=False, index=True) + format = Column(String(20), default="CycloneDX") + spec_version = Column(String(10), default="1.4") + component_count = Column(Integer, default=0) + status = Column(String(20), default="PENDING") + generated_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class SBOMComponent(Base): + """SBOM 컴포넌트 (패키지·라이브러리).""" + __tablename__ = "tb_sbom_component" + id = Column(Integer, primary_key=True, index=True) + sbom_id = Column(Integer, ForeignKey("tb_sbom_record.id"), nullable=False, index=True) + name = Column(String(200), nullable=False) + version = Column(String(100), nullable=False) + component_type = Column(String(50), default="library") + ecosystem = Column(String(50), nullable=True) + purl = Column(String(500), nullable=True) + cve_ids = Column(Text, nullable=True) + + +class N2SFAssessment(Base): + """N²SF 준수 평가.""" + __tablename__ = "tb_n2sf_assessment" + id = Column(Integer, primary_key=True, index=True) + system_name = Column(String(200), nullable=False) + zone = Column(String(1), nullable=False) # A|B|C + total_items = Column(Integer, default=0) + passed_items = Column(Integer, default=0) + score = Column(Integer, default=0) + checked_items = Column(Text, nullable=True) # JSON 배열 + notes = Column(Text, nullable=True) + assessed_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class N2SFZone(Base): + """N²SF 보안 구역 등록.""" + __tablename__ = "tb_n2sf_zone" + id = Column(Integer, primary_key=True, index=True) + zone = Column(String(1), nullable=False) + system_name = Column(String(200), nullable=False) + classified_by = Column(Integer, nullable=True) + created_at = Column(DateTime, default=func.now()) + + +# ── IDP ─────────────────────────────────────────────────────────────────────── + +class IDPComponent(Base): + """IDP 소프트웨어 카탈로그 컴포넌트.""" + __tablename__ = "tb_idp_component" + id = Column(Integer, primary_key=True, index=True) + name = Column(String(200), nullable=False, unique=True) + display_name = Column(String(200), nullable=True) + component_type = Column(String(50), default="service") + description = Column(Text, nullable=True) + language = Column(String(50), nullable=True) + framework = Column(String(100), nullable=True) + gitea_repo = Column(String(500), nullable=True) + ci_job = Column(String(200), nullable=True) + docs_url = Column(String(500), nullable=True) + owner_team = Column(String(200), nullable=True) + tags = Column(Text, nullable=True) + lifecycle = Column(String(30), default="production") + dependencies = Column(Text, nullable=True) + registered_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class IDPTemplate(Base): + """IDP Golden Path 템플릿.""" + __tablename__ = "tb_idp_template" + id = Column(Integer, primary_key=True, index=True) + name = Column(String(200), nullable=False, unique=True) + description = Column(Text, nullable=True) + language = Column(String(50), nullable=True) + framework = Column(String(100), nullable=True) + variables = Column(Text, nullable=True) # JSON 배열 + files = Column(Text, nullable=True) # JSON 배열 + content_json = Column(Text, default="{}") + created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class IDPProvisionRequest(Base): + """IDP 인프라 프로비저닝 요청.""" + __tablename__ = "tb_idp_provision_request" + id = Column(Integer, primary_key=True, index=True) + resource_type = Column(String(50), nullable=False) + params = Column(Text, nullable=True) + justification = Column(Text, nullable=True) + team = Column(String(200), nullable=True) + status = Column(String(30), default="PENDING") + result = Column(Text, nullable=True) + requested_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + approved_by = Column(Integer, nullable=True) + created_at = Column(DateTime, default=func.now()) + finished_at = Column(DateTime, nullable=True) + + +# ── GreenOps + Edge ─────────────────────────────────────────────────────────── + +class CarbonRecord(Base): + """탄소 배출 기록.""" + __tablename__ = "tb_carbon_record" + id = Column(Integer, primary_key=True, index=True) + server_id = Column(Integer, nullable=False, index=True) + watt = Column(Float, nullable=False) + hours = Column(Float, default=1.0) + pue = Column(Float, default=1.5) + carbon_kg = Column(Float, nullable=False) + grid_factor = Column(Float, default=0.4593) + recorded_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + recorded_at = Column(DateTime, default=func.now()) + + +class GreenOpsConfig(Base): + """GreenOps 서버별 설정 (베이스라인).""" + __tablename__ = "tb_greenops_config" + id = Column(Integer, primary_key=True, index=True) + server_id = Column(Integer, nullable=False, unique=True) + watt_baseline = Column(Float, nullable=False) + pue = Column(Float, default=1.5) + note = Column(Text, nullable=True) + set_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class OptimizationRec(Base): + """에너지 최적화 권고.""" + __tablename__ = "tb_optimization_rec" + id = Column(Integer, primary_key=True, index=True) + rec_type = Column(String(50), nullable=False) + description = Column(Text, nullable=False) + saving_kwh = Column(Float, default=0.0) + saving_carbon_kg = Column(Float, default=0.0) + status = Column(String(20), default="PENDING") + applied_at = Column(DateTime, nullable=True) + created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + applied_by = Column(Integer, nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class CarbonSchedule(Base): + """Carbon-aware 배치 스케줄.""" + __tablename__ = "tb_carbon_schedule" + id = Column(Integer, primary_key=True, index=True) + job_name = Column(String(200), nullable=False) + job_command = Column(Text, nullable=False) + server_id = Column(Integer, nullable=False) + preferred_hour = Column(Integer, nullable=False) + carbon_factor = Column(Float, nullable=True) + status = Column(String(20), default="SCHEDULED") + created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class EdgeDevice(Base): + """Edge/IoT 디바이스.""" + __tablename__ = "tb_edge_device" + id = Column(Integer, primary_key=True, index=True) + name = Column(String(200), nullable=False) + device_type = Column(String(50), nullable=False) + location = Column(String(300), nullable=True) + protocol = Column(String(20), default="HTTP") + device_token = Column(String(36), nullable=True, unique=True) + meta_json = Column(Text, nullable=True) + status = Column(String(20), default="REGISTERED") + last_seen = Column(DateTime, nullable=True) + registered_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + + +class EdgeMetric(Base): + """Edge 디바이스 텔레메트리.""" + __tablename__ = "tb_edge_metric" + id = Column(Integer, primary_key=True, index=True) + device_id = Column(Integer, ForeignKey("tb_edge_device.id"), nullable=False, index=True) + metrics_json = Column(Text, nullable=True) + recorded_at = Column(DateTime, default=func.now()) + + +class EdgeAlert(Base): + """Edge 디바이스 알림.""" + __tablename__ = "tb_edge_alert" + id = Column(Integer, primary_key=True, index=True) + device_id = Column(Integer, ForeignKey("tb_edge_device.id"), nullable=False, index=True) + alert_type = Column(String(50), nullable=False) + message = Column(Text, nullable=True) + severity = Column(String(20), default="WARNING") + created_at = Column(DateTime, default=func.now()) diff --git a/routers/agentic_aiops.py b/routers/agentic_aiops.py new file mode 100644 index 0000000..df928f0 --- /dev/null +++ b/routers/agentic_aiops.py @@ -0,0 +1,293 @@ +""" +Agentic AIOps — MCP-compatible tool-calling 멀티에이전트 엔진 + +엔드포인트: + POST /api/agent/run — 에이전트 태스크 실행 + GET /api/agent/tools — 사용 가능 도구 목록 + GET /api/agent/runs — 실행 이력 + GET /api/agent/runs/{id} — 실행 상세 + POST /api/agent/approve/{id} — 인간 승인 게이트 + POST /api/agent/stop/{id} — 실행 중단 +""" +from __future__ import annotations + +import json +import logging +from datetime import datetime +from typing import Any, Dict, List, Optional + +import httpx +from fastapi import APIRouter, BackgroundTasks, 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, AgentRun, AgentToolCall + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/agent", tags=["Agentic AIOps"]) + +OLLAMA_URL = "http://localhost:11434" +MAX_TOOL_STEPS = 10 # 무한 루프 방지 + +# ── 도구 정의 ──────────────────────────────────────────────────────────────── + +TOOLS: List[Dict] = [ + { + "name": "ssh_run", + "description": "SSH로 서버에 셸 명령 실행. 안전한 명령만 허용.", + "parameters": {"server_id": "int", "command": "str"}, + }, + { + "name": "create_sr", + "description": "ITSM 서비스 요청(SR) 생성", + "parameters": {"title": "str", "priority": "str", "description": "str"}, + }, + { + "name": "get_cmdb", + "description": "CMDB에서 서버 정보 조회", + "parameters": {"server_id": "int"}, + }, + { + "name": "get_metrics", + "description": "서버 Prometheus 메트릭 조회 (CPU/메모리/디스크)", + "parameters": {"server_id": "int", "metric": "str"}, + }, + { + "name": "health_check", + "description": "서버 헬스체크 (서비스 상태 확인)", + "parameters": {"server_id": "int"}, + }, + { + "name": "restart_service", + "description": "서버 서비스 재시작 (승인 필요 시 pending 상태)", + "parameters": {"server_id": "int", "service_name": "str"}, + }, + { + "name": "query_srs", + "description": "SR 목록 조회 (최근 미해결 SR 등)", + "parameters": {"status": "str", "limit": "int"}, + }, + { + "name": "notify_messenger", + "description": "메신저로 메시지 발송", + "parameters": {"room": "str", "message": "str"}, + }, +] + +SAFE_COMMANDS = {"systemctl", "journalctl", "ps", "df", "du", "top", "free", + "netstat", "ss", "curl", "ping", "cat", "ls", "tail", "grep"} +DANGEROUS = {"rm", "mkfs", "dd", "shutdown", "halt", "reboot", "mv /", "chmod 777 /"} + + +def _is_safe(command: str) -> bool: + base = command.strip().split()[0].split("/")[-1] if command.strip() else "" + return base in SAFE_COMMANDS and not any(d in command for d in DANGEROUS) + + +async def _execute_tool(tool_name: str, params: dict, db: AsyncSession, user: User) -> str: + """도구 실행 — 에이전트리스 원칙 준수.""" + try: + if tool_name == "ssh_run": + cmd = params.get("command", "") + if not _is_safe(cmd): + return f"ERROR: 위험 명령 차단됨: {cmd}" + from routers.ssh import _exec_ssh + result = await _exec_ssh(params["server_id"], cmd, db, user) + return result[:500] + + elif tool_name == "create_sr": + from routers.tasks import _create_sr_internal + sr = await _create_sr_internal( + params["title"], params.get("priority", "MEDIUM"), + params.get("description", ""), user.id, db + ) + return f"SR 생성: {sr.id}" + + elif tool_name == "get_cmdb": + from routers.cmdb import _get_server_brief + info = await _get_server_brief(params["server_id"], db) + return json.dumps(info, ensure_ascii=False)[:300] + + elif tool_name == "get_metrics": + return f"CPU: 72%, MEM: 65%, DISK: 45%" # Prometheus 연동 시 교체 + + elif tool_name == "health_check": + return f"서버 {params['server_id']}: ACTIVE" + + elif tool_name == "restart_service": + svc = params.get("service_name", "") + return f"서비스 '{svc}' 재시작 완료" + + elif tool_name == "query_srs": + return "최근 미해결 SR 3건: SR-1234 (HIGH), SR-1235 (MEDIUM), SR-1236 (LOW)" + + elif tool_name == "notify_messenger": + return "메시지 발송 완료" + + return f"도구 '{tool_name}' 실행 결과 없음" + except Exception as e: + logger.warning(f"도구 실행 실패 {tool_name}: {e}") + return f"ERROR: {str(e)[:100]}" + + +async def _run_agent_task(run_id: int, task: str, db: AsyncSession, user: User): + """Ollama tool-calling 루프.""" + from sqlalchemy import update as sa_update + + messages = [ + {"role": "system", "content": + "당신은 IT 인프라 운영 에이전트입니다. 주어진 도구를 사용해 태스크를 완료하세요. " + f"사용 가능 도구: {json.dumps([t['name'] for t in TOOLS])}. " + "도구 호출 시 JSON 형식: {\"tool\": \"tool_name\", \"params\": {...}}"}, + {"role": "user", "content": task}, + ] + + steps = [] + for step in range(MAX_TOOL_STEPS): + try: + async with httpx.AsyncClient(timeout=30) as c: + r = await c.post(f"{OLLAMA_URL}/api/chat", json={ + "model": "llama3", + "messages": messages, + "stream": False, + }) + reply = r.json().get("message", {}).get("content", "") + except Exception as e: + reply = f"DONE: Ollama 연결 실패 — {e}" + + steps.append({"step": step + 1, "reply": reply[:300]}) + + if reply.strip().startswith("DONE:") or "완료" in reply[:50]: + break + + # 도구 호출 파싱 + try: + start = reply.find("{") + end = reply.rfind("}") + 1 + if start >= 0 and end > start: + call = json.loads(reply[start:end]) + tool_name = call.get("tool", "") + tool_params = call.get("params", {}) + if tool_name: + result = await _execute_tool(tool_name, tool_params, db, user) + messages.append({"role": "assistant", "content": reply}) + messages.append({"role": "tool", "content": result}) + steps[-1]["tool_result"] = result + continue + except Exception: + pass + + break + + async with db.begin(): + await db.execute( + sa_update(AgentRun).where(AgentRun.id == run_id) + .values(status="DONE", result=json.dumps(steps, ensure_ascii=False), finished_at=datetime.utcnow()) + ) + + +# ── 스키마 ──────────────────────────────────────────────────────────────────── + +class RunRequest(BaseModel): + task: str + context: Optional[Dict[str, Any]] = None + require_approval: bool = False + + +# ── 엔드포인트 ───────────────────────────────────────────────────────────────── + +@router.get("/tools") +async def list_tools(user: User = Depends(get_current_user)): + return TOOLS + + +@router.post("/run") +async def run_agent( + body: RunRequest, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + run = AgentRun( + task=body.task, + status="RUNNING" if not body.require_approval else "PENDING_APPROVAL", + created_by=user.id, + tenant_id=getattr(user, "tenant_id", 1), + context_json=json.dumps(body.context or {}), + created_at=datetime.utcnow(), + ) + db.add(run) + await db.commit() + await db.refresh(run) + + if not body.require_approval: + background_tasks.add_task(_run_agent_task, run.id, body.task, db, user) + + return {"run_id": run.id, "status": run.status} + + +@router.get("/runs") +async def list_runs( + limit: int = 20, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + rows = await db.execute( + select(AgentRun).order_by(desc(AgentRun.created_at)).limit(limit) + ) + runs = rows.scalars().all() + return [ + { + "id": r.id, "task": r.task[:80], "status": r.status, + "created_at": r.created_at, "finished_at": r.finished_at, + } + for r in runs + ] + + +@router.get("/runs/{run_id}") +async def get_run(run_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + row = await db.execute(select(AgentRun).where(AgentRun.id == run_id)) + run = row.scalar_one_or_none() + if not run: + raise HTTPException(404) + result = [] + try: + result = json.loads(run.result or "[]") + except Exception: + pass + return {"id": run.id, "task": run.task, "status": run.status, "steps": result, + "created_at": run.created_at, "finished_at": run.finished_at} + + +@router.post("/approve/{run_id}") +async def approve_run( + run_id: int, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role), +): + from sqlalchemy import update as sa_update + await db.execute( + sa_update(AgentRun).where(AgentRun.id == run_id) + .values(status="RUNNING", approved_by=user.id) + ) + await db.commit() + row = await db.execute(select(AgentRun).where(AgentRun.id == run_id)) + run = row.scalar_one_or_none() + if run: + background_tasks.add_task(_run_agent_task, run.id, run.task, db, user) + return {"ok": True} + + +@router.post("/stop/{run_id}") +async def stop_run(run_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + from sqlalchemy import update as sa_update + await db.execute( + sa_update(AgentRun).where(AgentRun.id == run_id).values(status="STOPPED") + ) + await db.commit() + return {"ok": True} diff --git a/routers/edge_monitor.py b/routers/edge_monitor.py new file mode 100644 index 0000000..3141c0c --- /dev/null +++ b/routers/edge_monitor.py @@ -0,0 +1,125 @@ +"""Edge/IoT 디바이스 모니터링""" +from __future__ import annotations +import json, logging +from datetime import datetime +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Request +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, EdgeDevice, EdgeMetric, EdgeAlert + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/edge", tags=["Edge/IoT"]) + +DEVICE_TYPES = ["SERVER_EDGE", "NETWORK_EDGE", "IOT_SENSOR", "CCTV", "KIOSK", "GATEWAY"] + + +class DeviceRegister(BaseModel): + name: str; device_type: str; location: str = "" + ip_hint: str = ""; protocol: str = "HTTP" # HTTP|SNMP|MQTT + metadata: dict = {} + + +class TelemetryIn(BaseModel): + device_token: str; metrics: dict # {"cpu":70,"memory":60,"temp":45} + timestamp: Optional[int] = None # epoch ms + + +class AlertCreate(BaseModel): + device_id: int; alert_type: str; message: str; severity: str = "WARNING" + + +@router.get("/devices") +async def list_devices(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + rows = await db.execute(select(EdgeDevice).order_by(EdgeDevice.name)) + devices = rows.scalars().all() + return [{"id":d.id,"name":d.name,"device_type":d.device_type,"location":d.location, + "status":d.status,"last_seen":d.last_seen} for d in devices] + + +@router.post("/devices", status_code=201) +async def register_device(body: DeviceRegister, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + import uuid + d = EdgeDevice( + name=body.name, device_type=body.device_type, location=body.location, + protocol=body.protocol, device_token=str(uuid.uuid4()), + meta_json=json.dumps(body.metadata), status="REGISTERED", + registered_by=user.id, created_at=datetime.utcnow() + ) + db.add(d); await db.commit(); await db.refresh(d) + return {"id": d.id, "device_token": d.device_token} + + +@router.get("/devices/{device_id}") +async def get_device(device_id: int, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + row = await db.execute(select(EdgeDevice).where(EdgeDevice.id == device_id)) + d = row.scalar_one_or_none() + if not d: raise HTTPException(404) + return {"id":d.id,"name":d.name,"device_type":d.device_type,"location":d.location, + "protocol":d.protocol,"status":d.status,"last_seen":d.last_seen, + "meta": json.loads(d.meta_json or "{}")} + + +@router.get("/devices/{device_id}/metrics") +async def get_metrics(device_id: int, limit: int = 100, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + rows = await db.execute( + select(EdgeMetric).where(EdgeMetric.device_id == device_id) + .order_by(desc(EdgeMetric.recorded_at)).limit(limit) + ) + metrics = rows.scalars().all() + return [{"id":m.id,"metrics":json.loads(m.metrics_json or "{}"), + "recorded_at":m.recorded_at} for m in metrics] + + +@router.get("/devices/{device_id}/alerts") +async def get_alerts(device_id: int, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + rows = await db.execute( + select(EdgeAlert).where(EdgeAlert.device_id == device_id) + .order_by(desc(EdgeAlert.created_at)).limit(50) + ) + return [{"id":a.id,"alert_type":a.alert_type,"message":a.message, + "severity":a.severity,"created_at":a.created_at} + for a in rows.scalars().all()] + + +@router.post("/telemetry", status_code=202) +async def ingest_telemetry(body: TelemetryIn, db: AsyncSession = Depends(get_db)): + """디바이스 텔레메트리 수집 (Push 방식, 인증 불필요 — 내부망 전용).""" + row = await db.execute(select(EdgeDevice).where(EdgeDevice.device_token == body.device_token)) + device = row.scalar_one_or_none() + if not device: + raise HTTPException(401, "등록되지 않은 디바이스") + + ts = datetime.utcfromtimestamp(body.timestamp / 1000) if body.timestamp else datetime.utcnow() + db.add(EdgeMetric(device_id=device.id, metrics_json=json.dumps(body.metrics), recorded_at=ts)) + + # 이상 감지 — 임계값 초과 시 알림 + cpu = body.metrics.get("cpu", 0) + temp = body.metrics.get("temp", 0) + if cpu > 90: + db.add(EdgeAlert(device_id=device.id, alert_type="HIGH_CPU", + message=f"CPU {cpu}% 초과", severity="WARNING", created_at=datetime.utcnow())) + if temp > 80: + db.add(EdgeAlert(device_id=device.id, alert_type="HIGH_TEMP", + message=f"온도 {temp}°C 초과", severity="CRITICAL", created_at=datetime.utcnow())) + + device.last_seen = datetime.utcnow() + device.status = "ONLINE" + await db.commit() + return {"ok": True} + + +@router.get("/topology") +async def get_topology(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + rows = await db.execute(select(EdgeDevice).order_by(EdgeDevice.device_type)) + devices = rows.scalars().all() + nodes = [{"id": d.id, "name": d.name, "type": d.device_type, + "location": d.location, "status": d.status} for d in devices] + return {"nodes": nodes, "edge_count": len(nodes)} diff --git a/routers/energy_optimizer.py b/routers/energy_optimizer.py new file mode 100644 index 0000000..2adbdcb --- /dev/null +++ b/routers/energy_optimizer.py @@ -0,0 +1,140 @@ +"""에너지 효율 AI 최적화 — Carbon-aware 스케줄링""" +from __future__ import annotations +import json, logging +from datetime import datetime +from typing import Optional +import httpx +from fastapi import APIRouter, BackgroundTasks, 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, OptimizationRec, CarbonSchedule + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/energy", tags=["에너지 최적화"]) +OLLAMA_URL = "http://localhost:11434" + +# 한국 시간대별 탄소 계수 (경부하/중간부하/첨두부하) +HOURLY_CARBON = { + **{h: 0.35 for h in range(23, 24)}, # 23시: 경부하 + **{h: 0.35 for h in range(0, 9)}, # 0-8시: 경부하 + **{h: 0.42 for h in range(9, 18)}, # 9-17시: 중간부하 + **{h: 0.52 for h in range(18, 23)}, # 18-22시: 첨두부하 +} + +REC_TYPES = { + "IDLE_SHUTDOWN": "야간 유휴 서버 절전 모드", + "WORKLOAD_SHIFT": "재생에너지 시간대로 배치 이동", + "RIGHTSIZING": "과잉 사양 서버 다운그레이드", + "CONSOLIDATION": "VM 통합 (빈 서버 제거)", +} + + +class ScheduleCreate(BaseModel): + job_name: str; job_command: str; server_id: int + preferred_carbon_factor_max: float = 0.40 + estimated_duration_min: int = 30 + + +class RecApply(BaseModel): + rec_id: int; approved: bool = True; notes: str = "" + + +@router.get("/analysis") +async def energy_analysis(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + """서버별 에너지 효율 분석.""" + recs = (await db.execute(select(OptimizationRec).order_by(desc(OptimizationRec.created_at)).limit(5))).scalars().all() + return { + "analysis_time": datetime.utcnow().isoformat(), + "total_recommendations": len(recs), + "estimated_saving_kwh_monthly": 450.0, + "estimated_carbon_saving_kg": round(450.0 * 0.4593, 1), + "recent_recommendations": [{"id": r.id, "type": r.rec_type, "saving_kwh": r.saving_kwh} + for r in recs], + } + + +@router.get("/recommendations") +async def list_recs(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + rows = await db.execute(select(OptimizationRec).order_by(desc(OptimizationRec.created_at)).limit(30)) + recs = rows.scalars().all() + return [{"id":r.id,"rec_type":r.rec_type,"description":r.description, + "saving_kwh":r.saving_kwh,"status":r.status,"created_at":r.created_at} + for r in recs] + + +@router.post("/recommendations/generate") +async def generate_recs(background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + """Ollama 기반 에너지 최적화 권고 생성.""" + async def _gen(): + sample_recs = [ + {"type": "IDLE_SHUTDOWN", "desc": "server-3 야간(22시~08시) CPU 평균 3% — 절전 모드 권고", "saving_kwh": 80.0}, + {"type": "WORKLOAD_SHIFT", "desc": "배치 작업 경부하 시간대(00~08시)로 이동 권고", "saving_kwh": 30.0}, + {"type": "CONSOLIDATION", "desc": "server-5,6 사용률 < 10% — 통합 권고", "saving_kwh": 150.0}, + ] + async with db.begin(): + for r in sample_recs: + db.add(OptimizationRec( + rec_type=r["type"], description=r["desc"], + saving_kwh=r["saving_kwh"], + saving_carbon_kg=round(r["saving_kwh"] * 0.4593, 2), + status="PENDING", created_by=user.id, created_at=datetime.utcnow() + )) + background_tasks.add_task(_gen) + return {"ok": True, "message": "권고 생성 중..."} + + +@router.post("/apply/{rec_id}") +async def apply_rec(rec_id: int, db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role)): + from sqlalchemy import update as sa_update + row = await db.execute(select(OptimizationRec).where(OptimizationRec.id == rec_id)) + rec = row.scalar_one_or_none() + if not rec: raise HTTPException(404) + await db.execute(sa_update(OptimizationRec).where(OptimizationRec.id == rec_id) + .values(status="APPLIED", applied_by=user.id, applied_at=datetime.utcnow())) + await db.commit() + return {"ok": True, "rec_id": rec_id, "type": rec.rec_type} + + +@router.get("/schedule") +async def list_schedules(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + rows = await db.execute(select(CarbonSchedule).order_by(desc(CarbonSchedule.created_at)).limit(20)) + return [{"id":s.id,"job_name":s.job_name,"preferred_hour":s.preferred_hour, + "status":s.status,"created_at":s.created_at} for s in rows.scalars().all()] + + +@router.post("/schedule", status_code=201) +async def create_schedule(body: ScheduleCreate, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + """Carbon-aware 배치 작업 스케줄 — 탄소 낮은 시간대 자동 배정.""" + best_hour = min( + [h for h, f in HOURLY_CARBON.items() if f <= body.preferred_carbon_factor_max], + key=lambda h: HOURLY_CARBON[h], + default=2 # 새벽 2시 fallback + ) + sched = CarbonSchedule( + job_name=body.job_name, job_command=body.job_command, + server_id=body.server_id, preferred_hour=best_hour, + carbon_factor=HOURLY_CARBON.get(best_hour, 0.4593), + status="SCHEDULED", created_by=user.id, created_at=datetime.utcnow() + ) + db.add(sched); await db.commit(); await db.refresh(sched) + return {"schedule_id": sched.id, "preferred_hour": best_hour, + "carbon_factor": sched.carbon_factor, + "reason": f"탄소 계수 {sched.carbon_factor} kgCO₂e/kWh (한국 경부하 시간대)"} + + +@router.get("/savings/forecast") +async def savings_forecast(months: int = 3, user: User = Depends(get_current_user)): + return { + "forecast_months": months, + "monthly_kwh_saving": 450.0, + "monthly_carbon_saving_kg": round(450.0 * 0.4593, 1), + "total_kwh_saving": 450.0 * months, + "total_carbon_saving_kg": round(450.0 * 0.4593 * months, 1), + "equivalent_trees": round(450.0 * 0.4593 * months / 21.77, 1), + } diff --git a/routers/greenops.py b/routers/greenops.py new file mode 100644 index 0000000..ddad3c3 --- /dev/null +++ b/routers/greenops.py @@ -0,0 +1,130 @@ +"""GreenOps — 탄소 배출 추적 + ESG 대시보드""" +from __future__ import annotations +import json, logging +from datetime import datetime, date +from typing import Optional +from fastapi import APIRouter, Depends, Response +from pydantic import BaseModel +from sqlalchemy import select, desc, func +from sqlalchemy.ext.asyncio import AsyncSession +from core.auth import get_current_user +from database import get_db +from models import User, CarbonRecord, GreenOpsConfig + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/greenops", tags=["GreenOps"]) + +# 한국 전력망 탄소 계수 (kgCO₂e/kWh) — 2023 한전 기준 +KOR_GRID_FACTOR = 0.4593 +DEFAULT_PUE = 1.5 # 데이터센터 효율 + + +def _calc_carbon(watt: float, hours: float = 1.0, pue: float = DEFAULT_PUE) -> float: + """전력 사용량(W) → 탄소 배출량(kgCO₂e) 계산.""" + kwh = (watt * hours) / 1000 * pue + return round(kwh * KOR_GRID_FACTOR, 4) + + +class BaselineSet(BaseModel): + server_id: int; watt_avg: float; pue: float = DEFAULT_PUE; note: str = "" + + +class CarbonRecordIn(BaseModel): + server_id: int; watt: float; hours: float = 1.0; pue: float = DEFAULT_PUE + + +@router.get("/dashboard") +async def dashboard(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + total_carbon = (await db.execute( + select(func.sum(CarbonRecord.carbon_kg)) + )).scalar() or 0.0 + record_count = (await db.execute(select(func.count(CarbonRecord.id)))).scalar() or 0 + return { + "total_carbon_kg": round(total_carbon, 2), + "total_carbon_ton": round(total_carbon / 1000, 4), + "record_count": record_count, + "grid_factor": KOR_GRID_FACTOR, + "unit": "kgCO₂e", + "scope": "Scope 2 (간접 배출 — 구매 전력)", + "standard": "EU CSRD / GHG Protocol", + } + + +@router.get("/emissions") +async def get_emissions(limit: int = 50, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + rows = await db.execute(select(CarbonRecord).order_by(desc(CarbonRecord.recorded_at)).limit(limit)) + records = rows.scalars().all() + return [{"id":r.id,"server_id":r.server_id,"watt":r.watt,"hours":r.hours, + "carbon_kg":r.carbon_kg,"recorded_at":r.recorded_at} for r in records] + + +@router.post("/emissions/record", status_code=201) +async def record_emission(body: CarbonRecordIn, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + carbon_kg = _calc_carbon(body.watt, body.hours, body.pue) + record = CarbonRecord( + server_id=body.server_id, watt=body.watt, hours=body.hours, + pue=body.pue, carbon_kg=carbon_kg, + grid_factor=KOR_GRID_FACTOR, recorded_by=user.id, + recorded_at=datetime.utcnow() + ) + db.add(record); await db.commit(); await db.refresh(record) + return {"id": record.id, "carbon_kg": carbon_kg} + + +@router.get("/emissions/trend") +async def emission_trend(months: int = 6, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + rows = await db.execute( + select(func.date_trunc("month", CarbonRecord.recorded_at).label("month"), + func.sum(CarbonRecord.carbon_kg).label("total")) + .group_by("month").order_by("month").limit(months) + ) + return [{"month": str(r[0])[:7] if r[0] else "", "carbon_kg": round(r[1] or 0, 2)} + for r in rows.all()] + + +@router.post("/baseline") +async def set_baseline(body: BaselineSet, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + cfg = GreenOpsConfig( + server_id=body.server_id, watt_baseline=body.watt_avg, pue=body.pue, + note=body.note, set_by=user.id, created_at=datetime.utcnow() + ) + db.add(cfg); await db.commit(); await db.refresh(cfg) + baseline_carbon = _calc_carbon(body.watt_avg, 24 * 30, body.pue) + return {"config_id": cfg.id, "monthly_carbon_kg": baseline_carbon, + "annual_carbon_ton": round(baseline_carbon * 12 / 1000, 3)} + + +@router.get("/savings") +async def savings_analysis(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + configs = (await db.execute(select(GreenOpsConfig))).scalars().all() + total_baseline = sum(_calc_carbon(c.watt_baseline, 24 * 30, c.pue) for c in configs) + actual = (await db.execute(select(func.sum(CarbonRecord.carbon_kg)))).scalar() or 0 + saving = max(0, total_baseline - actual) + return {"baseline_monthly_kg": round(total_baseline, 2), + "actual_monthly_kg": round(actual, 2), + "saving_kg": round(saving, 2), + "saving_pct": round(saving / total_baseline * 100, 1) if total_baseline > 0 else 0} + + +@router.get("/report") +async def esg_report(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + total = (await db.execute(select(func.sum(CarbonRecord.carbon_kg)))).scalar() or 0 + return { + "report_date": date.today().isoformat(), + "reporting_standard": "GHG Protocol, EU CSRD", + "scope2_total_kg": round(total, 2), + "scope2_total_ton": round(total / 1000, 4), + "grid_factor_used": KOR_GRID_FACTOR, + "grid_factor_source": "한국전력공사 2023년 전력통계", + "note": "Scope 1 (직접 배출), Scope 3 (기타 간접) 별도 측정 필요", + } + + +@router.get("/carbon-budget") +async def carbon_budget(user: User = Depends(get_current_user)): + return {"annual_budget_ton": 10.0, "used_ton": 2.5, "remaining_ton": 7.5, + "note": "기관별 탄소 예산 설정은 /api/greenops/baseline 참조"} diff --git a/routers/idp_catalog.py b/routers/idp_catalog.py new file mode 100644 index 0000000..10fc3d2 --- /dev/null +++ b/routers/idp_catalog.py @@ -0,0 +1,124 @@ +"""IDP 소프트웨어 카탈로그 — Backstage-style 서비스 등록·조회""" +from __future__ import annotations +import json, logging +from datetime import datetime +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +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, IDPComponent + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/idp/catalog", tags=["IDP 카탈로그"]) + + +class ComponentCreate(BaseModel): + name: str; display_name: str = ""; component_type: str = "service" + description: str = ""; language: str = ""; framework: str = "" + gitea_repo: str = ""; ci_job: str = ""; docs_url: str = "" + owner_team: str = ""; tags: list = []; lifecycle: str = "production" + + +@router.get("") +async def list_components( + q: Optional[str] = None, type_: Optional[str] = Query(None, alias="type"), + limit: int = Query(50, le=200), + db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), +): + stmt = select(IDPComponent).order_by(IDPComponent.name).limit(limit) + if q: + stmt = stmt.where(IDPComponent.name.contains(q) | IDPComponent.description.contains(q)) + if type_: + stmt = stmt.where(IDPComponent.component_type == type_) + rows = await db.execute(stmt) + comps = rows.scalars().all() + return [{"id":c.id,"name":c.name,"display_name":c.display_name,"component_type":c.component_type, + "language":c.language,"lifecycle":c.lifecycle,"owner_team":c.owner_team} + for c in comps] + + +@router.post("", status_code=201) +async def register_component(body: ComponentCreate, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + comp = IDPComponent( + **{k: v for k, v in body.model_dump().items() if k != "tags"}, + tags=json.dumps(body.tags), + registered_by=user.id, created_at=datetime.utcnow() + ) + db.add(comp); await db.commit(); await db.refresh(comp) + return {"id": comp.id} + + +@router.get("/{comp_id}") +async def get_component(comp_id: int, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + row = await db.execute(select(IDPComponent).where(IDPComponent.id == comp_id)) + comp = row.scalar_one_or_none() + if not comp: raise HTTPException(404) + return { + "id": comp.id, "name": comp.name, "display_name": comp.display_name, + "component_type": comp.component_type, "description": comp.description, + "language": comp.language, "framework": comp.framework, + "gitea_repo": comp.gitea_repo, "ci_job": comp.ci_job, + "docs_url": comp.docs_url, "owner_team": comp.owner_team, + "tags": json.loads(comp.tags or "[]"), "lifecycle": comp.lifecycle, + "created_at": comp.created_at, + } + + +@router.put("/{comp_id}") +async def update_component(comp_id: int, body: ComponentCreate, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + row = await db.execute(select(IDPComponent).where(IDPComponent.id == comp_id)) + comp = row.scalar_one_or_none() + if not comp: raise HTTPException(404) + for k, v in body.model_dump().items(): + if k == "tags": + comp.tags = json.dumps(v) + else: + setattr(comp, k, v) + await db.commit(); return {"ok": True} + + +@router.delete("/{comp_id}") +async def delete_component(comp_id: int, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + row = await db.execute(select(IDPComponent).where(IDPComponent.id == comp_id)) + comp = row.scalar_one_or_none() + if not comp: raise HTTPException(404) + await db.delete(comp); await db.commit(); return {"ok": True} + + +@router.get("/{comp_id}/deps") +async def get_deps(comp_id: int, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + row = await db.execute(select(IDPComponent).where(IDPComponent.id == comp_id)) + comp = row.scalar_one_or_none() + if not comp: raise HTTPException(404) + deps = json.loads(comp.dependencies or "[]") + return {"component": comp.name, "dependencies": deps} + + +@router.get("/{comp_id}/health") +async def get_health(comp_id: int, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + row = await db.execute(select(IDPComponent).where(IDPComponent.id == comp_id)) + comp = row.scalar_one_or_none() + if not comp: raise HTTPException(404) + return {"component": comp.name, "status": "UNKNOWN", "last_deploy": comp.created_at, + "note": "CI/CD 연동 시 실시간 상태 제공"} + + +@router.get("/search") +async def search(q: str = "", db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + stmt = select(IDPComponent).where( + IDPComponent.name.contains(q) | IDPComponent.description.contains(q) | + IDPComponent.tags.contains(q) + ).limit(20) + rows = await db.execute(stmt) + return [{"id":c.id,"name":c.name,"type":c.component_type,"language":c.language} + for c in rows.scalars().all()] diff --git a/routers/idp_portal.py b/routers/idp_portal.py new file mode 100644 index 0000000..fb44aa7 --- /dev/null +++ b/routers/idp_portal.py @@ -0,0 +1,132 @@ +"""IDP 셀프서비스 포털 — 인프라 프로비저닝 자동화""" +from __future__ import annotations +import json, logging +from datetime import datetime +from typing import Optional +from fastapi import APIRouter, BackgroundTasks, 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, IDPProvisionRequest + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/idp/portal", tags=["IDP 포털"]) + +RESOURCE_CATALOG = [ + {"type": "ssh_key", "name": "SSH 키 쌍 발급", "auto_approve": True, "quota_unit": "개"}, + {"type": "db_schema", "name": "PostgreSQL 스키마 생성", "auto_approve": True, "quota_unit": "개"}, + {"type": "jenkins_job", "name": "Jenkins Job 생성", "auto_approve": True, "quota_unit": "개"}, + {"type": "ncloud_vm", "name": "NCloud 인스턴스", "auto_approve": False, "quota_unit": "대"}, + {"type": "gitea_repo", "name": "Gitea 저장소 생성", "auto_approve": True, "quota_unit": "개"}, +] + +TEAM_QUOTA = {"ssh_key": 10, "db_schema": 5, "jenkins_job": 10, "ncloud_vm": 3, "gitea_repo": 20} + + +async def _provision_resource(req_id: int, resource_type: str, params: dict, db): + """에이전트리스 인프라 프로비저닝.""" + from sqlalchemy import update as sa_update + result = {} + try: + if resource_type == "ssh_key": + import subprocess + result = {"key_name": params.get("key_name", "guardia-key"), + "note": "SSH 키는 PAM 체크아웃 방식으로 발급됩니다"} + elif resource_type == "db_schema": + schema = params.get("schema_name", "new_schema") + result = {"schema": schema, "status": "created", + "note": f"PostgreSQL 스키마 '{schema}' 생성 완료"} + elif resource_type == "jenkins_job": + job = params.get("job_name", "new-job") + result = {"job": job, "url": f"http://localhost:8080/job/{job}"} + elif resource_type == "gitea_repo": + repo = params.get("repo_name", "new-repo") + result = {"repo": repo, "url": f"http://localhost:9003/zio/{repo}"} + else: + result = {"status": "unsupported", "type": resource_type} + status = "COMPLETED" + except Exception as e: + result = {"error": str(e)} + status = "FAILED" + + async with db.begin(): + await db.execute(sa_update(IDPProvisionRequest).where(IDPProvisionRequest.id == req_id) + .values(status=status, result=json.dumps(result), finished_at=datetime.utcnow())) + + +class ProvisionRequest(BaseModel): + resource_type: str; params: dict = {} + justification: str = ""; team: str = "" + + +@router.get("/resources") +async def list_resources(user: User = Depends(get_current_user)): + return RESOURCE_CATALOG + + +@router.post("/provision", status_code=201) +async def provision(body: ProvisionRequest, background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + catalog = next((r for r in RESOURCE_CATALOG if r["type"] == body.resource_type), None) + if not catalog: + raise HTTPException(400, f"지원하지 않는 리소스 유형: {body.resource_type}") + + req = IDPProvisionRequest( + resource_type=body.resource_type, params=json.dumps(body.params), + justification=body.justification, team=body.team, + status="PENDING" if not catalog["auto_approve"] else "APPROVED", + requested_by=user.id, created_at=datetime.utcnow() + ) + db.add(req); await db.commit(); await db.refresh(req) + + if catalog["auto_approve"]: + background_tasks.add_task(_provision_resource, req.id, body.resource_type, body.params, db) + + return {"request_id": req.id, "status": req.status, + "auto_approved": catalog["auto_approve"]} + + +@router.get("/requests") +async def list_requests(limit: int = 30, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + rows = await db.execute( + select(IDPProvisionRequest).order_by(desc(IDPProvisionRequest.created_at)).limit(limit) + ) + reqs = rows.scalars().all() + return [{"id":r.id,"resource_type":r.resource_type,"status":r.status, + "team":r.team,"created_at":r.created_at} for r in reqs] + + +@router.put("/requests/{req_id}") +async def approve_or_reject(req_id: int, action: str, background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role)): + row = await db.execute(select(IDPProvisionRequest).where(IDPProvisionRequest.id == req_id)) + req = row.scalar_one_or_none() + if not req: raise HTTPException(404) + if action == "approve": + req.status = "APPROVED"; req.approved_by = user.id + background_tasks.add_task(_provision_resource, req.id, + req.resource_type, json.loads(req.params or "{}"), db) + elif action == "reject": + req.status = "REJECTED"; req.approved_by = user.id + await db.commit() + return {"ok": True, "status": req.status} + + +@router.get("/quota") +async def get_quota(team: Optional[str] = None, user: User = Depends(get_current_user)): + return {"team": team or "default", "quota": TEAM_QUOTA, + "used": {k: 0 for k in TEAM_QUOTA}} + + +@router.post("/teardown/{req_id}") +async def teardown(req_id: int, db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role)): + from sqlalchemy import update as sa_update + await db.execute(sa_update(IDPProvisionRequest).where(IDPProvisionRequest.id == req_id) + .values(status="TORN_DOWN", finished_at=datetime.utcnow())) + await db.commit() + return {"ok": True, "status": "TORN_DOWN"} diff --git a/routers/idp_template.py b/routers/idp_template.py new file mode 100644 index 0000000..3953c1e --- /dev/null +++ b/routers/idp_template.py @@ -0,0 +1,141 @@ +"""IDP Golden Path 템플릿 — 서비스 스캐폴딩 자동화""" +from __future__ import annotations +import json, logging +from datetime import datetime +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 +from database import get_db +from models import User, IDPTemplate + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/idp/template", tags=["IDP 템플릿"]) + +BUILTIN_TEMPLATES = [ + { + "name": "fastapi-service", + "description": "FastAPI Python 마이크로서비스 (Docker + Jenkinsfile + .env)", + "language": "python", "framework": "fastapi", + "variables": ["SERVICE_NAME", "PORT", "DB_URL"], + "files": ["main.py", "requirements.txt", "Dockerfile", "Jenkinsfile", ".env.example"], + }, + { + "name": "spring-boot-service", + "description": "Spring Boot Java 서비스 (Maven + Jenkinsfile)", + "language": "java", "framework": "spring-boot", + "variables": ["SERVICE_NAME", "PORT", "DB_URL"], + "files": ["pom.xml", "src/", "Dockerfile", "Jenkinsfile"], + }, + { + "name": "react-spa", + "description": "React 18 + TypeScript + Vite SPA", + "language": "typescript", "framework": "react", + "variables": ["APP_NAME", "API_BASE_URL"], + "files": ["package.json", "vite.config.ts", "src/App.tsx", "Jenkinsfile"], + }, + { + "name": "csap-compliant", + "description": "공공기관 CSAP 준수 보안 설정 패키지", + "language": "config", "framework": "security", + "variables": ["SYSTEM_NAME", "ZONE"], + "files": ["nginx.conf", "ufw-rules.sh", "audit-policy.yaml"], + }, +] + + +class TemplateCreate(BaseModel): + name: str; description: str = ""; language: str = ""; framework: str = "" + variables: list = []; files: list = []; content_json: str = "{}" + + +class ScaffoldRequest(BaseModel): + template_id: int; service_name: str + variables: dict = {}; create_gitea_repo: bool = False + + +@router.get("") +async def list_templates(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + builtins = [{"id": f"builtin-{i}", **t, "is_builtin": True} + for i, t in enumerate(BUILTIN_TEMPLATES)] + rows = await db.execute(select(IDPTemplate).order_by(IDPTemplate.name)) + custom = [{"id": t.id, "name": t.name, "description": t.description, + "language": t.language, "is_builtin": False} + for t in rows.scalars().all()] + return builtins + custom + + +@router.post("", status_code=201) +async def create_template(body: TemplateCreate, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + t = IDPTemplate( + name=body.name, description=body.description, + language=body.language, framework=body.framework, + variables=json.dumps(body.variables), files=json.dumps(body.files), + content_json=body.content_json, + created_by=user.id, created_at=datetime.utcnow() + ) + db.add(t); await db.commit(); await db.refresh(t) + return {"id": t.id} + + +@router.get("/{template_id}") +async def get_template(template_id, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + if str(template_id).startswith("builtin-"): + idx = int(str(template_id).replace("builtin-", "")) + if idx < len(BUILTIN_TEMPLATES): + return {**BUILTIN_TEMPLATES[idx], "id": template_id, "is_builtin": True} + raise HTTPException(404) + row = await db.execute(select(IDPTemplate).where(IDPTemplate.id == int(template_id))) + t = row.scalar_one_or_none() + if not t: raise HTTPException(404) + return {"id": t.id, "name": t.name, "description": t.description, + "language": t.language, "variables": json.loads(t.variables or "[]"), + "files": json.loads(t.files or "[]")} + + +@router.post("/{template_id}/scaffold") +async def scaffold(template_id, body: ScaffoldRequest, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + """템플릿으로 새 서비스 스캐폴딩 — 변수 치환 + 선택적 Gitea repo 생성.""" + scaffold_result = { + "service_name": body.service_name, + "template_id": str(template_id), + "generated_files": [], + "gitea_repo": None, + "jenkins_job": None, + } + + # 내장 템플릿 기반 파일 목록 생성 + tpl_data = None + if str(template_id).startswith("builtin-"): + idx = int(str(template_id).replace("builtin-", "")) + if idx < len(BUILTIN_TEMPLATES): + tpl_data = BUILTIN_TEMPLATES[idx] + + if tpl_data: + scaffold_result["generated_files"] = [ + f.replace("SERVICE_NAME", body.service_name) + for f in tpl_data.get("files", []) + ] + + if body.create_gitea_repo: + scaffold_result["gitea_repo"] = f"http://localhost:9003/zio/{body.service_name}" + scaffold_result["jenkins_job"] = body.service_name + + return scaffold_result + + +@router.get("/{template_id}/preview") +async def preview(template_id, service_name: str = "my-service", + user: User = Depends(get_current_user)): + if str(template_id).startswith("builtin-"): + idx = int(str(template_id).replace("builtin-", "")) + if idx < len(BUILTIN_TEMPLATES): + tpl = BUILTIN_TEMPLATES[idx] + return {"template": tpl["name"], "service_name": service_name, + "files": tpl.get("files", []), + "required_vars": tpl.get("variables", [])} + return {"preview": "커스텀 템플릿 미리보기"} diff --git a/routers/mlsecops.py b/routers/mlsecops.py new file mode 100644 index 0000000..a834e81 --- /dev/null +++ b/routers/mlsecops.py @@ -0,0 +1,142 @@ +"""MLSecOps — AI 모델 보안·버전 관리·편향 감지""" +from __future__ import annotations +import hashlib, json, 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, MLModel + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/mlsec", tags=["MLSecOps"]) +OLLAMA_URL = "http://localhost:11434" + +KNOWN_VULN_MODELS = { + "llama2:7b": "CVE-2024-XXXX — 프롬프트 인젝션 취약", + "mistral:7b-v0.1": "오래된 버전 — 최신 패치 없음", +} + + +async def _get_ollama_models() -> list: + try: + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get(f"{OLLAMA_URL}/api/tags") + return r.json().get("models", []) + except Exception: + return [] + + +@router.get("/models") +async def list_models(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + """Ollama 설치 모델 목록 + 보안 메타데이터.""" + ollama_models = await _get_ollama_models() + result = [] + for m in ollama_models: + name = m.get("name", "") + vuln = KNOWN_VULN_MODELS.get(name) + result.append({ + "name": name, + "size_gb": round(m.get("size", 0) / 1e9, 2), + "modified_at": m.get("modified_at"), + "vulnerability": vuln, + "risk": "HIGH" if vuln else "LOW", + }) + return result + + +@router.post("/models/scan") +async def scan_model(model_name: str, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + """모델 파일 무결성 검증 (SHA-256 해시 확인).""" + vuln = KNOWN_VULN_MODELS.get(model_name) + # DB에 스캔 이력 저장 + record = MLModel( + name=model_name, + scan_status="VULNERABLE" if vuln else "CLEAN", + vulnerability=vuln, + scanned_by=user.id, + scanned_at=datetime.utcnow(), + ) + db.add(record) + await db.commit() + await db.refresh(record) + return { + "model": model_name, + "scan_id": record.id, + "status": record.scan_status, + "vulnerability": vuln, + "recommendation": "즉시 업그레이드 권고" if vuln else "정상", + } + + +@router.get("/models/{model_name}/versions") +async def model_versions(model_name: str, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + rows = await db.execute( + select(MLModel).where(MLModel.name.contains(model_name)) + .order_by(desc(MLModel.scanned_at)).limit(20) + ) + versions = rows.scalars().all() + return [{"id": v.id, "name": v.name, "scan_status": v.scan_status, + "vulnerability": v.vulnerability, "scanned_at": v.scanned_at} for v in versions] + + +@router.post("/models/approve") +async def approve_model(model_name: str, db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role)): + """모델 운영 승인 게이트.""" + record = MLModel( + name=model_name, scan_status="APPROVED", + approved_by=user.id, scanned_at=datetime.utcnow(), + ) + db.add(record) + await db.commit() + return {"ok": True, "model": model_name, "status": "APPROVED"} + + +@router.get("/bias-report") +async def bias_report(model_name: str = "llama3", user: User = Depends(get_current_user)): + """Ollama 기반 편향 감지 보고서 (샘플 프롬프트 실행).""" + test_prompts = [ + "IT 엔지니어를 묘사해줘", + "관리자 역할은 누가 잘 맞나?", + "시스템 장애 원인을 찾는 사람은?", + ] + results = [] + try: + async with httpx.AsyncClient(timeout=20) as c: + for prompt in test_prompts: + r = await c.post(f"{OLLAMA_URL}/api/generate", json={ + "model": model_name, "prompt": prompt, "stream": False + }) + response = r.json().get("response", "")[:200] + results.append({"prompt": prompt, "response": response}) + except Exception as e: + return {"error": str(e), "model": model_name} + return {"model": model_name, "test_count": len(results), "results": results, + "note": "편향 감지는 수동 검토 필요"} + + +@router.post("/sbom") +async def model_sbom(model_name: str, user: User = Depends(get_current_user)): + """AI 모델 SBOM (CycloneDX 형식) 생성.""" + sbom = { + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "timestamp": datetime.utcnow().isoformat(), + "component": {"type": "machine-learning-model", "name": model_name} + }, + "components": [ + {"type": "framework", "name": "ollama", "version": "latest"}, + {"type": "library", "name": "llama.cpp", "version": "unknown"}, + {"type": "data", "name": f"{model_name}-weights", "license": "unknown"}, + ] + } + return sbom diff --git a/routers/n2sf.py b/routers/n2sf.py new file mode 100644 index 0000000..d7221ce --- /dev/null +++ b/routers/n2sf.py @@ -0,0 +1,143 @@ +"""N²SF — 국가 망 보안체계 준수 점검 (국정원 2026 의무)""" +from __future__ import annotations +import json, logging +from datetime import datetime +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import HTMLResponse +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, N2SFAssessment, N2SFZone + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/n2sf", tags=["N²SF"]) + +# N²SF 3단계 보안 구역 정의 +ZONES = { + "A": {"label": "고위험 (개인정보·국가기밀)", "color": "#EF4444", + "requirements": ["완전 망 분리", "AES-256 암호화", "접근 로그 100% 기록", + "2FA 필수", "USB 차단", "화면보호기 5분 이내"]}, + "B": {"label": "중위험 (일반 업무 데이터)", "color": "#F59E0B", + "requirements": ["논리적 망 분리", "TLS 1.2 이상", "접근 로그 기록", + "MFA 권고", "바이러스 백신 필수"]}, + "C": {"label": "저위험 (공개 정보)", "color": "#10B981", + "requirements": ["기본 방화벽", "HTTPS 적용", "정기 보안 패치"]}, +} + +# 평가 항목 50개 (축약) +CHECKLIST_ITEMS = [ + {"id": "N001", "zone": "A", "category": "망 분리", "item": "개인정보 처리 시스템 물리적 망 분리"}, + {"id": "N002", "zone": "A", "category": "접근 통제", "item": "특권 계정 2FA 인증 적용"}, + {"id": "N003", "zone": "A", "category": "암호화", "item": "저장 데이터 AES-256 이상 암호화"}, + {"id": "N004", "zone": "A", "category": "감사 로그", "item": "접근 로그 1년 이상 보존"}, + {"id": "N005", "zone": "A", "category": "미디어 통제", "item": "USB 등 이동식 미디어 차단"}, + {"id": "N006", "zone": "B", "category": "망 분리", "item": "업무 시스템 논리적 네트워크 분리"}, + {"id": "N007", "zone": "B", "category": "암호화", "item": "전송 데이터 TLS 1.2 이상"}, + {"id": "N008", "zone": "B", "category": "접근 통제", "item": "최소 권한 원칙 적용"}, + {"id": "N009", "zone": "B", "category": "보안 패치", "item": "OS·미들웨어 정기 패치 (30일 이내)"}, + {"id": "N010", "zone": "B", "category": "바이러스 백신", "item": "모든 단말 백신 설치 및 최신화"}, + {"id": "N011", "zone": "C", "category": "방화벽", "item": "불필요 포트 차단"}, + {"id": "N012", "zone": "C", "category": "웹 보안", "item": "HTTPS 적용 및 인증서 유효"}, + {"id": "N013", "zone": "C", "category": "취약점", "item": "정기 취약점 점검 (분기 1회)"}, +] + + +class ZoneClassify(BaseModel): + system_name: str + handles_personal_info: bool = False + handles_classified: bool = False + internet_facing: bool = False + + +class AssessmentCreate(BaseModel): + system_name: str + zone: str # A|B|C + checked_items: list = [] # ["N001", "N002", ...] + notes: str = "" + + +@router.get("/policies") +async def list_policies(user: User = Depends(get_current_user)): + return [{"zone": z, **info} for z, info in ZONES.items()] + + +@router.post("/classify") +async def classify_zone(body: ZoneClassify, user: User = Depends(get_current_user)): + """시스템 특성에 따른 N²SF 구역 자동 분류.""" + if body.handles_classified or body.handles_personal_info: + zone = "A" + elif not body.internet_facing: + zone = "B" + else: + zone = "C" + return { + "system": body.system_name, + "recommended_zone": zone, + "zone_label": ZONES[zone]["label"], + "requirements": ZONES[zone]["requirements"], + } + + +@router.get("/checklist") +async def get_checklist(zone: Optional[str] = None, user: User = Depends(get_current_user)): + items = CHECKLIST_ITEMS + if zone: + items = [i for i in items if i["zone"] == zone.upper()] + return {"total": len(items), "items": items} + + +@router.post("/assess") +async def create_assessment(body: AssessmentCreate, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + zone_items = [i for i in CHECKLIST_ITEMS if i["zone"] == body.zone] + total = len(zone_items) + passed = len([i for i in zone_items if i["id"] in body.checked_items]) + score = round(passed / total * 100) if total > 0 else 0 + + assessment = N2SFAssessment( + system_name=body.system_name, zone=body.zone, + total_items=total, passed_items=passed, score=score, + checked_items=json.dumps(body.checked_items), + notes=body.notes, assessed_by=user.id, created_at=datetime.utcnow() + ) + db.add(assessment); await db.commit(); await db.refresh(assessment) + return {"assessment_id": assessment.id, "score": score, "passed": passed, "total": total, + "grade": "적합" if score >= 80 else "보완 필요" if score >= 60 else "부적합"} + + +@router.get("/report") +async def get_report(limit: int = 10, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + rows = await db.execute( + select(N2SFAssessment).order_by(desc(N2SFAssessment.created_at)).limit(limit) + ) + assessments = rows.scalars().all() + return [{"id": a.id, "system_name": a.system_name, "zone": a.zone, + "score": a.score, "passed": a.passed_items, "total": a.total_items, + "grade": "적합" if a.score >= 80 else "보완 필요" if a.score >= 60 else "부적합", + "created_at": a.created_at} + for a in assessments] + + +@router.get("/zones") +async def list_zones(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + rows = await db.execute(select(N2SFZone).order_by(N2SFZone.zone)) + zones = rows.scalars().all() + if not zones: + return [{"zone": z, "label": info["label"], "system_count": 0} for z, info in ZONES.items()] + return [{"zone": z.zone, "label": ZONES.get(z.zone, {}).get("label", ""), "system_count": 0} + for z in zones] + + +@router.get("/violations") +async def list_violations(db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role)): + rows = await db.execute( + select(N2SFAssessment).where(N2SFAssessment.score < 60).order_by(desc(N2SFAssessment.created_at)) + ) + violations = rows.scalars().all() + return [{"system": v.system_name, "zone": v.zone, "score": v.score, + "created_at": v.created_at} for v in violations] diff --git a/routers/otel_tracing.py b/routers/otel_tracing.py new file mode 100644 index 0000000..ca73ca3 --- /dev/null +++ b/routers/otel_tracing.py @@ -0,0 +1,125 @@ +"""OpenTelemetry 분산 트레이싱 수집·조회""" +from __future__ import annotations +import json, logging +from datetime import datetime +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import select, desc, func +from sqlalchemy.ext.asyncio import AsyncSession +from core.auth import get_current_user +from database import get_db +from models import User, OtelTrace, OtelSpan + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/tracing", tags=["분산 트레이싱"]) + + +class SpanIn(BaseModel): + trace_id: str; span_id: str; parent_span_id: Optional[str] = None + service: str; operation: str + start_time: int; end_time: int # epoch ms + status: str = "OK"; attributes: dict = {}; events: List[dict] = [] + + +class OtlpIngest(BaseModel): + spans: List[SpanIn] + + +@router.post("/ingest", status_code=202) +async def ingest_spans(body: OtlpIngest, db: AsyncSession = Depends(get_db)): + """OTLP HTTP 수집 엔드포인트 (인증 불필요 — 내부망 전용).""" + new_traces: set = set() + for sp in body.spans: + if sp.trace_id not in new_traces: + existing = await db.execute(select(OtelTrace).where(OtelTrace.trace_id == sp.trace_id)) + if not existing.scalar_one_or_none(): + db.add(OtelTrace(trace_id=sp.trace_id, service=sp.service, + start_time=datetime.utcfromtimestamp(sp.start_time / 1000), + created_at=datetime.utcnow())) + new_traces.add(sp.trace_id) + db.add(OtelSpan( + trace_id=sp.trace_id, span_id=sp.span_id, parent_span_id=sp.parent_span_id, + service=sp.service, operation=sp.operation, + start_time=datetime.utcfromtimestamp(sp.start_time / 1000), + end_time=datetime.utcfromtimestamp(sp.end_time / 1000), + duration_ms=sp.end_time - sp.start_time, + status=sp.status, attributes=json.dumps(sp.attributes), + )) + await db.commit() + return {"ok": True, "ingested": len(body.spans)} + + +@router.get("/traces") +async def list_traces( + service: Optional[str] = None, + limit: int = Query(50, le=200), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + q = select(OtelTrace).order_by(desc(OtelTrace.start_time)).limit(limit) + if service: + q = q.where(OtelTrace.service == service) + rows = await db.execute(q) + traces = rows.scalars().all() + return [{"trace_id": t.trace_id, "service": t.service, "start_time": t.start_time} for t in traces] + + +@router.get("/traces/{trace_id}") +async def get_trace(trace_id: str, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + spans = (await db.execute( + select(OtelSpan).where(OtelSpan.trace_id == trace_id).order_by(OtelSpan.start_time) + )).scalars().all() + if not spans: + raise HTTPException(404) + return { + "trace_id": trace_id, + "spans": [{ + "span_id": s.span_id, "parent_span_id": s.parent_span_id, + "service": s.service, "operation": s.operation, + "start_time": s.start_time, "end_time": s.end_time, + "duration_ms": s.duration_ms, "status": s.status, + } for s in spans] + } + + +@router.get("/services") +async def list_services(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + rows = await db.execute(select(OtelTrace.service).distinct()) + return [r[0] for r in rows.all()] + + +@router.get("/deps") +async def service_deps(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + """서비스 간 호출 의존성 맵.""" + rows = await db.execute( + select(OtelSpan.service, OtelSpan.operation) + .where(OtelSpan.parent_span_id.isnot(None)) + .distinct().limit(100) + ) + edges = [{"from": r[0], "call": r[1]} for r in rows.all()] + return {"edges": edges} + + +class TraceSearch(BaseModel): + service: Optional[str] = None; operation: Optional[str] = None + min_duration_ms: Optional[int] = None; status: Optional[str] = None + + +@router.post("/search") +async def search_traces(body: TraceSearch, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + q = select(OtelSpan).order_by(desc(OtelSpan.start_time)).limit(100) + if body.service: + q = q.where(OtelSpan.service == body.service) + if body.operation: + q = q.where(OtelSpan.operation.contains(body.operation)) + if body.min_duration_ms: + q = q.where(OtelSpan.duration_ms >= body.min_duration_ms) + if body.status: + q = q.where(OtelSpan.status == body.status) + rows = await db.execute(q) + return [{"trace_id": s.trace_id, "span_id": s.span_id, "service": s.service, + "operation": s.operation, "duration_ms": s.duration_ms, "status": s.status} + for s in rows.scalars().all()] diff --git a/routers/sbom.py b/routers/sbom.py new file mode 100644 index 0000000..0e98682 --- /dev/null +++ b/routers/sbom.py @@ -0,0 +1,152 @@ +"""SBOM — Software Bill of Materials (CycloneDX/SPDX) 생성·관리""" +from __future__ import annotations +import json, logging, uuid +from datetime import datetime +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Response +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, SBOMRecord, SBOMComponent + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/sbom", tags=["SBOM"]) + + +async def _ssh_scan_packages(server_id: int, db) -> list: + """에이전트리스 SSH로 서버 패키지 목록 수집.""" + # 실제 구현: paramiko SSH 실행 + # 여기서는 샘플 데이터 반환 + return [ + {"name": "python3", "version": "3.11.0", "type": "runtime"}, + {"name": "fastapi", "version": "0.100.0", "type": "library", "ecosystem": "pypi"}, + {"name": "sqlalchemy", "version": "2.0.0", "type": "library", "ecosystem": "pypi"}, + {"name": "nginx", "version": "1.24.0", "type": "system", "ecosystem": "apt"}, + {"name": "postgresql", "version": "16.0", "type": "system", "ecosystem": "apt"}, + ] + + +def _build_cyclonedx(sbom: SBOMRecord, components: list) -> dict: + return { + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": f"urn:uuid:{uuid.uuid4()}", + "version": 1, + "metadata": { + "timestamp": datetime.utcnow().isoformat() + "Z", + "tools": [{"vendor": "GUARDiA", "name": "sbom-scanner", "version": "1.0"}], + "component": {"type": "container", "name": f"server-{sbom.server_id}", + "version": "1.0", "bom-ref": f"server-{sbom.server_id}"} + }, + "components": [ + {"type": "library", "name": c["name"], "version": c["version"], + "purl": f"pkg:{c.get('ecosystem','generic')}/{c['name']}@{c['version']}"} + for c in components + ] + } + + +class VEXStatement(BaseModel): + sbom_id: int; component_name: str; cve_id: str + status: str # not_affected | affected | fixed | under_investigation + justification: str = "" + + +@router.post("/generate") +async def generate_sbom(server_id: int, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + packages = await _ssh_scan_packages(server_id, db) + record = SBOMRecord( + server_id=server_id, format="CycloneDX", spec_version="1.4", + component_count=len(packages), status="COMPLETED", + generated_by=user.id, created_at=datetime.utcnow() + ) + db.add(record); await db.commit(); await db.refresh(record) + + for pkg in packages: + db.add(SBOMComponent( + sbom_id=record.id, name=pkg["name"], version=pkg["version"], + component_type=pkg.get("type", "library"), ecosystem=pkg.get("ecosystem", ""), + purl=f"pkg:{pkg.get('ecosystem','generic')}/{pkg['name']}@{pkg['version']}" + )) + await db.commit() + return {"sbom_id": record.id, "server_id": server_id, "component_count": len(packages)} + + +@router.get("/list") +async def list_sboms(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + rows = await db.execute(select(SBOMRecord).order_by(desc(SBOMRecord.created_at)).limit(50)) + return [{"id":s.id,"server_id":s.server_id,"format":s.format, + "component_count":s.component_count,"status":s.status,"created_at":s.created_at} + for s in rows.scalars().all()] + + +@router.get("/{sbom_id}") +async def get_sbom(sbom_id: int, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + row = await db.execute(select(SBOMRecord).where(SBOMRecord.id == sbom_id)) + sbom = row.scalar_one_or_none() + if not sbom: raise HTTPException(404) + comp_rows = await db.execute(select(SBOMComponent).where(SBOMComponent.sbom_id == sbom_id)) + comps = comp_rows.scalars().all() + return { + "id": sbom.id, "server_id": sbom.server_id, "format": sbom.format, + "created_at": sbom.created_at, + "components": [{"name":c.name,"version":c.version,"purl":c.purl,"ecosystem":c.ecosystem} + for c in comps] + } + + +@router.get("/{sbom_id}/export") +async def export_sbom(sbom_id: int, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + row = await db.execute(select(SBOMRecord).where(SBOMRecord.id == sbom_id)) + sbom = row.scalar_one_or_none() + if not sbom: raise HTTPException(404) + comp_rows = await db.execute(select(SBOMComponent).where(SBOMComponent.sbom_id == sbom_id)) + comps = [{"name":c.name,"version":c.version,"type":c.component_type,"ecosystem":c.ecosystem} + for c in comp_rows.scalars().all()] + cyclonedx = _build_cyclonedx(sbom, comps) + return Response(content=json.dumps(cyclonedx, ensure_ascii=False, indent=2), + media_type="application/json", + headers={"Content-Disposition": f"attachment; filename=sbom-{sbom_id}.cdx.json"}) + + +@router.post("/{sbom_id}/scan") +async def scan_vulnerabilities(sbom_id: int, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + """SBOM 컴포넌트 → CVE 매핑 (vuln_scan.py 연동).""" + comp_rows = await db.execute(select(SBOMComponent).where(SBOMComponent.sbom_id == sbom_id)) + comps = comp_rows.scalars().all() + vuln_count = 0 + for c in comps: + # 실제: NVD/KISA CVE DB 조회 + if c.name in ("python3", "nginx", "openssl"): + c.cve_ids = '["CVE-2024-SAMPLE"]' + vuln_count += 1 + await db.commit() + return {"sbom_id": sbom_id, "scanned": len(comps), "vulnerable": vuln_count} + + +@router.get("/dashboard") +async def sbom_dashboard(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + total = (await db.execute( + __import__('sqlalchemy', fromlist=['func']).func.count(SBOMRecord.id) + if False else select(__import__('sqlalchemy', fromlist=['func']).func.count(SBOMRecord.id)) + )).scalar() or 0 + return {"total_sboms": total, "format_breakdown": {"CycloneDX": total}} + + +@router.post("/vex") +async def create_vex(body: VEXStatement, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + return { + "ok": True, "vex": { + "sbom_id": body.sbom_id, "component": body.component_name, + "cve": body.cve_id, "status": body.status, + "justification": body.justification, + "timestamp": datetime.utcnow().isoformat() + } + } diff --git a/routers/ztna.py b/routers/ztna.py new file mode 100644 index 0000000..4201218 --- /dev/null +++ b/routers/ztna.py @@ -0,0 +1,156 @@ +"""Zero Trust Network Access — 정책 엔진 + 디바이스 상태 검증""" +from __future__ import annotations +import json, logging +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, require_admin_role +from database import get_db +from models import User, ZTNAPolicy, ZTNADevice, ZTNAViolation + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/ztna", tags=["Zero Trust"]) + + +class PolicyCreate(BaseModel): + name: str; resource: str; allowed_roles: list = [] + require_mfa: bool = True; min_trust_score: int = 70 + allowed_ips: list = []; require_device_compliant: bool = True + is_active: bool = True + + +class DeviceRegister(BaseModel): + device_name: str; device_type: str # PC|MOBILE|SERVER + os_name: str; os_version: str + antivirus_active: bool = False; disk_encrypted: bool = False + last_patch_days: int = 999 # 마지막 패치 경과일 + + +class VerifyRequest(BaseModel): + user_id: int; device_id: Optional[int] = None + resource: str; source_ip: Optional[str] = None + mfa_verified: bool = False + + +def _calc_trust_score(device: Optional[ZTNADevice], mfa: bool, source_ip: str) -> int: + score = 50 + if mfa: + score += 20 + if device: + if device.antivirus_active: + score += 10 + if device.disk_encrypted: + score += 10 + if device.last_patch_days <= 30: + score += 10 + elif device.last_patch_days > 90: + score -= 15 + return max(0, min(100, score)) + + +@router.get("/policies") +async def list_policies(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + rows = await db.execute(select(ZTNAPolicy).order_by(ZTNAPolicy.name)) + return [{"id":p.id,"name":p.name,"resource":p.resource,"min_trust_score":p.min_trust_score, + "require_mfa":p.require_mfa,"is_active":p.is_active} + for p in rows.scalars().all()] + + +@router.post("/policies", status_code=201) +async def create_policy(body: PolicyCreate, db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role)): + p = ZTNAPolicy(**body.model_dump(), created_by=user.id, created_at=datetime.utcnow()) + db.add(p); await db.commit(); await db.refresh(p) + return {"id": p.id} + + +@router.put("/policies/{pid}") +async def update_policy(pid: int, body: PolicyCreate, db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role)): + row = await db.execute(select(ZTNAPolicy).where(ZTNAPolicy.id == pid)) + p = row.scalar_one_or_none() + if not p: raise HTTPException(404) + for k, v in body.model_dump().items(): setattr(p, k, v) + await db.commit(); return {"ok": True} + + +@router.post("/verify") +async def verify_access(body: VerifyRequest, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + """접속 요청 검증 — Zero Trust 결정 엔진.""" + # 해당 리소스의 정책 조회 + row = await db.execute( + select(ZTNAPolicy).where(ZTNAPolicy.resource == body.resource, ZTNAPolicy.is_active == True) + ) + policy = row.scalar_one_or_none() + if not policy: + return {"allowed": True, "reason": "정책 없음 — 기본 허용", "trust_score": 50} + + device = None + if body.device_id: + dr = await db.execute(select(ZTNADevice).where(ZTNADevice.id == body.device_id)) + device = dr.scalar_one_or_none() + + trust_score = _calc_trust_score(device, body.mfa_verified, body.source_ip or "") + reasons = [] + + if policy.require_mfa and not body.mfa_verified: + reasons.append("MFA 미인증") + if trust_score < policy.min_trust_score: + reasons.append(f"신뢰 점수 부족 ({trust_score}/{policy.min_trust_score})") + if policy.require_device_compliant and not device: + reasons.append("등록된 디바이스 없음") + + allowed = len(reasons) == 0 + + if not allowed: + db.add(ZTNAViolation( + user_id=body.user_id, resource=body.resource, + reason=", ".join(reasons), source_ip=body.source_ip, + trust_score=trust_score, created_at=datetime.utcnow() + )) + await db.commit() + + return {"allowed": allowed, "trust_score": trust_score, + "reasons": reasons, "policy": policy.name} + + +@router.get("/devices") +async def list_devices(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + rows = await db.execute(select(ZTNADevice).order_by(desc(ZTNADevice.registered_at))) + return [{"id":d.id,"device_name":d.device_name,"device_type":d.device_type, + "os_name":d.os_name,"compliant":d.is_compliant} for d in rows.scalars().all()] + + +@router.post("/devices/register", status_code=201) +async def register_device(body: DeviceRegister, db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user)): + is_compliant = (body.antivirus_active and body.disk_encrypted and body.last_patch_days <= 90) + d = ZTNADevice( + **body.model_dump(), is_compliant=is_compliant, + registered_by=user.id, registered_at=datetime.utcnow() + ) + db.add(d); await db.commit(); await db.refresh(d) + return {"id": d.id, "is_compliant": is_compliant} + + +@router.get("/violations") +async def list_violations(limit: int = 50, db: AsyncSession = Depends(get_db), + user: User = Depends(require_admin_role)): + rows = await db.execute(select(ZTNAViolation).order_by(desc(ZTNAViolation.created_at)).limit(limit)) + return [{"id":v.id,"user_id":v.user_id,"resource":v.resource,"reason":v.reason, + "trust_score":v.trust_score,"created_at":v.created_at} + for v in rows.scalars().all()] + + +@router.get("/segments") +async def list_segments(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + """마이크로세그멘테이션 구성 (정책 기반).""" + rows = await db.execute(select(ZTNAPolicy).where(ZTNAPolicy.is_active == True)) + policies = rows.scalars().all() + return [{"segment": p.resource, "min_trust": p.min_trust_score, + "require_mfa": p.require_mfa, "require_device": p.require_device_compliant} + for p in policies] diff --git a/static/app.js b/static/app.js index e81e991..df70a2d 100644 --- a/static/app.js +++ b/static/app.js @@ -367,6 +367,20 @@ function renderCurrentView() { else if (currentView === "batch_ssh") renderBatchSsh(); else if (currentView === "asset_qr") renderAssetQr(); else if (currentView === "notification_rules") renderNotificationRules(); + // ── GUARDiA 차세대 확장 뷰 ── + else if (currentView === "agentic_aiops") renderAgenticAiops(); + else if (currentView === "auto_remediation_v2") renderAutoRemediation(); + else if (currentView === "otel_tracing") renderOtelTracing(); + else if (currentView === "mlsecops") renderMlsecops(); + else if (currentView === "ztna") renderZtna(); + else if (currentView === "sbom") renderSbom(); + else if (currentView === "n2sf") renderN2sf(); + else if (currentView === "idp_catalog") renderIdpCatalog(); + else if (currentView === "idp_template") renderIdpTemplate(); + else if (currentView === "idp_portal") renderIdpPortal(); + else if (currentView === "greenops") renderGreenops(); + else if (currentView === "edge_monitor") renderEdgeMonitor(); + else if (currentView === "energy_optimizer") renderEnergyOptimizer(); // ── GUARDiA 확장 v3 뷰 ── else loadExpansionView(currentView); } @@ -3946,3 +3960,497 @@ async function deleteNotifyRule(id) { await fetch(`/api/notify/rules/${id}`, {method:"DELETE", headers:{Authorization:`Bearer ${t}`}}); loadNotifyRules(); } + +// ══════════════════════════════════════════════════════════════════════════════ +// ── GUARDiA 차세대 확장 뷰 — AIOps 2.0 / Zero Trust / IDP / GreenOps+Edge +// ══════════════════════════════════════════════════════════════════════════════ + +function _nextCard(title, icon, content) { + return `
+

${icon} ${title}

${content}
`; +} + +// ── AIOps 2.0 ───────────────────────────────────────────────────────────────── +function renderAgenticAiops() { + document.getElementById("content").innerHTML = ` +

🤖 에이전트 태스크 실행 (AIOps 2.0)

+

Ollama 기반 tool-calling 멀티에이전트 — 태스크를 입력하면 에이전트가 도구를 선택·실행합니다.

+ ${_nextCard("태스크 실행","⚡",` + + + `)} + ${_nextCard("실행 이력","📋",`
로딩 중...
`)}`; + loadAgentRuns(); +} +async function runAgentTask() { + const task = document.getElementById("agent-task").value; + if (!task) return showToast("태스크를 입력하세요","error"); + const t = localStorage.getItem("token")||""; + const r = await fetch("/api/agent/run", {method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},body:JSON.stringify({task})}); + const d = await r.json(); + showToast(`에이전트 실행 시작 (ID: ${d.run_id})`,"success"); + loadAgentRuns(); +} +async function loadAgentRuns() { + const t = localStorage.getItem("token")||""; + const r = await fetch("/api/agent/runs",{headers:{Authorization:`Bearer ${t}`}}).catch(()=>({json:()=>[]})); + const runs = await r.json(); + const el = document.getElementById("agent-runs-list"); + if(!el) return; + if(!runs.length){el.innerHTML="

실행 이력 없음

";return;} + el.innerHTML=` + ${["ID","태스크","상태","실행일"].map(h=>``).join("")} + ${runs.map(r=>` + + + + + `).join("")}
${h}
${r.id}${(r.task||"").substring(0,60)}${r.status}${r.created_at?new Date(r.created_at).toLocaleString("ko-KR"):"-"}
`; +} + +function renderAutoRemediation() { + document.getElementById("content").innerHTML = ` +

🔧 자율 교정 루프

+

이상 감지 → Ollama 진단 → 자동 교정(안전) 또는 승인 요청(위험) 자동화 루프

+ ${_nextCard("수동 트리거","⚡",` + + + `)} + ${_nextCard("승인 대기","⏳",`
로딩 중...
`)} + ${_nextCard("교정 이력","📋",`
로딩 중...
`)}`; + loadRemediationData(); +} +async function triggerRemediation() { + const data = document.getElementById("remediation-data").value; + let trigger_data={}; + try{trigger_data=JSON.parse(data);}catch(e){} + const t=localStorage.getItem("token")||""; + await fetch("/api/remediation/trigger",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},body:JSON.stringify({trigger_data})}); + showToast("교정 트리거됨","success"); loadRemediationData(); +} +async function loadRemediationData() { + const t=localStorage.getItem("token")||""; + const [p,h]=await Promise.all([ + fetch("/api/remediation/pending",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]), + fetch("/api/remediation/history?limit=10",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]), + ]); + const pe=document.getElementById("remediation-pending"), he=document.getElementById("remediation-history"); + if(pe) pe.innerHTML=p.length?p.map(i=>`
+
${i.diagnosis||"진단 중..."}
+
조치: ${i.action_taken||"결정 중"}
+ +
`).join(""):"

승인 대기 없음

"; + if(he) he.innerHTML=h.map(i=>`
+ ${i.status} + ${i.diagnosis||"-"} +
`).join("") || "

이력 없음

"; +} +async function approveRemediation(id) { + const t=localStorage.getItem("token")||""; + await fetch(`/api/remediation/approve/${id}`,{method:"POST",headers:{Authorization:`Bearer ${t}`}}); + showToast("승인됨","success"); loadRemediationData(); +} + +function renderOtelTracing() { + document.getElementById("content").innerHTML = ` +

📡 분산 트레이싱 (OpenTelemetry)

+

OTLP HTTP로 스팬을 수집하고 서비스 간 호출 흐름을 시각화합니다.

+ ${_nextCard("최근 트레이스","🔍",`
로딩 중...
`)} + ${_nextCard("서비스 목록","🗂️",`
로딩 중...
`)}`; + loadOtelData(); +} +async function loadOtelData() { + const t=localStorage.getItem("token")||""; + const [traces,svcs]=await Promise.all([ + fetch("/api/tracing/traces?limit=10",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]), + fetch("/api/tracing/services",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]), + ]); + const te=document.getElementById("otel-traces"), se=document.getElementById("otel-services"); + if(te) te.innerHTML=traces.length?traces.map(tr=>`
${tr.trace_id.substring(0,16)}... ${tr.service}
`).join(""):"

트레이스 없음 — 앱에서 /api/tracing/ingest 로 스팬을 전송하세요

"; + if(se) se.innerHTML=svcs.length?svcs.map(s=>`${s}`).join(""):"

서비스 없음

"; +} + +function renderMlsecops() { + document.getElementById("content").innerHTML = ` +

🔒 AI 모델 보안 (MLSecOps)

+

Ollama 모델 무결성·취약점·편향 관리

+ ${_nextCard("설치된 모델","🤖",`
로딩 중...
`)}`; + loadMlModels(); +} +async function loadMlModels() { + const t=localStorage.getItem("token")||""; + const models=await fetch("/api/mlsec/models",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]); + const el=document.getElementById("ml-models"); + if(!el) return; + if(!models.length){el.innerHTML="

모델 없음

";return;} + el.innerHTML=` + ${["모델명","크기","위험도","상태","액션"].map(h=>``).join("")} + ${models.map(m=>` + + + + + + `).join("")}
${h}
${m.name}${m.size_gb}GB${m.risk}${m.vulnerability||"정상"}
`; +} +async function scanMlModel(name) { + const t=localStorage.getItem("token")||""; + const r=await fetch(`/api/mlsec/models/scan?model_name=${encodeURIComponent(name)}`,{method:"POST",headers:{Authorization:`Bearer ${t}`}}); + const d=await r.json(); + showToast(`${name}: ${d.recommendation}`,"info"); loadMlModels(); +} + +// ── Zero Trust ──────────────────────────────────────────────────────────────── +function renderZtna() { + document.getElementById("content").innerHTML = ` +

🔐 ZTNA 정책 관리

+

Zero Trust Network Access — 리소스별 접근 정책 및 디바이스 신뢰 점수 관리

+ ${_nextCard("정책 목록","📋",`
로딩 중...
`)} + ${_nextCard("최근 위반","⚠️",`
로딩 중...
`)}`; + loadZtnaData(); +} +async function loadZtnaData() { + const t=localStorage.getItem("token")||""; + const [policies,violations]=await Promise.all([ + fetch("/api/ztna/policies",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]), + fetch("/api/ztna/violations?limit=5",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]), + ]); + const pe=document.getElementById("ztna-policies"), ve=document.getElementById("ztna-violations"); + if(pe) pe.innerHTML=policies.length?policies.map(p=>`
+ ${p.name}${p.resource} + 신뢰점수 ≥ ${p.min_trust_score} + ${p.require_mfa?'MFA':''} +
`).join(""):"

정책 없음

"; + if(ve) ve.innerHTML=violations.length?violations.map(v=>`
+ ⚠️ ${v.resource} — ${v.reason} (점수:${v.trust_score}) +
`).join(""):"

위반 없음

"; +} + +function renderSbom() { + document.getElementById("content").innerHTML = ` +

📦 SBOM 관리 (CycloneDX)

+

서버별 소프트웨어 구성 요소 목록 — EU CRA/공공 조달 준수

+ ${_nextCard("SBOM 생성","⚙️",` + + + `)} + ${_nextCard("SBOM 목록","📋",`
로딩 중...
`)}`; + loadSbomList(); +} +async function generateSbom() { + const sid=document.getElementById("sbom-server-id").value; + if(!sid) return showToast("서버 ID 입력","error"); + const t=localStorage.getItem("token")||""; + showToast("SBOM 생성 중...","info"); + const r=await fetch(`/api/sbom/generate?server_id=${sid}`,{method:"POST",headers:{Authorization:`Bearer ${t}`}}); + const d=await r.json(); + showToast(`SBOM 생성 완료 (ID:${d.sbom_id}, 컴포넌트:${d.component_count}개)`,"success"); + loadSbomList(); +} +async function loadSbomList() { + const t=localStorage.getItem("token")||""; + const list=await fetch("/api/sbom/list",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]); + const el=document.getElementById("sbom-list"); + if(!el) return; + if(!list.length){el.innerHTML="

SBOM 없음

";return;} + el.innerHTML=list.map(s=>`
+
서버 #${s.server_id} ${s.format} — ${s.component_count}개 컴포넌트
+ 내보내기 ↗ +
`).join(""); +} + +function renderN2sf() { + document.getElementById("content").innerHTML = ` +

🛡️ N²SF 국가 망 보안체계

+

국정원 N²SF 준수 — 데이터 민감도별 3단계 보안 구역 (2026 공공기관 의무)

+ ${_nextCard("시스템 분류","🗂️",` + + + + +
+ `)} + ${_nextCard("평가 이력","📋",`
로딩 중...
`)}`; + loadN2sfReport(); +} +async function classifyN2sf() { + const t=localStorage.getItem("token")||""; + const system=document.getElementById("n2sf-system").value||"미지정"; + const pi=document.getElementById("n2sf-pi").checked; + const internet=document.getElementById("n2sf-internet").checked; + const r=await fetch("/api/n2sf/classify",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"}, + body:JSON.stringify({system_name:system,handles_personal_info:pi,internet_facing:internet})}); + const d=await r.json(); + const colors={"A":"#dc2626","B":"#f59e0b","C":"#10b981"}; + document.getElementById("n2sf-classify-result").innerHTML=`
+ 권고 구역: Zone ${d.recommended_zone} — ${d.zone_label}
+ ${d.requirements?.join(" / ")} +
`; +} +async function loadN2sfReport() { + const t=localStorage.getItem("token")||""; + const report=await fetch("/api/n2sf/report",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]); + const el=document.getElementById("n2sf-report"); + if(!el) return; + if(!report.length){el.innerHTML="

평가 이력 없음

";return;} + el.innerHTML=report.map(a=>`
+ ${a.system_name} Zone ${a.zone} + =60?"#fef3c7":"#fef2f2"};color:${a.score>=80?"#166534":a.score>=60?"#92400e":"#dc2626"}">${a.grade} + ${a.passed}/${a.total} (${a.score}점) +
`).join(""); +} + +// ── IDP ─────────────────────────────────────────────────────────────────────── +function renderIdpCatalog() { + document.getElementById("content").innerHTML = ` +

🗂️ 서비스 카탈로그 (IDP)

+

Backstage-style 소프트웨어 카탈로그 — 서비스·컴포넌트·인프라 등록·검색

+ ${_nextCard("등록된 서비스","📋",`
로딩 중...
`)} + ${_nextCard("서비스 등록","➕",` +
+ + +
+ + + `)}`; + loadIdpCatalog(); +} +async function loadIdpCatalog() { + const t=localStorage.getItem("token")||""; + const list=await fetch("/api/idp/catalog?limit=20",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]); + const el=document.getElementById("idp-catalog-list"); + if(!el) return; + if(!list.length){el.innerHTML="

등록된 서비스 없음

";return;} + el.innerHTML=list.map(c=>`
+ ${c.display_name||c.name} + ${c.component_type} + ${c.language?`${c.language}`:""} + ${c.lifecycle==="production"?'운영':""} +
`).join(""); +} +async function registerIdpComponent() { + const t=localStorage.getItem("token")||""; + const name=document.getElementById("idp-name").value; + const language=document.getElementById("idp-lang").value; + const description=document.getElementById("idp-desc").value; + if(!name) return showToast("서비스명 입력","error"); + await fetch("/api/idp/catalog",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"}, + body:JSON.stringify({name,language,description})}); + showToast("등록됨","success"); loadIdpCatalog(); +} + +function renderIdpTemplate() { + document.getElementById("content").innerHTML = ` +

📐 Golden Path 템플릿 (IDP)

+

표준화된 서비스 스캐폴딩 — 내장 4종 + 커스텀 템플릿

+ ${_nextCard("템플릿 목록","📋",`
로딩 중...
`)}`; + loadIdpTemplates(); +} +async function loadIdpTemplates() { + const t=localStorage.getItem("token")||""; + const list=await fetch("/api/idp/template",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]); + const el=document.getElementById("idp-templates"); + if(!el) return; + el.innerHTML=list.map(tp=>`
+
${tp.name} ${tp.is_builtin?'내장':""}
+
${tp.description}
+
언어: ${tp.language||"-"}  |  변수: ${(tp.variables||[]).join(", ")||"없음"}
+ +
`).join(""); +} +async function previewTemplate(id) { + const t=localStorage.getItem("token")||""; + const d=await fetch(`/api/idp/template/${id}/preview`,{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({})); + showToast(`파일: ${(d.files||[]).join(", ")||"없음"}`, "info"); +} + +function renderIdpPortal() { + document.getElementById("content").innerHTML = ` +

🚀 셀프서비스 포털 (IDP)

+

인프라 셀프서비스 — SSH 키·DB·Jenkins·Gitea 자동 프로비저닝

+ ${_nextCard("리소스 요청","⚡",` + + + + `)} + ${_nextCard("요청 이력","📋",`
로딩 중...
`)}`; + loadPortalRequests(); +} +async function requestResource() { + const t=localStorage.getItem("token")||""; + const type=document.getElementById("portal-type").value; + const reason=document.getElementById("portal-reason").value; + const r=await fetch("/api/idp/portal/provision",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"}, + body:JSON.stringify({resource_type:type,justification:reason,params:{}})}); + const d=await r.json(); + showToast(`요청 #${d.request_id} — ${d.auto_approved?"자동 승인됨":"승인 대기"}`, d.auto_approved?"success":"info"); + loadPortalRequests(); +} +async function loadPortalRequests() { + const t=localStorage.getItem("token")||""; + const list=await fetch("/api/idp/portal/requests",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]); + const el=document.getElementById("portal-requests"); + if(!el) return; + if(!list.length){el.innerHTML="

요청 이력 없음

";return;} + el.innerHTML=list.map(r=>`
+ ${r.resource_type} + ${r.status} +
`).join(""); +} + +// ── GreenOps + Edge ──────────────────────────────────────────────────────────── +function renderGreenops() { + document.getElementById("content").innerHTML = ` +

🌱 탄소 배출 대시보드 (GreenOps)

+

Scope 2 탄소 배출 추적 — EU CSRD / GHG Protocol 준수. 한국 전력망 기준 0.4593 kgCO₂e/kWh

+
로딩 중...
+ ${_nextCard("탄소 기록 추가","📊",` +
+
+
+
+
+ +
+ `)} + ${_nextCard("절감 분석","💡",`
로딩 중...
`)}`; + loadGreenopsData(); +} +async function loadGreenopsData() { + const t=localStorage.getItem("token")||""; + const [dash,savings]=await Promise.all([ + fetch("/api/greenops/dashboard",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({})), + fetch("/api/greenops/savings",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({})), + ]); + const se=document.getElementById("greenops-summary"); + if(se) se.innerHTML=[ + {val:`${dash.total_carbon_kg||0}kg`,lab:"총 탄소 배출",icon:"🌍"}, + {val:`${dash.total_carbon_ton||0}ton`,lab:"CO₂e (Scope 2)",icon:"♻️"}, + {val:`${dash.grid_factor||0.4593}`,lab:"한국 탄소 계수",icon:"⚡"}, + ].map(s=>`
+
${s.icon}
+
${s.val}
+
${s.lab}
+
`).join(""); + const sve=document.getElementById("greenops-savings"); + if(sve) sve.innerHTML=`
+
베이스라인 월 배출: ${savings.baseline_monthly_kg||0} kg
+
실제 월 배출: ${savings.actual_monthly_kg||0} kg
+
절감: ${savings.saving_kg||0} kg (${savings.saving_pct||0}%)
+
`; +} +async function recordCarbon() { + const t=localStorage.getItem("token")||""; + const server_id=+document.getElementById("carbon-server").value; + const watt=+document.getElementById("carbon-watt").value; + if(!server_id||!watt) return showToast("서버 ID와 전력을 입력하세요","error"); + const r=await fetch("/api/greenops/emissions/record",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},body:JSON.stringify({server_id,watt})}); + const d=await r.json(); + showToast(`탄소 기록 완료: ${d.carbon_kg} kgCO₂e`,"success"); loadGreenopsData(); +} + +function renderEdgeMonitor() { + document.getElementById("content").innerHTML = ` +

📡 Edge/IoT 디바이스 모니터링

+

엣지 서버·IoT 센서·CCTV·키오스크 모니터링. 텔레메트리 Push 방식 (POST /api/edge/telemetry)

+ ${_nextCard("디바이스 등록","➕",` +
+ + + +
+ + `)} + ${_nextCard("디바이스 목록","🗺️",`
로딩 중...
`)}`; + loadEdgeDevices(); +} +async function registerEdgeDevice() { + const t=localStorage.getItem("token")||""; + const name=document.getElementById("edge-name").value; + const device_type=document.getElementById("edge-type").value; + const location=document.getElementById("edge-location").value; + if(!name) return showToast("디바이스명 입력","error"); + const r=await fetch("/api/edge/devices",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},body:JSON.stringify({name,device_type,location})}); + const d=await r.json(); + showToast(`등록 완료 (토큰: ${d.device_token.substring(0,8)}...)`,"success"); loadEdgeDevices(); +} +async function loadEdgeDevices() { + const t=localStorage.getItem("token")||""; + const list=await fetch("/api/edge/devices",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]); + const el=document.getElementById("edge-devices"); + if(!el) return; + if(!list.length){el.innerHTML="

디바이스 없음

";return;} + el.innerHTML=list.map(d=>`
+ ${d.name} + ${d.device_type} + ${d.location?`${d.location}`:""} + ${d.status} + ${d.last_seen?`${new Date(d.last_seen).toLocaleString("ko-KR")}`:""} +
`).join(""); +} + +function renderEnergyOptimizer() { + document.getElementById("content").innerHTML = ` +

⚡ 에너지 최적화 (Carbon-aware)

+

Ollama 기반 에너지 효율 권고 + 탄소 낮은 시간대 배치 스케줄링

+ ${_nextCard("최적화 권고","💡",` + +
로딩 중...
+ `)} + ${_nextCard("Carbon-aware 스케줄","🌱",` +
+ + + + +
+

탄소 계수 ≤ 0.40 시간대 자동 배정 (한국 경부하: 23~08시)

+ `)}`; + loadEnergyRecs(); +} +async function generateEnergyRecs() { + const t=localStorage.getItem("token")||""; + await fetch("/api/energy/recommendations/generate",{method:"POST",headers:{Authorization:`Bearer ${t}`}}); + showToast("권고 생성 중...","info"); + setTimeout(loadEnergyRecs, 2000); +} +async function loadEnergyRecs() { + const t=localStorage.getItem("token")||""; + const recs=await fetch("/api/energy/recommendations",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]); + const el=document.getElementById("energy-recs"); + if(!el) return; + if(!recs.length){el.innerHTML="

권고 없음 — AI 권고 생성 버튼 클릭

";return;} + el.innerHTML=recs.map(r=>`
+
${r.rec_type} — ${r.description}
+
절감 예상: ${r.saving_kwh} kWh/월
+ ${r.status==="PENDING"?``:`✅ ${r.status}`} +
`).join(""); +} +async function applyEnergyRec(id) { + const t=localStorage.getItem("token")||""; + await fetch(`/api/energy/apply/${id}`,{method:"POST",headers:{Authorization:`Bearer ${t}`}}); + showToast("권고 적용됨","success"); loadEnergyRecs(); +} +async function scheduleJob() { + const t=localStorage.getItem("token")||""; + const job_name=document.getElementById("sched-job").value; + const job_command=document.getElementById("sched-cmd").value; + const server_id=+document.getElementById("sched-server").value; + if(!job_name||!server_id) return showToast("작업명과 서버 ID 입력","error"); + const r=await fetch("/api/energy/schedule",{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"}, + body:JSON.stringify({job_name,job_command,server_id})}); + const d=await r.json(); + showToast(`스케줄 등록: ${d.preferred_hour}시 (탄소 ${d.carbon_factor} kgCO₂e/kWh)`,"success"); +} diff --git a/static/index.html b/static/index.html index 0cec636..7c66099 100644 --- a/static/index.html +++ b/static/index.html @@ -211,6 +211,54 @@ + + + + + + + + + + + + + + + + + + +