sync: update from workspace (latest ITSM/CICD/DR changes)
This commit is contained in:
parent
a3ec9d30df
commit
5f3a0247b3
20
main.py
20
main.py
@ -390,6 +390,26 @@ app.include_router(batch_ssh.router) # 다중 서버 동시 SSH 실
|
|||||||
app.include_router(asset_qr.router) # 서버 자산 QR 태그 관리
|
app.include_router(asset_qr.router) # 서버 자산 QR 태그 관리
|
||||||
app.include_router(smart_notify.router) # 스마트 알림 규칙 엔진
|
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")
|
@app.middleware("http")
|
||||||
|
|||||||
348
models.py
348
models.py
@ -5551,3 +5551,351 @@ class NotifyLog(Base):
|
|||||||
success = Column(Boolean, default=False)
|
success = Column(Boolean, default=False)
|
||||||
error_msg = Column(Text, nullable=True)
|
error_msg = Column(Text, nullable=True)
|
||||||
sent_at = Column(DateTime, default=func.now())
|
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())
|
||||||
|
|||||||
293
routers/agentic_aiops.py
Normal file
293
routers/agentic_aiops.py
Normal file
@ -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}
|
||||||
125
routers/edge_monitor.py
Normal file
125
routers/edge_monitor.py
Normal file
@ -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)}
|
||||||
140
routers/energy_optimizer.py
Normal file
140
routers/energy_optimizer.py
Normal file
@ -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),
|
||||||
|
}
|
||||||
130
routers/greenops.py
Normal file
130
routers/greenops.py
Normal file
@ -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 참조"}
|
||||||
124
routers/idp_catalog.py
Normal file
124
routers/idp_catalog.py
Normal file
@ -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()]
|
||||||
132
routers/idp_portal.py
Normal file
132
routers/idp_portal.py
Normal file
@ -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"}
|
||||||
141
routers/idp_template.py
Normal file
141
routers/idp_template.py
Normal file
@ -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": "커스텀 템플릿 미리보기"}
|
||||||
142
routers/mlsecops.py
Normal file
142
routers/mlsecops.py
Normal file
@ -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
|
||||||
143
routers/n2sf.py
Normal file
143
routers/n2sf.py
Normal file
@ -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]
|
||||||
125
routers/otel_tracing.py
Normal file
125
routers/otel_tracing.py
Normal file
@ -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()]
|
||||||
152
routers/sbom.py
Normal file
152
routers/sbom.py
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
156
routers/ztna.py
Normal file
156
routers/ztna.py
Normal file
@ -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]
|
||||||
508
static/app.js
508
static/app.js
@ -367,6 +367,20 @@ function renderCurrentView() {
|
|||||||
else if (currentView === "batch_ssh") renderBatchSsh();
|
else if (currentView === "batch_ssh") renderBatchSsh();
|
||||||
else if (currentView === "asset_qr") renderAssetQr();
|
else if (currentView === "asset_qr") renderAssetQr();
|
||||||
else if (currentView === "notification_rules") renderNotificationRules();
|
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 뷰 ──
|
// ── GUARDiA 확장 v3 뷰 ──
|
||||||
else loadExpansionView(currentView);
|
else loadExpansionView(currentView);
|
||||||
}
|
}
|
||||||
@ -3946,3 +3960,497 @@ async function deleteNotifyRule(id) {
|
|||||||
await fetch(`/api/notify/rules/${id}`, {method:"DELETE", headers:{Authorization:`Bearer ${t}`}});
|
await fetch(`/api/notify/rules/${id}`, {method:"DELETE", headers:{Authorization:`Bearer ${t}`}});
|
||||||
loadNotifyRules();
|
loadNotifyRules();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// ── GUARDiA 차세대 확장 뷰 — AIOps 2.0 / Zero Trust / IDP / GreenOps+Edge
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function _nextCard(title, icon, content) {
|
||||||
|
return `<div style="background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:20px;margin-bottom:14px">
|
||||||
|
<h3 style="margin:0 0 12px;font-size:15px;font-weight:700">${icon} ${title}</h3>${content}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AIOps 2.0 ─────────────────────────────────────────────────────────────────
|
||||||
|
function renderAgenticAiops() {
|
||||||
|
document.getElementById("content").innerHTML = `
|
||||||
|
<h2>🤖 에이전트 태스크 실행 (AIOps 2.0)</h2>
|
||||||
|
<p style="color:#64748b;margin-bottom:16px">Ollama 기반 tool-calling 멀티에이전트 — 태스크를 입력하면 에이전트가 도구를 선택·실행합니다.</p>
|
||||||
|
${_nextCard("태스크 실행","⚡",`
|
||||||
|
<textarea id="agent-task" class="form-control" rows="2" placeholder="예: server-1 CPU 90% 원인 분석 후 해결해줘"></textarea>
|
||||||
|
<button class="btn btn-primary" style="margin-top:8px" onclick="runAgentTask()">🤖 실행</button>
|
||||||
|
`)}
|
||||||
|
${_nextCard("실행 이력","📋",`<div id="agent-runs-list">로딩 중...</div>`)}`;
|
||||||
|
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="<p style='color:#94a3b8;text-align:center;padding:12px'>실행 이력 없음</p>";return;}
|
||||||
|
el.innerHTML=`<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||||
|
<thead><tr style="border-bottom:1px solid #e2e8f0">${["ID","태스크","상태","실행일"].map(h=>`<th style="text-align:left;padding:8px;font-size:11px;color:#64748b">${h}</th>`).join("")}</tr></thead>
|
||||||
|
<tbody>${runs.map(r=>`<tr style="border-bottom:1px solid #f1f5f9">
|
||||||
|
<td style="padding:8px;font-weight:600">${r.id}</td>
|
||||||
|
<td style="padding:8px">${(r.task||"").substring(0,60)}</td>
|
||||||
|
<td style="padding:8px"><span style="padding:2px 8px;border-radius:8px;font-size:11px;background:${r.status==="DONE"?"#dcfce7":"#fef3c7"};color:${r.status==="DONE"?"#166534":"#92400e"}">${r.status}</span></td>
|
||||||
|
<td style="padding:8px;color:#64748b;font-size:11px">${r.created_at?new Date(r.created_at).toLocaleString("ko-KR"):"-"}</td>
|
||||||
|
</tr>`).join("")}</tbody></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAutoRemediation() {
|
||||||
|
document.getElementById("content").innerHTML = `
|
||||||
|
<h2>🔧 자율 교정 루프</h2>
|
||||||
|
<p style="color:#64748b;margin-bottom:16px">이상 감지 → Ollama 진단 → 자동 교정(안전) 또는 승인 요청(위험) 자동화 루프</p>
|
||||||
|
${_nextCard("수동 트리거","⚡",`
|
||||||
|
<input id="remediation-data" class="form-control" placeholder='{"cpu":95,"server":"server-1"}' style="margin-bottom:8px">
|
||||||
|
<button class="btn btn-primary" onclick="triggerRemediation()">교정 트리거</button>
|
||||||
|
`)}
|
||||||
|
${_nextCard("승인 대기","⏳",`<div id="remediation-pending">로딩 중...</div>`)}
|
||||||
|
${_nextCard("교정 이력","📋",`<div id="remediation-history">로딩 중...</div>`)}`;
|
||||||
|
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=>`<div style="border:1px solid #fef3c7;border-radius:8px;padding:12px;margin-bottom:8px">
|
||||||
|
<div style="font-weight:600">${i.diagnosis||"진단 중..."}</div>
|
||||||
|
<div style="font-size:12px;color:#64748b;margin-top:4px">조치: ${i.action_taken||"결정 중"}</div>
|
||||||
|
<button onclick="approveRemediation(${i.id})" class="btn btn-primary btn-sm" style="margin-top:8px">승인</button>
|
||||||
|
</div>`).join(""):"<p style='color:#94a3b8;padding:12px;text-align:center'>승인 대기 없음</p>";
|
||||||
|
if(he) he.innerHTML=h.map(i=>`<div style="padding:8px;border-bottom:1px solid #f1f5f9;font-size:13px">
|
||||||
|
<span style="padding:2px 8px;border-radius:8px;font-size:11px;background:${i.status==="AUTO_FIXED"?"#dcfce7":"#fef3c7"};color:${i.status==="AUTO_FIXED"?"#166534":"#92400e"}">${i.status}</span>
|
||||||
|
<span style="margin-left:8px">${i.diagnosis||"-"}</span>
|
||||||
|
</div>`).join("") || "<p style='color:#94a3b8;padding:12px;text-align:center'>이력 없음</p>";
|
||||||
|
}
|
||||||
|
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 = `
|
||||||
|
<h2>📡 분산 트레이싱 (OpenTelemetry)</h2>
|
||||||
|
<p style="color:#64748b;margin-bottom:16px">OTLP HTTP로 스팬을 수집하고 서비스 간 호출 흐름을 시각화합니다.</p>
|
||||||
|
${_nextCard("최근 트레이스","🔍",`<div id="otel-traces">로딩 중...</div>`)}
|
||||||
|
${_nextCard("서비스 목록","🗂️",`<div id="otel-services">로딩 중...</div>`)}`;
|
||||||
|
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=>`<div style="padding:8px;border-bottom:1px solid #f1f5f9;font-size:13px;font-family:monospace">${tr.trace_id.substring(0,16)}... <span style="color:#64748b">${tr.service}</span></div>`).join(""):"<p style='color:#94a3b8;padding:12px'>트레이스 없음 — 앱에서 /api/tracing/ingest 로 스팬을 전송하세요</p>";
|
||||||
|
if(se) se.innerHTML=svcs.length?svcs.map(s=>`<span style="padding:4px 10px;background:#eff6ff;color:#1d4ed8;border-radius:8px;font-size:12px;margin:2px;display:inline-block">${s}</span>`).join(""):"<p style='color:#94a3b8;padding:12px'>서비스 없음</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMlsecops() {
|
||||||
|
document.getElementById("content").innerHTML = `
|
||||||
|
<h2>🔒 AI 모델 보안 (MLSecOps)</h2>
|
||||||
|
<p style="color:#64748b;margin-bottom:16px">Ollama 모델 무결성·취약점·편향 관리</p>
|
||||||
|
${_nextCard("설치된 모델","🤖",`<div id="ml-models">로딩 중...</div>`)}`;
|
||||||
|
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="<p style='color:#94a3b8;padding:12px'>모델 없음</p>";return;}
|
||||||
|
el.innerHTML=`<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||||
|
<thead><tr style="border-bottom:1px solid #e2e8f0">${["모델명","크기","위험도","상태","액션"].map(h=>`<th style="text-align:left;padding:8px;font-size:11px;color:#64748b">${h}</th>`).join("")}</tr></thead>
|
||||||
|
<tbody>${models.map(m=>`<tr style="border-bottom:1px solid #f1f5f9">
|
||||||
|
<td style="padding:8px;font-family:monospace">${m.name}</td>
|
||||||
|
<td style="padding:8px">${m.size_gb}GB</td>
|
||||||
|
<td style="padding:8px"><span style="padding:2px 8px;border-radius:8px;font-size:11px;background:${m.risk==="HIGH"?"#fef2f2":"#dcfce7"};color:${m.risk==="HIGH"?"#dc2626":"#166534"}">${m.risk}</span></td>
|
||||||
|
<td style="padding:8px;font-size:11px;color:#64748b">${m.vulnerability||"정상"}</td>
|
||||||
|
<td style="padding:8px"><button onclick="scanMlModel('${m.name}')" style="padding:3px 8px;border:1px solid #e2e8f0;border-radius:4px;font-size:11px;cursor:pointer;background:none">스캔</button></td>
|
||||||
|
</tr>`).join("")}</tbody></table>`;
|
||||||
|
}
|
||||||
|
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 = `
|
||||||
|
<h2>🔐 ZTNA 정책 관리</h2>
|
||||||
|
<p style="color:#64748b;margin-bottom:16px">Zero Trust Network Access — 리소스별 접근 정책 및 디바이스 신뢰 점수 관리</p>
|
||||||
|
${_nextCard("정책 목록","📋",`<div id="ztna-policies">로딩 중...</div>`)}
|
||||||
|
${_nextCard("최근 위반","⚠️",`<div id="ztna-violations">로딩 중...</div>`)}`;
|
||||||
|
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=>`<div style="padding:10px;border-bottom:1px solid #f1f5f9">
|
||||||
|
<span style="font-weight:600">${p.name}</span> → <span style="color:#64748b">${p.resource}</span>
|
||||||
|
<span style="margin-left:8px;font-size:11px">신뢰점수 ≥ ${p.min_trust_score}</span>
|
||||||
|
${p.require_mfa?'<span style="margin-left:6px;background:#eff6ff;color:#1d4ed8;font-size:10px;padding:1px 6px;border-radius:8px">MFA</span>':''}
|
||||||
|
</div>`).join(""):"<p style='color:#94a3b8;padding:12px;text-align:center'>정책 없음</p>";
|
||||||
|
if(ve) ve.innerHTML=violations.length?violations.map(v=>`<div style="padding:8px;border-bottom:1px solid #f1f5f9;font-size:13px">
|
||||||
|
<span style="color:#dc2626">⚠️</span> ${v.resource} — ${v.reason} <span style="color:#94a3b8;font-size:11px">(점수:${v.trust_score})</span>
|
||||||
|
</div>`).join(""):"<p style='color:#94a3b8;padding:12px;text-align:center'>위반 없음</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSbom() {
|
||||||
|
document.getElementById("content").innerHTML = `
|
||||||
|
<h2>📦 SBOM 관리 (CycloneDX)</h2>
|
||||||
|
<p style="color:#64748b;margin-bottom:16px">서버별 소프트웨어 구성 요소 목록 — EU CRA/공공 조달 준수</p>
|
||||||
|
${_nextCard("SBOM 생성","⚙️",`
|
||||||
|
<input id="sbom-server-id" class="form-control" type="number" placeholder="서버 ID" style="width:150px;display:inline-block">
|
||||||
|
<button class="btn btn-primary" style="margin-left:8px" onclick="generateSbom()">SBOM 생성</button>
|
||||||
|
`)}
|
||||||
|
${_nextCard("SBOM 목록","📋",`<div id="sbom-list">로딩 중...</div>`)}`;
|
||||||
|
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="<p style='color:#94a3b8;padding:12px;text-align:center'>SBOM 없음</p>";return;}
|
||||||
|
el.innerHTML=list.map(s=>`<div style="padding:10px;border-bottom:1px solid #f1f5f9;font-size:13px;display:flex;justify-content:space-between">
|
||||||
|
<div>서버 #${s.server_id} <span style="color:#64748b">${s.format}</span> — ${s.component_count}개 컴포넌트</div>
|
||||||
|
<a href="/api/sbom/${s.id}/export" style="color:#003366;font-size:12px">내보내기 ↗</a>
|
||||||
|
</div>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderN2sf() {
|
||||||
|
document.getElementById("content").innerHTML = `
|
||||||
|
<h2>🛡️ N²SF 국가 망 보안체계</h2>
|
||||||
|
<p style="color:#64748b;margin-bottom:16px">국정원 N²SF 준수 — 데이터 민감도별 3단계 보안 구역 (2026 공공기관 의무)</p>
|
||||||
|
${_nextCard("시스템 분류","🗂️",`
|
||||||
|
<input id="n2sf-system" class="form-control" placeholder="시스템명" style="width:200px;display:inline-block">
|
||||||
|
<label style="margin-left:12px"><input type="checkbox" id="n2sf-pi"> 개인정보</label>
|
||||||
|
<label style="margin-left:8px"><input type="checkbox" id="n2sf-internet"> 인터넷 연결</label>
|
||||||
|
<button class="btn btn-primary" style="margin-left:8px" onclick="classifyN2sf()">구역 분류</button>
|
||||||
|
<div id="n2sf-classify-result" style="margin-top:10px"></div>
|
||||||
|
`)}
|
||||||
|
${_nextCard("평가 이력","📋",`<div id="n2sf-report">로딩 중...</div>`)}`;
|
||||||
|
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=`<div style="padding:10px;border-left:4px solid ${colors[d.recommended_zone]||"#64748b"};background:#f8fafc;border-radius:4px">
|
||||||
|
<strong>권고 구역: Zone ${d.recommended_zone}</strong> — ${d.zone_label}<br>
|
||||||
|
<span style="font-size:12px;color:#64748b">${d.requirements?.join(" / ")}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
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="<p style='color:#94a3b8;padding:12px;text-align:center'>평가 이력 없음</p>";return;}
|
||||||
|
el.innerHTML=report.map(a=>`<div style="padding:10px;border-bottom:1px solid #f1f5f9;font-size:13px">
|
||||||
|
<strong>${a.system_name}</strong> Zone ${a.zone}
|
||||||
|
<span style="margin-left:8px;padding:2px 8px;border-radius:8px;font-size:11px;background:${a.score>=80?"#dcfce7":a.score>=60?"#fef3c7":"#fef2f2"};color:${a.score>=80?"#166534":a.score>=60?"#92400e":"#dc2626"}">${a.grade}</span>
|
||||||
|
<span style="margin-left:8px;color:#64748b">${a.passed}/${a.total} (${a.score}점)</span>
|
||||||
|
</div>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IDP ───────────────────────────────────────────────────────────────────────
|
||||||
|
function renderIdpCatalog() {
|
||||||
|
document.getElementById("content").innerHTML = `
|
||||||
|
<h2>🗂️ 서비스 카탈로그 (IDP)</h2>
|
||||||
|
<p style="color:#64748b;margin-bottom:16px">Backstage-style 소프트웨어 카탈로그 — 서비스·컴포넌트·인프라 등록·검색</p>
|
||||||
|
${_nextCard("등록된 서비스","📋",`<div id="idp-catalog-list">로딩 중...</div>`)}
|
||||||
|
${_nextCard("서비스 등록","➕",`
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px">
|
||||||
|
<input id="idp-name" class="form-control" placeholder="서비스명">
|
||||||
|
<input id="idp-lang" class="form-control" placeholder="언어 (python/java/...)">
|
||||||
|
</div>
|
||||||
|
<input id="idp-desc" class="form-control" placeholder="설명" style="margin-bottom:8px">
|
||||||
|
<button class="btn btn-primary" onclick="registerIdpComponent()">등록</button>
|
||||||
|
`)}`;
|
||||||
|
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="<p style='color:#94a3b8;padding:12px;text-align:center'>등록된 서비스 없음</p>";return;}
|
||||||
|
el.innerHTML=list.map(c=>`<div style="padding:10px;border-bottom:1px solid #f1f5f9;font-size:13px">
|
||||||
|
<strong>${c.display_name||c.name}</strong>
|
||||||
|
<span style="margin-left:8px;padding:2px 8px;background:#eff6ff;color:#1d4ed8;border-radius:8px;font-size:10px">${c.component_type}</span>
|
||||||
|
${c.language?`<span style="margin-left:4px;color:#64748b;font-size:12px">${c.language}</span>`:""}
|
||||||
|
${c.lifecycle==="production"?'<span style="margin-left:8px;background:#dcfce7;color:#166534;font-size:10px;padding:1px 6px;border-radius:8px">운영</span>':""}
|
||||||
|
</div>`).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 = `
|
||||||
|
<h2>📐 Golden Path 템플릿 (IDP)</h2>
|
||||||
|
<p style="color:#64748b;margin-bottom:16px">표준화된 서비스 스캐폴딩 — 내장 4종 + 커스텀 템플릿</p>
|
||||||
|
${_nextCard("템플릿 목록","📋",`<div id="idp-templates">로딩 중...</div>`)}`;
|
||||||
|
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=>`<div style="border:1px solid #e2e8f0;border-radius:8px;padding:14px;margin-bottom:8px">
|
||||||
|
<div style="font-weight:700;margin-bottom:4px">${tp.name} ${tp.is_builtin?'<span style="background:#eff6ff;color:#1d4ed8;font-size:10px;padding:1px 6px;border-radius:8px">내장</span>':""}</div>
|
||||||
|
<div style="font-size:12px;color:#64748b;margin-bottom:8px">${tp.description}</div>
|
||||||
|
<div style="font-size:12px">언어: ${tp.language||"-"} | 변수: ${(tp.variables||[]).join(", ")||"없음"}</div>
|
||||||
|
<button onclick="previewTemplate('${tp.id}')" style="margin-top:8px;padding:4px 10px;border:1px solid #e2e8f0;border-radius:4px;font-size:12px;cursor:pointer;background:none">미리보기</button>
|
||||||
|
</div>`).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 = `
|
||||||
|
<h2>🚀 셀프서비스 포털 (IDP)</h2>
|
||||||
|
<p style="color:#64748b;margin-bottom:16px">인프라 셀프서비스 — SSH 키·DB·Jenkins·Gitea 자동 프로비저닝</p>
|
||||||
|
${_nextCard("리소스 요청","⚡",`
|
||||||
|
<select id="portal-type" class="form-control" style="width:200px;display:inline-block;margin-right:8px">
|
||||||
|
<option value="ssh_key">SSH 키 쌍 발급</option>
|
||||||
|
<option value="db_schema">DB 스키마 생성</option>
|
||||||
|
<option value="jenkins_job">Jenkins Job 생성</option>
|
||||||
|
<option value="gitea_repo">Gitea 저장소 생성</option>
|
||||||
|
</select>
|
||||||
|
<input id="portal-reason" class="form-control" placeholder="요청 사유" style="width:200px;display:inline-block;margin-right:8px">
|
||||||
|
<button class="btn btn-primary" onclick="requestResource()">요청</button>
|
||||||
|
`)}
|
||||||
|
${_nextCard("요청 이력","📋",`<div id="portal-requests">로딩 중...</div>`)}`;
|
||||||
|
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="<p style='color:#94a3b8;padding:12px;text-align:center'>요청 이력 없음</p>";return;}
|
||||||
|
el.innerHTML=list.map(r=>`<div style="padding:8px;border-bottom:1px solid #f1f5f9;font-size:13px">
|
||||||
|
<strong>${r.resource_type}</strong>
|
||||||
|
<span style="margin-left:8px;padding:2px 8px;border-radius:8px;font-size:11px;background:${r.status==="COMPLETED"?"#dcfce7":"#fef3c7"};color:${r.status==="COMPLETED"?"#166534":"#92400e"}">${r.status}</span>
|
||||||
|
</div>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GreenOps + Edge ────────────────────────────────────────────────────────────
|
||||||
|
function renderGreenops() {
|
||||||
|
document.getElementById("content").innerHTML = `
|
||||||
|
<h2>🌱 탄소 배출 대시보드 (GreenOps)</h2>
|
||||||
|
<p style="color:#64748b;margin-bottom:16px">Scope 2 탄소 배출 추적 — EU CSRD / GHG Protocol 준수. 한국 전력망 기준 0.4593 kgCO₂e/kWh</p>
|
||||||
|
<div id="greenops-summary" style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:16px">로딩 중...</div>
|
||||||
|
${_nextCard("탄소 기록 추가","📊",`
|
||||||
|
<div style="display:flex;gap:8px;align-items:flex-end">
|
||||||
|
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:3px">서버 ID</label>
|
||||||
|
<input id="carbon-server" type="number" class="form-control" style="width:80px"></div>
|
||||||
|
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:3px">전력(W)</label>
|
||||||
|
<input id="carbon-watt" type="number" class="form-control" style="width:80px" placeholder="200"></div>
|
||||||
|
<button class="btn btn-primary" onclick="recordCarbon()">기록</button>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
${_nextCard("절감 분석","💡",`<div id="greenops-savings">로딩 중...</div>`)}`;
|
||||||
|
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=>`<div style="background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:16px;text-align:center">
|
||||||
|
<div style="font-size:20px">${s.icon}</div>
|
||||||
|
<div style="font-size:22px;font-weight:700;color:#003366">${s.val}</div>
|
||||||
|
<div style="font-size:12px;color:#64748b">${s.lab}</div>
|
||||||
|
</div>`).join("");
|
||||||
|
const sve=document.getElementById("greenops-savings");
|
||||||
|
if(sve) sve.innerHTML=`<div style="font-size:13px">
|
||||||
|
<div>베이스라인 월 배출: <strong>${savings.baseline_monthly_kg||0} kg</strong></div>
|
||||||
|
<div>실제 월 배출: <strong>${savings.actual_monthly_kg||0} kg</strong></div>
|
||||||
|
<div style="color:#166534;font-weight:700">절감: ${savings.saving_kg||0} kg (${savings.saving_pct||0}%)</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
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 = `
|
||||||
|
<h2>📡 Edge/IoT 디바이스 모니터링</h2>
|
||||||
|
<p style="color:#64748b;margin-bottom:16px">엣지 서버·IoT 센서·CCTV·키오스크 모니터링. 텔레메트리 Push 방식 (POST /api/edge/telemetry)</p>
|
||||||
|
${_nextCard("디바이스 등록","➕",`
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:8px">
|
||||||
|
<input id="edge-name" class="form-control" placeholder="디바이스명">
|
||||||
|
<select id="edge-type" class="form-control">
|
||||||
|
<option value="SERVER_EDGE">엣지 서버</option>
|
||||||
|
<option value="IOT_SENSOR">IoT 센서</option>
|
||||||
|
<option value="CCTV">CCTV</option>
|
||||||
|
<option value="KIOSK">키오스크</option>
|
||||||
|
<option value="NETWORK_EDGE">네트워크 장비</option>
|
||||||
|
</select>
|
||||||
|
<input id="edge-location" class="form-control" placeholder="위치">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="registerEdgeDevice()">등록</button>
|
||||||
|
`)}
|
||||||
|
${_nextCard("디바이스 목록","🗺️",`<div id="edge-devices">로딩 중...</div>`)}`;
|
||||||
|
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="<p style='color:#94a3b8;padding:12px;text-align:center'>디바이스 없음</p>";return;}
|
||||||
|
el.innerHTML=list.map(d=>`<div style="padding:10px;border-bottom:1px solid #f1f5f9;font-size:13px">
|
||||||
|
<strong>${d.name}</strong>
|
||||||
|
<span style="margin-left:8px;padding:2px 8px;background:#eff6ff;color:#1d4ed8;border-radius:8px;font-size:10px">${d.device_type}</span>
|
||||||
|
${d.location?`<span style="margin-left:6px;color:#64748b;font-size:12px">${d.location}</span>`:""}
|
||||||
|
<span style="margin-left:8px;padding:2px 8px;border-radius:8px;font-size:10px;background:${d.status==="ONLINE"?"#dcfce7":"#f1f5f9"};color:${d.status==="ONLINE"?"#166534":"#64748b"}">${d.status}</span>
|
||||||
|
${d.last_seen?`<span style="margin-left:6px;font-size:11px;color:#94a3b8">${new Date(d.last_seen).toLocaleString("ko-KR")}</span>`:""}
|
||||||
|
</div>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEnergyOptimizer() {
|
||||||
|
document.getElementById("content").innerHTML = `
|
||||||
|
<h2>⚡ 에너지 최적화 (Carbon-aware)</h2>
|
||||||
|
<p style="color:#64748b;margin-bottom:16px">Ollama 기반 에너지 효율 권고 + 탄소 낮은 시간대 배치 스케줄링</p>
|
||||||
|
${_nextCard("최적화 권고","💡",`
|
||||||
|
<button class="btn btn-primary" onclick="generateEnergyRecs()" style="margin-bottom:12px">🤖 AI 권고 생성</button>
|
||||||
|
<div id="energy-recs">로딩 중...</div>
|
||||||
|
`)}
|
||||||
|
${_nextCard("Carbon-aware 스케줄","🌱",`
|
||||||
|
<div style="display:flex;gap:8px;margin-bottom:8px">
|
||||||
|
<input id="sched-job" class="form-control" placeholder="작업명">
|
||||||
|
<input id="sched-cmd" class="form-control" placeholder="명령어">
|
||||||
|
<input id="sched-server" type="number" class="form-control" style="width:80px" placeholder="서버 ID">
|
||||||
|
<button class="btn btn-primary" onclick="scheduleJob()">예약</button>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:12px;color:#64748b">탄소 계수 ≤ 0.40 시간대 자동 배정 (한국 경부하: 23~08시)</p>
|
||||||
|
`)}`;
|
||||||
|
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="<p style='color:#94a3b8;padding:12px;text-align:center'>권고 없음 — AI 권고 생성 버튼 클릭</p>";return;}
|
||||||
|
el.innerHTML=recs.map(r=>`<div style="border:1px solid #e2e8f0;border-radius:8px;padding:12px;margin-bottom:8px">
|
||||||
|
<div style="font-weight:600;margin-bottom:4px">${r.rec_type} — ${r.description}</div>
|
||||||
|
<div style="font-size:12px;color:#64748b">절감 예상: ${r.saving_kwh} kWh/월</div>
|
||||||
|
${r.status==="PENDING"?`<button onclick="applyEnergyRec(${r.id})" style="margin-top:8px;padding:4px 10px;background:#003366;color:#fff;border:none;border-radius:4px;font-size:11px;cursor:pointer">적용</button>`:`<span style="color:#166534;font-size:11px">✅ ${r.status}</span>`}
|
||||||
|
</div>`).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");
|
||||||
|
}
|
||||||
|
|||||||
@ -211,6 +211,54 @@
|
|||||||
<div class="nav-sub-item" data-view="white_label">브랜딩 설정</div>
|
<div class="nav-sub-item" data-view="white_label">브랜딩 설정</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── GUARDiA 차세대 확장 ───────────────────── -->
|
||||||
|
<div class="nav-separator"></div>
|
||||||
|
|
||||||
|
<!-- AIOps 2.0 -->
|
||||||
|
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
|
||||||
|
<span class="nav-icon">🤖</span><span>AIOps 2.0</span>
|
||||||
|
<span class="nav-arrow" aria-hidden="true">▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-group-body" role="group">
|
||||||
|
<div class="nav-sub-item" data-view="agentic_aiops">에이전트 태스크 실행</div>
|
||||||
|
<div class="nav-sub-item" data-view="auto_remediation_v2">자율 교정 루프</div>
|
||||||
|
<div class="nav-sub-item" data-view="otel_tracing">분산 트레이싱 (OTLP)</div>
|
||||||
|
<div class="nav-sub-item" data-view="mlsecops">AI 모델 보안</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zero Trust + Supply Chain -->
|
||||||
|
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
|
||||||
|
<span class="nav-icon">🔐</span><span>Zero Trust + SBOM</span>
|
||||||
|
<span class="nav-arrow" aria-hidden="true">▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-group-body" role="group">
|
||||||
|
<div class="nav-sub-item" data-view="ztna">ZTNA 정책 관리</div>
|
||||||
|
<div class="nav-sub-item" data-view="sbom">SBOM 생성·관리</div>
|
||||||
|
<div class="nav-sub-item" data-view="n2sf">N²SF 보안체계</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- IDP -->
|
||||||
|
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
|
||||||
|
<span class="nav-icon">🏗️</span><span>개발자 플랫폼 (IDP)</span>
|
||||||
|
<span class="nav-arrow" aria-hidden="true">▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-group-body" role="group">
|
||||||
|
<div class="nav-sub-item" data-view="idp_catalog">서비스 카탈로그</div>
|
||||||
|
<div class="nav-sub-item" data-view="idp_template">Golden Path 템플릿</div>
|
||||||
|
<div class="nav-sub-item" data-view="idp_portal">셀프서비스 포털</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GreenOps + Edge -->
|
||||||
|
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
|
||||||
|
<span class="nav-icon">🌱</span><span>GreenOps + Edge/IoT</span>
|
||||||
|
<span class="nav-arrow" aria-hidden="true">▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-group-body" role="group">
|
||||||
|
<div class="nav-sub-item" data-view="greenops">탄소 대시보드</div>
|
||||||
|
<div class="nav-sub-item" data-view="edge_monitor">Edge/IoT 디바이스</div>
|
||||||
|
<div class="nav-sub-item" data-view="energy_optimizer">에너지 최적화</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── GUARDiA 기능 개선 v4 ─────────────────── -->
|
<!-- ── GUARDiA 기능 개선 v4 ─────────────────── -->
|
||||||
<div class="nav-separator"></div>
|
<div class="nav-separator"></div>
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user