zioinfo-mail/workspace/guardia-itsm/routers/admin.py
DESKTOP-TKLFCPR\ython cfe2901a55 refactor(structure): consolidate all projects under workspace/
- itsm/    -> workspace/guardia-itsm/
- manager/ -> workspace/guardia-manager/
- app/     -> workspace/guardia-messenger/
- manual/  -> workspace/guardia-docs/

workspace/zioinfo-web/ unchanged.
git mv preserves full commit history.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:50:56 +09:00

325 lines
14 KiB
Python

"""
GUARDiA ITSM 관리자 기능 API (GS인증 필수 요구사항)
엔드포인트:
GET /api/admin/about — 시스템 버전/빌드 정보 (GS인증 유지보수성)
POST /api/admin/backup — DB 백업 (GS인증 신뢰성 > 복구성)
GET /api/admin/backups — 백업 목록
POST /api/admin/restore/{filename} — 백업 복원
GET /api/admin/health — 시스템 상태 종합
GET /api/admin/errors/codes — 에러 코드 목록 (GS인증 기능적절성)
"""
from __future__ import annotations
import logging
import os
import shutil
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy import text
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
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin", tags=["admin"])
# 백업 저장 경로
BACKUP_DIR = Path(os.getenv("BACKUP_DIR", "./backups"))
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
# ── About / 버전 정보 (GS인증 유지보수성) ──────────────────────────────────
@router.get("/about")
async def get_about(_u: User = Depends(get_current_user)):
"""시스템 버전, 빌드 정보, 라이선스, 오픈소스 정보 (GS인증 유지보수성 > 분석성)."""
import sys, platform
return {
"product": "GUARDiA ITSM",
"version": "2.0.0",
"build_date": "2026-05-30",
"description": "AI 기반 레거시 인프라 자율 운영 플랫폼",
"vendor": "(주)지오정보기술",
"copyright": "Copyright © 2026 (주)지오정보기술 All Rights Reserved.",
"support": "support@zioinfo.co.kr",
"website": "https://www.zioinfo.co.kr",
"gs_cert": "GS 1등급 취득 예정 (2026년 12월)",
"environment": {
"python": sys.version.split()[0],
"os": platform.system() + " " + platform.release(),
"arch": platform.machine(),
},
"open_source": [
{"name": "FastAPI", "version": "0.115+", "license": "MIT"},
{"name": "SQLAlchemy", "version": "2.0+", "license": "MIT"},
{"name": "Pydantic", "version": "2.0+", "license": "MIT"},
{"name": "React.js", "version": "18.3+", "license": "MIT"},
{"name": "Chart.js", "version": "4.4+", "license": "MIT"},
{"name": "D3.js", "version": "7.0+", "license": "BSD-3"},
{"name": "Ollama", "version": "최신", "license": "MIT"},
{"name": "APScheduler", "version": "3.x", "license": "MIT"},
{"name": "paramiko", "version": "최신", "license": "LGPL"},
],
"api_count": 595,
"total_routes": 595,
}
# ── 에러 코드 목록 (GS인증 기능 적절성) ─────────────────────────────────────
@router.get("/errors/codes")
async def get_error_codes(_u: User = Depends(get_current_user)):
"""GUARDiA 에러 코드 및 해결 방법 목록 (GS인증 기능 적절성 > 오류 메시지)."""
return {
"error_codes": [
# 인증/권한
{"code": "AUTH_001", "http": 401, "message": "로그인이 필요합니다.", "solution": "다시 로그인해 주세요."},
{"code": "AUTH_002", "http": 401, "message": "아이디 또는 비밀번호가 틀렸습니다.", "solution": "입력 정보를 확인하고 다시 시도하세요."},
{"code": "AUTH_003", "http": 403, "message": "계정이 잠겼습니다.", "solution": "30분 후 다시 시도하거나 관리자에게 문의하세요."},
{"code": "AUTH_004", "http": 403, "message": "이 기능을 사용할 권한이 없습니다.", "solution": "관리자에게 권한 부여를 요청하세요."},
# SR
{"code": "SR_001", "http": 404, "message": "SR을 찾을 수 없습니다.", "solution": "SR ID를 확인하거나 목록에서 다시 검색하세요."},
{"code": "SR_002", "http": 400, "message": "이 상태에서는 해당 전이를 할 수 없습니다.", "solution": "SR 상태 흐름을 확인하세요 (접수→파싱→승인→진행→완료)."},
{"code": "SR_003", "http": 400, "message": "SR ID 목록이 비어 있습니다.", "solution": "처리할 SR을 하나 이상 선택하세요."},
{"code": "SR_004", "http": 400, "message": "한 번에 최대 100건까지 처리할 수 있습니다.", "solution": "SR을 100건 이하로 나누어 처리하세요."},
# 라이선스
{"code": "LIC_001", "http": 402, "message": "라이선스가 만료되었습니다.", "solution": "/license 페이지에서 라이선스를 갱신하세요."},
{"code": "LIC_002", "http": 409, "message": "무료 체험은 설치당 1회만 가능합니다.", "solution": "정식 라이선스를 구매하거나 지원팀에 문의하세요."},
{"code": "LIC_003", "http": 400, "message": "라이선스 한도를 초과했습니다.", "solution": "상위 에디션으로 업그레이드하세요."},
# CMDB
{"code": "CMDB_001", "http": 400, "message": "root SSH 계정은 등록할 수 없습니다.", "solution": "opsagent 등 일반 계정을 사용하세요 (보안 정책)."},
{"code": "CMDB_002", "http": 404, "message": "서버를 찾을 수 없습니다.", "solution": "CMDB에서 서버가 등록되어 있는지 확인하세요."},
# AI/LLM
{"code": "AI_001", "http": 503, "message": "AI 엔진에 연결할 수 없습니다.", "solution": "Ollama 서비스 상태를 확인하세요 (http://localhost:11434)."},
{"code": "AI_002", "http": 400, "message": "외부 AI API는 사용할 수 없습니다.", "solution": "온프레미스 Ollama만 허용됩니다 (보안 정책)."},
# 일반
{"code": "SYS_001", "http": 500, "message": "서버 오류가 발생했습니다.", "solution": "잠시 후 다시 시도하거나 관리자에게 문의하세요."},
{"code": "SYS_002", "http": 503, "message": "서비스가 일시적으로 사용할 수 없습니다.", "solution": "잠시 후 다시 시도하세요."},
{"code": "VAL_001", "http": 422, "message": "입력 값이 올바르지 않습니다.", "solution": "입력 필드의 형식과 범위를 확인하세요."},
]
}
# ── 백업 (GS인증 신뢰성 > 복구성) ──────────────────────────────────────────
@router.post("/backup")
async def create_backup(
db: AsyncSession = Depends(get_db),
cu: User = Depends(require_admin_role),
):
"""DB 및 설정 파일 백업 생성 (ADMIN 전용)."""
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
backup_name = f"guardia_backup_{timestamp}"
backup_path = BACKUP_DIR / backup_name
backup_path.mkdir(parents=True, exist_ok=True)
backed_up = []
# 1. SQLite DB 백업
from database import engine
db_url = str(engine.url)
if "sqlite" in db_url:
db_file = db_url.replace("sqlite+aiosqlite:///", "").replace("sqlite:///", "")
db_file = Path(db_file)
if db_file.exists():
shutil.copy2(db_file, backup_path / "guardia_itsm.db")
backed_up.append("guardia_itsm.db")
# 2. .env 파일 백업
env_paths = [
Path("./itsm/.env"),
Path("./.env"),
Path("../.env"),
]
for env_p in env_paths:
if env_p.exists():
shutil.copy2(env_p, backup_path / ".env")
backed_up.append(".env")
break
# 3. 업로드 파일 백업
upload_dir = Path("./uploads")
if upload_dir.exists():
shutil.copytree(upload_dir, backup_path / "uploads", dirs_exist_ok=True)
backed_up.append("uploads/")
# 4. 백업 메타데이터
meta = {
"backup_name": backup_name,
"created_at": datetime.utcnow().isoformat(),
"created_by": cu.username,
"files": backed_up,
"version": "2.0.0",
}
import json
(backup_path / "backup_meta.json").write_text(
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
)
# 5. ZIP 압축
zip_path = BACKUP_DIR / f"{backup_name}.zip"
shutil.make_archive(str(BACKUP_DIR / backup_name), "zip", str(BACKUP_DIR), backup_name)
shutil.rmtree(backup_path)
size_mb = round(zip_path.stat().st_size / 1024 / 1024, 2)
logger.info("백업 생성: %s (%.2fMB) by %s", zip_path.name, size_mb, cu.username)
return {
"message": f"백업 완료: {zip_path.name}",
"backup_name": zip_path.name,
"size_mb": size_mb,
"files": backed_up,
"created_at": datetime.utcnow().isoformat(),
}
@router.get("/backups")
async def list_backups(cu: User = Depends(require_admin_role)):
"""백업 파일 목록 조회 (ADMIN 전용)."""
backups = []
for f in sorted(BACKUP_DIR.glob("guardia_backup_*.zip"), reverse=True):
stat = f.stat()
backups.append({
"filename": f.name,
"size_mb": round(stat.st_size / 1024 / 1024, 2),
"created_at": datetime.fromtimestamp(stat.st_mtime).isoformat(),
})
return {"backups": backups, "total": len(backups)}
@router.get("/backups/{filename}/download")
async def download_backup(
filename: str,
cu: User = Depends(require_admin_role),
):
"""백업 파일 다운로드 (ADMIN 전용)."""
# 경로 조작 방지
safe_name = Path(filename).name
if not safe_name.startswith("guardia_backup_") or not safe_name.endswith(".zip"):
raise HTTPException(400, "올바르지 않은 백업 파일명입니다.")
file_path = BACKUP_DIR / safe_name
if not file_path.exists():
raise HTTPException(404, "백업 파일을 찾을 수 없습니다.")
return FileResponse(
path=str(file_path),
filename=safe_name,
media_type="application/zip",
)
@router.post("/restore/{filename}")
async def restore_backup(
filename: str,
db: AsyncSession = Depends(get_db),
cu: User = Depends(require_admin_role),
):
"""백업에서 복원 (ADMIN 전용 — 주의: 현재 데이터 덮어씀)."""
safe_name = Path(filename).name
if not safe_name.startswith("guardia_backup_") or not safe_name.endswith(".zip"):
raise HTTPException(400, "올바르지 않은 백업 파일명입니다.")
file_path = BACKUP_DIR / safe_name
if not file_path.exists():
raise HTTPException(404, "백업 파일을 찾을 수 없습니다.")
# 복원 전 현재 상태 임시 백업
pre_backup = BACKUP_DIR / f"pre_restore_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.zip"
# SQLite 복원
import zipfile
extract_dir = BACKUP_DIR / "restore_temp"
extract_dir.mkdir(exist_ok=True)
try:
with zipfile.ZipFile(file_path, "r") as zf:
zf.extractall(extract_dir)
# DB 파일 복원
from database import engine
db_url = str(engine.url)
if "sqlite" in db_url:
db_file = db_url.replace("sqlite+aiosqlite:///", "").replace("sqlite:///", "")
db_file = Path(db_file)
# 복원할 DB 파일 탐색
for f in extract_dir.rglob("guardia_itsm.db"):
shutil.copy2(f, db_file)
break
return {
"message": f"복원 완료: {filename}",
"warning": "서비스를 재시작해야 변경사항이 적용됩니다.",
"pre_backup": pre_backup.name if pre_backup.exists() else None,
}
finally:
shutil.rmtree(extract_dir, ignore_errors=True)
# ── 시스템 건강 상태 ──────────────────────────────────────────────────────────
@router.get("/health")
async def system_health(
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""시스템 종합 건강 상태 (GS인증 신뢰성 > 가용성)."""
import httpx
checks = {}
# DB 연결
try:
await db.execute(text("SELECT 1"))
checks["database"] = {"status": "ok", "message": "DB 연결 정상"}
except Exception as e:
checks["database"] = {"status": "error", "message": str(e)[:100]}
# Ollama
try:
async with httpx.AsyncClient(timeout=3.0) as c:
r = await c.get(os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") + "/api/version")
checks["ollama"] = {"status": "ok" if r.status_code == 200 else "warn",
"message": r.json().get("version", "연결됨")}
except Exception:
checks["ollama"] = {"status": "warn", "message": "Ollama 미연결 (AI 기능 제한)"}
# 디스크
import shutil as sh
disk = sh.disk_usage(".")
free_gb = round(disk.free / 1024**3, 1)
checks["disk"] = {
"status": "ok" if free_gb > 1 else "warn",
"message": f"여유 공간: {free_gb}GB",
"free_gb": free_gb,
}
# 라이선스
try:
from routers.license import get_license_status
lic = await get_license_status(db)
checks["license"] = {
"status": "ok" if lic.get("valid") else ("warn" if lic.get("expired") else "info"),
"message": lic.get("message", ""),
"edition": lic.get("edition"),
"days": lic.get("days_remaining"),
}
except Exception:
checks["license"] = {"status": "warn", "message": "라이선스 조회 실패"}
overall = "ok" if all(c["status"] == "ok" for c in checks.values()) else \
"error" if any(c["status"] == "error" for c in checks.values()) else "warn"
return {
"overall": overall,
"version": "2.0.0",
"uptime": "서버 시작 후 경과 시간",
"checks": checks,
"checked_at": datetime.utcnow().isoformat(),
}